"Knocking the Wooden Fish" - A Zen-inspired Mini Game
This article delves into the core technical aspects of implementing a "knocking wooden fish" game using HarmonyOS's ArkUI framework. Key features include animated interactions, state management, haptic feedback, and sound effects.
I. Architecture Design & Project Setup
1.1 Project Structure
The complete project consists of these core modules:
├── entry/src/main/ets/
│ ├── components/ // Custom UI components
│ ├── model/ // Data models (e.g., StateArray)
│ ├── pages/ // Page components (WoodenFishGame.ets)
│ └── resources/ // Media assets (wooden fish icons, sound effects)
This modular design separates the UI layer (pages), logic layer (model), and resource layer (resources), adhering to HarmonyOS development standards.
1.2 Component-based Development
Using the @Component
decorator to create reusable UI units, with @Entry
marking the page entry. Key states are managed via @State
:
@Entry
@Component
struct WoodenFishGame {
@State count: number = 0; // Merit counter
@State scaleWood: number = 1; // Wooden fish scale factor
@State rotateWood: number = 0; // Wooden fish rotation angle
@State animateTexts: Array<StateArray> = []; // Animation queue
private audioPlayer?: media.AVPlayer; // Audio player instance
private autoPlay: boolean = false; // Auto-tap flag
}
@State
enables reactive programming: UI elements automatically re-render when state variables change.
II. Animation System Deep Dive
2.1 Composite Animation for Knocking
The animation consists of two phases: compression (100ms) and elastic recovery (200ms), using animateTo
for smooth transitions:
playAnimation() {
// Phase 1: Quick compression + left rotation
animateTo({
duration: 100,
curve: Curve.Friction // Simulates physical resistance
}, () => {
this.scaleWood = 0.9; // Scale X/Y to 90%
this.rotateWood = -2; // Rotate counter-clockwise 2 degrees
});
// Phase 2: Elastic recovery
setTimeout(() => {
animateTo({
duration: 200,
curve: Curve.Linear // Ensures smooth recovery
}, () => {
this.scaleWood = 1;
this.rotateWood = 0;
});
}, 100); // Delay for animation chaining
}
Curve Selection:
-
Curve.Friction
: Simulates impact resistance -
Curve.Linear
: Ensures consistent recovery speed
2.2 Floating Merit Text Animation
Manages multiple animations using a dynamic array with unique IDs:
countAnimation() {
const animId = new Date().getTime(); // Unique timestamp ID
// Add new animation element
this.animateTexts = [...this.animateTexts, {
id: animId,
opacity: 1,
offsetY: 20
}];
// Fade out and float animation
animateTo({
duration: 800,
curve: Curve.EaseOut // Simulates inertia
}, () => {
this.animateTexts = this.animateTexts.map(item =>
item.id === animId ?
{ ...item, opacity: 0, offsetY: -100 } : item
);
});
// Clean up after animation completes
setTimeout(() => {
this.animateTexts = this.animateTexts.filter(t => t.id !== animId);
}, 800); // Synchronized with animation duration
}
Key Techniques:
-
Data-driven: Modifying
animateTexts
triggers UI re-render - Layered animation: Control opacity and vertical offset
- Memory optimization: Remove completed animations to prevent bloat
III. Multi-modal Interaction
3.1 Haptic Feedback
Utilizes the @kit.SensorServiceKit
vibration module:
vibrator.startVibration({
type: "time", // Time-based vibration
duration: 50 // 50ms short pulse
}, {
id: 0, // Vibrator ID
usage: 'alarm' // Resource usage scenario
});
Parameter Tuning:
- Duration: 50ms mimics the "crisp" feel of striking wood
- Intensity: Automatically optimized by HarmonyOS based on usage
3.2 Audio Playback & Resource Management
Implements sound effects with media.AVPlayer
:
aboutToAppear(): void {
media.createAVPlayer().then(player => {
this.audioPlayer = player;
this.audioPlayer.url = "";
this.audioPlayer.loop = false; // Disable looping
});
}
// Reset playback position on tap
if (this.audioPlayer) {
this.audioPlayer.seek(0); // Jump to 0ms
this.audioPlayer.play(); // Play sound
}
Best Practices:
- Preload resources: Initialize player during page setup
-
Avoid delays: Use
seek(0)
for instant sound -
Resource cleanup: Call
release()
inonPageHide
to prevent leaks
IV. Auto-Tap Functionality
4.1 Timer-State Integration
Toggle auto-tap mode with the Toggle
component:
// State toggle callback
Toggle({ type: ToggleType.Checkbox, isOn: false })
.onChange((isOn: boolean) => {
this.autoPlay = isOn;
if (isOn) {
this.startAutoPlay();
} else {
clearInterval(this.intervalId); // Clear timer
}
});
// Start timer
private intervalId: number = 0;
startAutoPlay() {
this.intervalId = setInterval(() => {
if (this.autoPlay) this.handleTap();
}, 400); // 400ms interval mimics human tapping
}
Key Improvements:
- Track timer with
intervalId
to ensure cleanup - 400ms interval balances smoothness and performance
4.2 Thread Safety & Performance
Risk: Frequent timer triggers may block the UI thread
Solution:
// Clear timer when page is hidden
aboutToDisappear() {
clearInterval(this.intervalId);
}
Ensures resources are released when the page is inactive.
V. UI Layout & Rendering Optimization
5.1 Layered Layout & Animation Composition
Use Stack
for overlapping UI elements:
Stack() {
// Wooden fish base (bottom layer)
Image($r("app.media.icon_wooden_fish"))
.width(280)
.height(280)
.margin({ top: -10 })
.scale({ x: this.scaleWood, y: this.scaleWood })
.rotate({ angle: this.rotateWood });
// Floating merit text (top layer)
ForEach(this.animateTexts, (item, index) => {
Text(`+1`)
.translate({ y: -item.offsetY * index }) // Staggered positioning
});
}
Rendering Optimization:
- Add
fixedSize(true)
to static images to avoid re-layout - Use
translate
instead ofmargin
for animations to reduce layout recalculations
5.2 Efficient State-to-UI Binding
Dynamic styling with chain methods:
Text(`Merit +${this.count}`)
.fontSize(20)
.fontColor('#4A4A4A')
.margin({
top: 20 + AppUtil.getStatusBarHeight() // Adaptive to notch screens
})
Adaptation Strategies:
- Use
AppUtil.getStatusBarHeight()
to avoid notch interference - Leverage HarmonyOS's flexible layout for screen size adaptability
VI. Complete Code
import { media } from '@kit.MediaKit';
import { vibrator } from '@kit.SensorServiceKit';
import { AppUtil, ToastUtil } from '@pura/harmony-utils';
import { StateArray } from '../model/HomeModel';
@Entry
@Component
struct WoodenFishGame {
@State count: number = 0;
@State scaleWood: number = 1;
@State rotateWood: number = 0;
audioPlayer?: media.AVPlayer;
// Auto-tap feature
autoPlay: boolean = false;
// New state variable
@State animateTexts: Array<StateArray> = []
aboutToAppear(): void {
media.createAVPlayer().then(player => {
this.audioPlayer = player
this.audioPlayer.url = ""
})
}
startAutoPlay() {
setInterval(() => {
if (this.autoPlay) {
this.handleTap();
}
}, 400);
}
// Knocking animation
playAnimation() {
animateTo({
duration: 100,
curve: Curve.Friction
}, () => {
this.scaleWood = 0.9;
this.rotateWood = -2;
});
setTimeout(() => {
animateTo({
duration: 200,
curve: Curve.Linear
}, () => {
this.scaleWood = 1;
this.rotateWood = 0;
});
}, 100);
}
// Tap handler
handleTap() {
this.count++;
this.playAnimation();
this.countAnimation();
// Add haptic feedback
vibrator.startVibration({
type: "time",
duration: 50
}, {
id: 0,
usage: 'alarm'
});
// Play sound effect
if (this.audioPlayer) {
this.audioPlayer.seek(0);
this.audioPlayer.play();
}
}
countAnimation() {
// Unique ID to prevent animation conflicts
const animId = new Date().getTime();
// Initialize animation state
this.animateTexts = [...this.animateTexts, {id: animId, opacity: 1, offsetY: 20}];
// Execute animation
animateTo({
duration: 800,
curve: Curve.EaseOut
}, () => {
this.animateTexts = this.animateTexts.map(item => {
if (item.id === animId) {
return { id: item.id, opacity: 0, offsetY: -100 };
}
return item;
});
});
// Clean up after animation
setTimeout(() => {
this.animateTexts = this.animateTexts.filter(t => t.id !== animId);
}, 800);
}
build() {
Column() {
// Counter display
Text(`Merit +${this.count}`)
.fontSize(20)
.margin({ top: 20 + AppUtil.getStatusBarHeight() })
// Wooden fish container
Stack() {
// Tappable area
Image($r("app.media.icon_wooden_fish"))
.width(280)
.height(280)
.margin({ top: -10 })
.scale({ x: this.scaleWood, y: this.scaleWood })
.rotate({ angle: this.rotateWood })
.onClick(() => this.handleTap())
// Floating text container
ForEach(this.animateTexts, (item: StateArray, index) => {
Text(`+1`)
.fontSize(24)
.fontColor('#FFD700')
.opacity(item.opacity)
.margin({ top: -100 }) // Initial position
.translate({ y: -item.offsetY * index }) // Vertical displacement
.animation({ duration: 800, curve: Curve.EaseOut })
})
}
.margin({ top: 50 })
// Auto-tap toggle (extended feature)
Row() {
Toggle({ type: ToggleType.Checkbox, isOn: false })
.onChange((isOn: boolean) => {
this.autoPlay = isOn;
if (isOn) {
this.startAutoPlay();
} else {
clearInterval();
}
})
Text("Auto-Tap")
}
.alignItems(VerticalAlign.Center)
.justifyContent(FlexAlign.Center)
.width("100%")
.position({ bottom: 100 })
}
.width('100%')
.height('100%')
.backgroundColor('#f0f0f0')
}
}
This implementation showcases HarmonyOS ArkUI's capabilities in building engaging, interactive applications with smooth animations, responsive state management, and multi-modal feedback.