DEV Community

CodeCao
CodeCao

Posted on

Implementing the Wooden Fish Knocking Mini Game on HarmonyOS

"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.

wooden fish

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) 
Enter fullscreen mode Exit fullscreen mode

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 } 
Enter fullscreen mode Exit fullscreen mode

@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 } 
Enter fullscreen mode Exit fullscreen mode

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 } 
Enter fullscreen mode Exit fullscreen mode

Key Techniques:

  1. Data-driven: Modifying animateTexts triggers UI re-render
  2. Layered animation: Control opacity and vertical offset
  3. 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 }); 
Enter fullscreen mode Exit fullscreen mode

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 } 
Enter fullscreen mode Exit fullscreen mode

Best Practices:

  1. Preload resources: Initialize player during page setup
  2. Avoid delays: Use seek(0) for instant sound
  3. Resource cleanup: Call release() in onPageHide 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 } 
Enter fullscreen mode Exit fullscreen mode

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); } 
Enter fullscreen mode Exit fullscreen mode

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 }); } 
Enter fullscreen mode Exit fullscreen mode

Rendering Optimization:

  • Add fixedSize(true) to static images to avoid re-layout
  • Use translate instead of margin 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 }) 
Enter fullscreen mode Exit fullscreen mode

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') } } 
Enter fullscreen mode Exit fullscreen mode

This implementation showcases HarmonyOS ArkUI's capabilities in building engaging, interactive applications with smooth animations, responsive state management, and multi-modal feedback.

Top comments (0)