diff --git a/README.md b/README.md index f2d409f..49a9409 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,8 @@ -> **Note:** To access all shared projects, get information about environment setup, and view other guides, please visit [Explore-In-HMOS-Wearable Index](https://github.com/Explore-In-HMOS-Wearable/hmos-index). - -# Block Game - -Block Game is a captivating puzzle game that challenges you to fit different block shapes onto a grid, clearing lines to score points. +# Block Game + +Block Game is a captivating puzzle game that challenges you to fit different block shapes onto a grid, clearing lines to score points. + +To access all shared projects, get information about environment setup, and view other guides, please visit [Explore-In-HMOS-Wearable Index](https://github.com/Explore-In-HMOS-Wearable/hmos-index). # Preview @@ -20,8 +20,8 @@ Quick entertainment during free time with its simple yet addictive gameplay mech # Tech Stack - **Languages**: ArkTS -- **Frameworks**: HarmonyOS SDK 5.1.0(18) -- **Tools**: DevEco Studio Vers 5.1.0.842 +- **Frameworks**: HarmonyOS SDK 6.0.0(20) +- **Tools**: DevEco Studio 6.x - **Libraries**: @kit.ArkUI, @ohos/lottie # Directory Structure diff --git a/build-profile.json5 b/build-profile.json5 index 74a76a7..2c8d13b 100644 --- a/build-profile.json5 +++ b/build-profile.json5 @@ -5,7 +5,7 @@ { "name": "default", "signingConfig": "default", - "compatibleSdkVersion": "5.1.0(18)", + "compatibleSdkVersion": "6.0.0(20)", "runtimeOS": "HarmonyOS", "buildOption": { "strictMode": { diff --git a/entry/src/main/ets/common/GameConstants.ets b/entry/src/main/ets/common/GameConstants.ets index f5a41a6..937a992 100644 --- a/entry/src/main/ets/common/GameConstants.ets +++ b/entry/src/main/ets/common/GameConstants.ets @@ -2,10 +2,12 @@ import { ShapeDefinition } from '../model/Game'; export class GameConstants { static readonly GRID_SIZE = 5; - static readonly BLOCK_PREVIEW_SIZE = 15; + static readonly BLOCK_PREVIEW_SIZE = 17; static readonly CLEAR_ANIMATION_DELAY_MS = 60; static readonly GAMEOVER_FILL_DELAY_MS = 5; static readonly ALL_SHAPES: ShapeDefinition[] = [ + + // ── O / Square pieces ──────────────────────────────────────────────────── { name: 'O_Square_3x3', cells: [ @@ -15,7 +17,6 @@ export class GameConstants { ], color: $r('app.color.shape_color_O') }, - { name: 'rect_horizontal', cells: [ @@ -33,7 +34,13 @@ export class GameConstants { ], color: $r('app.color.shape_color_O') }, + { + name: 'O_Square_2x2', + cells: [{ row: 0, col: 0 }, { row: 0, col: 1 }, { row: 1, col: 0 }, { row: 1, col: 1 }], + color: $r('app.color.shape_color_O') + }, + // ── I pieces ───────────────────────────────────────────────────────────── { name: 'I_Vertical', cells: [{ row: 0, col: 0 }, { row: 1, col: 0 }, { row: 2, col: 0 }, { row: 3, col: 0 }], @@ -45,12 +52,7 @@ export class GameConstants { color: $r('app.color.shape_color_I') }, - { - name: 'O_Square_2x2', - cells: [{ row: 0, col: 0 }, { row: 0, col: 1 }, { row: 1, col: 0 }, { row: 1, col: 1 }], - color: $r('app.color.shape_color_O') - }, - + // ── L pieces ───────────────────────────────────────────────────────────── { name: 'L_0', cells: [{ row: 0, col: 0 }, { row: 1, col: 0 }, { row: 2, col: 0 }, { row: 2, col: 1 }], @@ -71,35 +73,81 @@ export class GameConstants { cells: [{ row: 0, col: 0 }, { row: 0, col: 1 }, { row: 0, col: 2 }, { row: 1, col: 2 }], color: $r('app.color.shape_color_L') }, + { + name: 'L_2_0', + cells: [ + { row: 0, col: 0 }, { row: 1, col: 0 }, { row: 2, col: 0 }, { row: 2, col: 1 }, { row: 2, col: 2 } + ], + color: $r('app.color.shape_color_L') + }, + // ── J pieces ───────────────────────────────────────────────────────────── { name: 'J_0', cells: [{ row: 0, col: 1 }, { row: 1, col: 1 }, { row: 2, col: 1 }, { row: 2, col: 0 }], - color: $r('app.color.shape_color_I') + color: $r('app.color.shape_color_J') }, { name: 'J_90', - cells: [{ row: 0, col: 0 }, { row: 1, col: 0 }, { row: 1, col: 1 }, { row: 1, col: 2 }], - color: $r('app.color.shape_color_I') + cells: [{ row: 0, col: 0 }, { row: 0, col: 1 }, { row: 0, col: 2 }, { row: 1, col: 2 }], + color: $r('app.color.shape_color_J') }, { name: 'J_180', cells: [{ row: 0, col: 0 }, { row: 0, col: 1 }, { row: 1, col: 0 }, { row: 2, col: 0 }], - color: $r('app.color.shape_color_I') + color: $r('app.color.shape_color_J') }, { name: 'J_270', cells: [{ row: 0, col: 0 }, { row: 0, col: 1 }, { row: 0, col: 2 }, { row: 1, col: 0 }], - color: $r('app.color.shape_color_I') + color: $r('app.color.shape_color_J') }, + + // ── T pieces ───────────────────────────────────────────────────────────── { - name: 'L_2_0', - cells: [ - { row: 0, col: 0 }, { row: 1, col: 0 }, { row: 2, col: 0 }, { row: 2, col: 1 }, { row: 2, col: 2 } - ], - color: $r('app.color.shape_color_L') - } + name: 'T_0', + cells: [{ row: 0, col: 0 }, { row: 0, col: 1 }, { row: 0, col: 2 }, { row: 1, col: 1 }], + color: $r('app.color.shape_color_T') + }, + { + name: 'T_90', + cells: [{ row: 0, col: 0 }, { row: 1, col: 0 }, { row: 1, col: 1 }, { row: 2, col: 0 }], + color: $r('app.color.shape_color_T') + }, + { + name: 'T_180', + cells: [{ row: 0, col: 1 }, { row: 1, col: 0 }, { row: 1, col: 1 }, { row: 1, col: 2 }], + color: $r('app.color.shape_color_T') + }, + { + name: 'T_270', + cells: [{ row: 0, col: 1 }, { row: 1, col: 0 }, { row: 1, col: 1 }, { row: 2, col: 1 }], + color: $r('app.color.shape_color_T') + }, + + // ── S pieces ───────────────────────────────────────────────────────────── + { + name: 'S_0', + cells: [{ row: 0, col: 1 }, { row: 0, col: 2 }, { row: 1, col: 0 }, { row: 1, col: 1 }], + color: $r('app.color.shape_color_S') + }, + { + name: 'S_90', + cells: [{ row: 0, col: 0 }, { row: 1, col: 0 }, { row: 1, col: 1 }, { row: 2, col: 1 }], + color: $r('app.color.shape_color_S') + }, + // ── Z pieces ───────────────────────────────────────────────────────────── + { + name: 'Z_0', + cells: [{ row: 0, col: 0 }, { row: 0, col: 1 }, { row: 1, col: 1 }, { row: 1, col: 2 }], + color: $r('app.color.shape_color_Z') + }, + { + name: 'Z_90', + cells: [{ row: 0, col: 1 }, { row: 1, col: 0 }, { row: 1, col: 1 }, { row: 2, col: 0 }], + color: $r('app.color.shape_color_Z') + } ]; -} \ No newline at end of file +} diff --git a/entry/src/main/ets/components/GameBoard.ets b/entry/src/main/ets/components/GameBoard.ets index 401fbf6..7c4785d 100644 --- a/entry/src/main/ets/components/GameBoard.ets +++ b/entry/src/main/ets/components/GameBoard.ets @@ -23,12 +23,21 @@ const BOARD_CELLS: Array = buildBoardCells(GameConstants.GRID_SIZE); export struct GameBoard { @Link gridState: (Resource | null)[][]; @Link cellAnimationState: Map; - @Link highlightCells: Cell[]; + @Watch('onHighlightCellsChange') @Link highlightCells: Cell[]; @Prop currentShape: ShapeDefinition; @Link dropZoneArea: Area; + @State highlightCellKeys: Set = new Set(); + + aboutToAppear(): void { + this.onHighlightCellsChange(); + } + + private onHighlightCellsChange(): void { + this.highlightCellKeys = new Set(this.highlightCells.map((cell: Cell) => `${cell.row}-${cell.col}`)); + } private isHighlighted(row: number, col: number): boolean { - return this.highlightCells.some((c: Cell) => c.row === row && c.col === col); + return this.highlightCellKeys.has(`${row}-${col}`); } build() { @@ -37,11 +46,10 @@ export struct GameBoard { GridItem() { if (this.gridState[cell.row][cell.col] !== null) { Text() - .width('100%') - .height('100%') + .width('90%') + .height('90%') .backgroundColor(this.gridState[cell.row][cell.col] as Resource) .borderRadius(4) - .padding(4) .scale(this.cellAnimationState.get(cell.id) === 'clearing' ? { x: 1.3, y: 1.3 } : { x: 1, y: 1 }) @@ -49,15 +57,15 @@ export struct GameBoard { .animation({ duration: 300, curve: Curve.EaseIn }) } else if (this.isHighlighted(cell.row, cell.col)) { Text() - .width('95%') - .height('95%') + .width('90%') + .height('90%') .backgroundColor(this.currentShape.color) .opacity(0.5) .borderRadius(4) } else { Column() - .width('95%') - .height('95%') + .width('90%') + .height('90%') .backgroundColor(Color.Transparent) .border({ width: 1, color: '#20284E' }) } @@ -67,10 +75,10 @@ export struct GameBoard { .backgroundColor('#242C54') .columnsTemplate(Array(GameConstants.GRID_SIZE).fill('1fr').join(' ')) .rowsTemplate(Array(GameConstants.GRID_SIZE).fill('1fr').join(' ')) - .width('50%') + .width('62%') .aspectRatio(1) .onAreaChange((_, area) => this.dropZoneArea = area) - .margin({ bottom: 15 }) + .margin({ bottom: 10 }) .borderRadius(4) .padding(4) } diff --git a/entry/src/main/ets/components/SourceArea.ets b/entry/src/main/ets/components/SourceArea.ets index e034aec..2e238f3 100644 --- a/entry/src/main/ets/components/SourceArea.ets +++ b/entry/src/main/ets/components/SourceArea.ets @@ -1,9 +1,21 @@ import { GameConstants } from '../common/GameConstants'; import { Cell, PlacementInfo, ShapeDefinition } from '../model/Game'; +import { + applyShapeToGrid, + canPlaceShape, + canShapeBePlacedAnywhere, + clearCells, + getCompletedLines +} from '../model/GameLogic'; +import { secureRandomNumber } from '../utils/SecureRandom'; +import vibrator from '@ohos.vibrator'; + +const PREVIEW_GAP = 3; +const SOURCE_TRAY_HEIGHT = 94; @Component export struct SourceArea { - @Link currentShape: ShapeDefinition; + @Watch('onShapeChanged') @Link currentShape: ShapeDefinition; @Link isGameOver: boolean; @Link visualOffsetX: number; @Link visualOffsetY: number; @@ -14,147 +26,133 @@ export struct SourceArea { @Link targetScore: number; @Link cellAnimationState: Map; @Link bestScore: number; + @Watch('onResetCounterChanged') @Link resetCounter: number; @State isLineClearing: boolean = false; @State showShape: boolean = false; + @State isDragInvalid: boolean = false; + @State shapeMaxCol: number = 0; + @State shapeMaxRow: number = 0; + @State snapBackDuration: number = 0; @State draggableBlockArea: Area = { width: 0, height: 0, position: { x: 0, y: 0 }, globalPosition: { x: 0, y: 0 } }; + private hoverAnchorRow: number = -999; + private hoverAnchorCol: number = -999; aboutToAppear(): void { - this.generateNewShapeAndCheckGameOver() + this.updateShapeDimensions(); + this.generateNewShapeAndCheckGameOver(); + } + + private onShapeChanged(): void { + this.updateShapeDimensions(); + } + + private onResetCounterChanged(): void { + this.showShape = false; + this.isDragInvalid = false; + this.isLineClearing = false; + this.generateNewShapeAndCheckGameOver(); + } + + private updateShapeDimensions(): void { + this.shapeMaxCol = Math.max(...this.currentShape.cells.map(c => c.col)); + this.shapeMaxRow = Math.max(...this.currentShape.cells.map(c => c.row)); + } + + private triggerHaptic(duration: number): void { + vibrator.startVibration( + { type: 'time', duration: duration }, + { id: 0, usage: 'touch' } + ).catch(() => {}); } private resetDraggableBlock() { + this.snapBackDuration = 220; this.visualOffsetX = 0; this.visualOffsetY = 0; this.highlightCells = []; + this.hoverAnchorRow = -999; + this.hoverAnchorCol = -999; } - getPlacementInfo(finalGlobalX: number, finalGlobalY: number): PlacementInfo { + private getAnchorCell(finalGlobalX: number, finalGlobalY: number): Cell { const dropZoneWidth = this.dropZoneArea.width as number; const dropZoneHeight = this.dropZoneArea.height as number; const dropZoneGlobalX = this.dropZoneArea.globalPosition.x as number; const dropZoneGlobalY = this.dropZoneArea.globalPosition.y as number; const cellWidth = dropZoneWidth / GameConstants.GRID_SIZE; const cellHeight = dropZoneHeight / GameConstants.GRID_SIZE; - const anchorRelativeX = finalGlobalX - dropZoneGlobalX; - const anchorRelativeY = finalGlobalY - dropZoneGlobalY; - const anchorCol = Math.floor(anchorRelativeX / cellWidth); - const anchorRow = Math.floor(anchorRelativeY / cellHeight); - const targetCells: Cell[] = []; - let canPlace = true; - - for (const shapeCell of this.currentShape.cells) { - const targetRow = anchorRow + shapeCell.row; - const targetCol = anchorCol + shapeCell.col; - if (targetRow < 0 || targetRow >= GameConstants.GRID_SIZE || - targetCol < 0 || targetCol >= GameConstants.GRID_SIZE || - this.gridState[targetRow][targetCol] !== null) { - canPlace = false; - break; - } - targetCells.push({ row: targetRow, col: targetCol }); + return { + row: Math.floor((finalGlobalY - dropZoneGlobalY) / cellHeight), + col: Math.floor((finalGlobalX - dropZoneGlobalX) / cellWidth) + }; + } + + private updateHighlightForPointer(finalGlobalX: number, finalGlobalY: number): void { + const anchor = this.getAnchorCell(finalGlobalX, finalGlobalY); + if (anchor.row === this.hoverAnchorRow && anchor.col === this.hoverAnchorCol) { + return; } - return { targetCells, canPlace }; + + this.hoverAnchorRow = anchor.row; + this.hoverAnchorCol = anchor.col; + const placement = this.getPlacementInfo(finalGlobalX, finalGlobalY); + this.highlightCells = placement.canPlace ? placement.targetCells : []; } - checkForCompletedLines(placedCells: Cell[]): boolean { - const rowsToCheck = new Set(placedCells.map(c => c.row)); - const colsToCheck = new Set(placedCells.map(c => c.col)); - const cellsToClear: Cell[] = []; - rowsToCheck.forEach(rowIndex => { - if (this.gridState[rowIndex].every(cell => cell !== null)) { - for (let j = 0; j < GameConstants.GRID_SIZE; j++) { - cellsToClear.push({ row: rowIndex, col: j }); - } - } - }); - colsToCheck.forEach(colIndex => { - let isColFull = true; - for (let i = 0; i < GameConstants.GRID_SIZE; i++) { - if (this.gridState[i][colIndex] === null) { - isColFull = false; - break; - } - } - if (isColFull) { - for (let i = 0; i < GameConstants.GRID_SIZE; i++) { - if (!cellsToClear.some(cell => cell.row === i && cell.col === colIndex)) { - cellsToClear.push({ row: i, col: colIndex }); - } - } - } - }); + getPlacementInfo(finalGlobalX: number, finalGlobalY: number): PlacementInfo { + const anchor = this.getAnchorCell(finalGlobalX, finalGlobalY); + return canPlaceShape(this.gridState, this.currentShape.cells, anchor.row, anchor.col); + } - if (cellsToClear.length > 0) { + checkForCompletedLines(placedCells: Cell[]): boolean { + const completedLines = getCompletedLines(this.gridState, placedCells); + if (completedLines.cellsToClear.length > 0) { this.isLineClearing = true; - this.targetScore += cellsToClear.length * 10; - - cellsToClear.forEach((cell, index) => { - const key = `${cell.row}-${cell.col}`; - setTimeout(() => { - this.cellAnimationState.set(key, 'clearing'); - this.cellAnimationState = new Map(this.cellAnimationState); - }, index * GameConstants.CLEAR_ANIMATION_DELAY_MS); + this.targetScore += completedLines.cellsToClear.length * 10; + this.triggerHaptic(120); + + const clearingState = new Map(); + completedLines.cellsToClear.forEach((cell: Cell) => { + clearingState.set(`${cell.row}-${cell.col}`, 'clearing'); }); + this.cellAnimationState = clearingState; - const totalAnimationTime = cellsToClear.length * GameConstants.CLEAR_ANIMATION_DELAY_MS + 300; + const totalAnimationTime = 300; setTimeout(() => { - const newGrid = [...this.gridState.map(row => [...row])]; - cellsToClear.forEach(cell => { - newGrid[cell.row][cell.col] = null; - }); - this.gridState = newGrid; + this.gridState = clearCells(this.gridState, completedLines.cellsToClear) as (Resource | null)[][]; this.isLineClearing = false; - this.cellAnimationState.clear(); + this.cellAnimationState = new Map(); this.generateNewShapeAndCheckGameOver(); }, totalAnimationTime); return true; } - return false + return false; } generateNewShapeAndCheckGameOver() { this.generateNewShape(); if (!this.canNextShapeBePlaced()) { this.isGameOver = true; - if (this.targetScore > this.bestScore) { + if (this.targetScore > (this.bestScore ?? 0)) { AppStorage.set('bestScore', this.targetScore); this.bestScore = this.targetScore; } + this.triggerHaptic(280); } } canNextShapeBePlaced(): boolean { - for (let row = 0; row < GameConstants.GRID_SIZE; row++) { - for (let col = 0; col < GameConstants.GRID_SIZE; col++) { - let canPlaceAtThisPosition = true; - for (const shapeCell of this.currentShape.cells) { - const targetRow = row + shapeCell.row; - const targetCol = col + shapeCell.col; - - if (targetRow >= GameConstants.GRID_SIZE || - targetCol >= GameConstants.GRID_SIZE || - this.gridState[targetRow][targetCol] !== null - ) { - canPlaceAtThisPosition = false; - break; - } - } - if (canPlaceAtThisPosition) { - return true; - } - } - } - return false; + return canShapeBePlacedAnywhere(this.gridState, this.currentShape.cells, GameConstants.GRID_SIZE); } - generateNewShape() { - const randomIndex = Math.floor(rand() * GameConstants.ALL_SHAPES.length); + generateNewShape(): void { + const randomIndex = Math.floor(secureRandomNumber() * GameConstants.ALL_SHAPES.length); this.currentShape = GameConstants.ALL_SHAPES[randomIndex]; this.showShape = true; } @@ -162,45 +160,48 @@ export struct SourceArea { build() { Stack() { Stack() { - ForEach(this.currentShape.cells, (cell: Cell, index: number) => { + ForEach(this.currentShape.cells, (cell: Cell) => { if (this.showShape) { Text() .width(GameConstants.BLOCK_PREVIEW_SIZE) .height(GameConstants.BLOCK_PREVIEW_SIZE) - .backgroundColor(this.currentShape.color) + .backgroundColor(this.isDragInvalid ? Color.Red : this.currentShape.color) .borderRadius(4) .position({ - x: cell.col * (GameConstants.BLOCK_PREVIEW_SIZE + 2), - y: cell.row * (GameConstants.BLOCK_PREVIEW_SIZE + 2) + x: cell.col * (GameConstants.BLOCK_PREVIEW_SIZE + PREVIEW_GAP), + y: cell.row * (GameConstants.BLOCK_PREVIEW_SIZE + PREVIEW_GAP) }) } - - }, (cell: Cell, index: number) => `${this.currentShape.name}-${cell.row}-${cell.col}`) + }, (cell: Cell) => `${this.currentShape.name}-${cell.row}-${cell.col}`) } - .width((Math.max(...this.currentShape.cells.map(c => c.col)) + 1) * GameConstants.BLOCK_PREVIEW_SIZE) - .height((Math.max(...this.currentShape.cells.map(c => c.row)) + 1) * GameConstants.BLOCK_PREVIEW_SIZE) + .width(this.shapeMaxCol * (GameConstants.BLOCK_PREVIEW_SIZE + PREVIEW_GAP) + GameConstants.BLOCK_PREVIEW_SIZE) + .height(this.shapeMaxRow * (GameConstants.BLOCK_PREVIEW_SIZE + PREVIEW_GAP) + GameConstants.BLOCK_PREVIEW_SIZE) .onAreaChange((_, area) => { this.draggableBlockArea = area; }) .translate({ x: this.visualOffsetX, y: this.visualOffsetY }) - .animation({ duration: 500, curve: Curve.EaseOut }) - .scale(this.isDragging ? { x: 1.2, y: 1.2 } : { x: 1.0, y: 1.0 }) + .animation({ duration: this.snapBackDuration, curve: Curve.EaseOut }) + .scale(this.isDragging ? { x: 1.24, y: 1.24 } : { x: 1.0, y: 1.0 }) + .opacity(this.isDragging ? 0.92 : 1) .zIndex(100) - .margin(10) + .margin(6) .gesture( PanGesture({ distance: (this.isGameOver || this.isLineClearing) ? 10000 : 10 }) .onActionStart(() => { - this.isDragging = true + this.snapBackDuration = 0; + this.hoverAnchorRow = -999; + this.hoverAnchorCol = -999; + this.isDragging = true; }) .onActionUpdate((e: GestureEvent) => { this.visualOffsetX = e.offsetX; - this.visualOffsetY = e.offsetY - 30; + const liftOffset = (this.draggableBlockArea.height as number) * 0.1; + this.visualOffsetY = e.offsetY - liftOffset; const blockGlobalX = (this.draggableBlockArea.globalPosition.x as number); const blockGlobalY = (this.draggableBlockArea.globalPosition.y as number); - const placement = this.getPlacementInfo(blockGlobalX + e.offsetX, blockGlobalY + e.offsetY); - this.highlightCells = placement.canPlace ? placement.targetCells : []; + this.updateHighlightForPointer(blockGlobalX + e.offsetX, blockGlobalY + e.offsetY); }) .onActionEnd((e: GestureEvent) => { this.isDragging = false; @@ -211,28 +212,30 @@ export struct SourceArea { if (placement.canPlace) { this.showShape = false; - const newGrid = [...this.gridState.map(row => [...row])]; - placement.targetCells.forEach(cell => { - newGrid[cell.row][cell.col] = this.currentShape.color; - }); - this.gridState = newGrid; + this.gridState = applyShapeToGrid( + this.gridState, + placement.targetCells, + this.currentShape.color + ) as (Resource | null)[][]; this.targetScore += 10; + this.triggerHaptic(30); const lineWasCleared = this.checkForCompletedLines(placement.targetCells); if (!lineWasCleared) { this.generateNewShapeAndCheckGameOver(); } + } else { + this.isDragInvalid = true; + this.triggerHaptic(20); + setTimeout(() => { + this.isDragInvalid = false; + }, 200); } this.resetDraggableBlock(); }) ) } - .height(30) + .height(Math.max(SOURCE_TRAY_HEIGHT, this.shapeMaxRow * (GameConstants.BLOCK_PREVIEW_SIZE + PREVIEW_GAP) + + GameConstants.BLOCK_PREVIEW_SIZE + 14)) .width('100%') } } -let randSeed: number = Date.now() & 0xffffffff; - -function rand(): number { - randSeed = (1664525 * randSeed + 1013904223) >>> 0; - return randSeed / 0xffffffff; -} diff --git a/entry/src/main/ets/entryability/EntryAbility.ets b/entry/src/main/ets/entryability/EntryAbility.ets index 9156ecd..4ab41e5 100644 --- a/entry/src/main/ets/entryability/EntryAbility.ets +++ b/entry/src/main/ets/entryability/EntryAbility.ets @@ -17,9 +17,9 @@ export default class EntryAbility extends UIAbility { onWindowStageCreate(windowStage: window.WindowStage): void { // Main window is created, set main page for this ability Logger.info(TAG, '%{public}s', 'Ability onWindowStageCreate'); + PersistentStorage.persistProp('bestScore', 0); windowStage.loadContent('pages/Index', (err, data) => { AppStorage.setOrCreate('uiContext', windowStage.getMainWindowSync().getUIContext()); - PersistentStorage.persistProp('bestScore',0); if (err.code) { Logger.error(TAG, 'Failed to load the content. Cause: %{public}s', JSON.stringify(err) ?? ''); return; @@ -41,17 +41,14 @@ export default class EntryAbility extends UIAbility { } onWindowStageDestroy(): void { - // Main window is destroyed, release UI related resources - Logger.error(TAG, 'testTag', '%{public}s', 'Ability onWindowStageDestroy'); + Logger.info(TAG, '%{public}s', 'Ability onWindowStageDestroy'); } onForeground(): void { - // Ability has brought to foreground - Logger.error(TAG, 'testTag', '%{public}s', 'Ability onForeground'); + Logger.info(TAG, '%{public}s', 'Ability onForeground'); } onBackground(): void { - // Ability has back to background - Logger.error(TAG, 'testTag', '%{public}s', 'Ability onBackground'); + Logger.info(TAG, '%{public}s', 'Ability onBackground'); } -} \ No newline at end of file +} diff --git a/entry/src/main/ets/model/GameLogic.ets b/entry/src/main/ets/model/GameLogic.ets new file mode 100644 index 0000000..8e36ed8 --- /dev/null +++ b/entry/src/main/ets/model/GameLogic.ets @@ -0,0 +1,287 @@ +/** + * GameLogic.ets - Pure game logic module + * + * Design rules: + * - No ArkUI component imports, no AppStorage, no timers, no $r(). + * - Grid cells are typed as (object | null). null = empty; any non-null = occupied. + * In production code the non-null value will be a Resource (color). + * In unit tests any plain object {} is a valid occupied marker. + * - All functions are pure: they never mutate their input, always return new objects. + * - RNG is deterministic: seed is passed explicitly so tests can be reproducible. + */ + +import { Cell, PlacementInfo } from './Game'; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +/** null = empty cell, non-null object = occupied (holds a color Resource in production) */ +export type CellValue = Resource | object | null; + +/** 2-D grid of cell values. Row-major: grid[row][col]. */ +export type GridState = CellValue[][]; + +/** A minimal shape descriptor used by functions that need only cell geometry. */ +export interface ShapeLayout { + name: string; + cells: Cell[]; +} + +export interface RngResult { + seed: number; + value: number; // always in [0, 1) +} + +export interface ShapeIndexResult { + seed: number; + index: number; +} + +export interface CompletedLinesResult { + cellsToClear: Cell[]; + clearedRows: number[]; + clearedCols: number[]; +} + +// --------------------------------------------------------------------------- +// Grid utilities +// --------------------------------------------------------------------------- + +/** + * Creates a new empty grid of the given size. + * All cells are null. + */ +export function createEmptyGrid(size: number): GridState { + const grid: GridState = []; + for (let r = 0; r < size; r++) { + const row: CellValue[] = []; + for (let c = 0; c < size; c++) { + row.push(null); + } + grid.push(row); + } + return grid; +} + +/** + * Returns a deep copy of the grid. + * Prevents accidental mutation of the source state. + */ +export function cloneGrid(grid: GridState): GridState { + return grid.map(row => [...row]); +} + +// --------------------------------------------------------------------------- +// RNG +// --------------------------------------------------------------------------- + +/** + * Advances the LCG seed by one step and returns the new seed and a float in [0, 1). + * Divisor is 0x100000000 so value is strictly less than 1.0. + */ +export function nextRand(seed: number): RngResult { + const nextSeed = (1664525 * seed + 1013904223) >>> 0; + return { + seed: nextSeed, + value: nextSeed / 0x100000000 + }; +} + +/** + * Returns a shape index in [0, shapeCount) using the given seed. + * Throws if shapeCount <= 0. + */ +export function randomShapeIndex(seed: number, shapeCount: number): ShapeIndexResult { + if (shapeCount <= 0) { + throw new Error(`randomShapeIndex: shapeCount must be > 0, got ${shapeCount}`); + } + const rng = nextRand(seed); + return { + seed: rng.seed, + index: Math.floor(rng.value * shapeCount) + }; +} + +// --------------------------------------------------------------------------- +// Placement +// --------------------------------------------------------------------------- + +/** + * Checks whether a shape can be placed with its top-left cell at (anchorRow, anchorCol). + * Returns canPlace=false if any target cell is out of bounds or already occupied. + * Does NOT mutate the grid. + */ +export function canPlaceShape( + grid: GridState, + shapeCells: Cell[], + anchorRow: number, + anchorCol: number +): PlacementInfo { + const size = grid.length; + const targetCells: Cell[] = []; + let canPlace = true; + + for (const shapeCell of shapeCells) { + const targetRow = anchorRow + shapeCell.row; + const targetCol = anchorCol + shapeCell.col; + if ( + targetRow < 0 || + targetRow >= size || + targetCol < 0 || + targetCol >= size || + grid[targetRow][targetCol] !== null + ) { + canPlace = false; + break; + } + targetCells.push({ row: targetRow, col: targetCol }); + } + return { targetCells, canPlace }; +} + +/** + * Returns a new grid with the given cells filled with color. + * The original grid is never modified. + */ +export function applyShapeToGrid( + grid: GridState, + targetCells: Cell[], + color: CellValue +): GridState { + const newGrid = cloneGrid(grid); + for (const cell of targetCells) { + newGrid[cell.row][cell.col] = color; + } + return newGrid; +} + +/** + * Returns true if the shape can be placed in at least one valid position. + * Iterates all (row, col) anchor positions within the grid. + */ +export function canShapeBePlacedAnywhere( + grid: GridState, + shapeCells: Cell[], + gridSize: number +): boolean { + for (let row = 0; row < gridSize; row++) { + for (let col = 0; col < gridSize; col++) { + if (canPlaceShape(grid, shapeCells, row, col).canPlace) { + return true; + } + } + } + return false; +} + +// --------------------------------------------------------------------------- +// Line clearing +// --------------------------------------------------------------------------- + +/** + * Inspects rows and columns touched by the placed cells. + * A line (row or column) is complete when every cell in it is non-null. + * Returns the set of cells to clear (no duplicates at row/column intersections), + * plus the indices of cleared rows and columns for score/animation purposes. + */ +export function getCompletedLines( + grid: GridState, + placedCells: Cell[] +): CompletedLinesResult { + const size = grid.length; + const rowsToCheck = new Set(placedCells.map(c => c.row)); + const colsToCheck = new Set(placedCells.map(c => c.col)); + const cellsToClear: Cell[] = []; + const clearedRows: number[] = []; + const clearedCols: number[] = []; + + rowsToCheck.forEach(rowIndex => { + if (grid[rowIndex].every(cell => cell !== null)) { + clearedRows.push(rowIndex); + for (let j = 0; j < size; j++) { + cellsToClear.push({ row: rowIndex, col: j }); + } + } + }); + + colsToCheck.forEach(colIndex => { + let isColFull = true; + for (let i = 0; i < size; i++) { + if (grid[i][colIndex] === null) { + isColFull = false; + break; + } + } + if (isColFull) { + clearedCols.push(colIndex); + for (let i = 0; i < size; i++) { + // Avoid duplicating cells already added by a cleared row + if (!cellsToClear.some(c => c.row === i && c.col === colIndex)) { + cellsToClear.push({ row: i, col: colIndex }); + } + } + } + }); + + return { cellsToClear, clearedRows, clearedCols }; +} + +/** + * Returns a new grid with the specified cells set to null. + * The original grid is never modified. + */ +export function clearCells(grid: GridState, cellsToClear: Cell[]): GridState { + const newGrid = cloneGrid(grid); + for (const cell of cellsToClear) { + newGrid[cell.row][cell.col] = null; + } + return newGrid; +} + +// --------------------------------------------------------------------------- +// Scoring +// --------------------------------------------------------------------------- + +/** + * Calculates the score delta for one move. + * +10 for placing a shape (always) + * +10 per unique cell cleared + * + * clearedCellCount must be the deduplicated cell count returned + * by getCompletedLines().cellsToClear.length. + */ +export function calculateScoreDelta(clearedCellCount: number): number { + return 10 + clearedCellCount * 10; +} + +// --------------------------------------------------------------------------- +// Shape validation +// --------------------------------------------------------------------------- + +/** + * Returns a list of human-readable descriptions of pairs of shapes + * that have identical cell layouts (order-insensitive). + * An empty result means all shapes are unique. + */ +export function findDuplicateLayouts(shapes: ShapeLayout[]): string[] { + const duplicates: string[] = []; + + const serialize = (cells: Cell[]): string => + [...cells] + .sort((a, b) => a.row !== b.row ? a.row - b.row : a.col - b.col) + .map(c => `${c.row},${c.col}`) + .join('|'); + + for (let i = 0; i < shapes.length; i++) { + for (let j = i + 1; j < shapes.length; j++) { + if ( + shapes[i].cells.length === shapes[j].cells.length && + serialize(shapes[i].cells) === serialize(shapes[j].cells) + ) { + duplicates.push(`${shapes[i].name} == ${shapes[j].name}`); + } + } + } + return duplicates; +} diff --git a/entry/src/main/ets/pages/Game.ets b/entry/src/main/ets/pages/Game.ets index 98da429..50f5134 100644 --- a/entry/src/main/ets/pages/Game.ets +++ b/entry/src/main/ets/pages/Game.ets @@ -1,9 +1,8 @@ import { Cell, ShapeDefinition } from '../model/Game'; import { GameConstants } from '../common/GameConstants'; +import { createEmptyGrid } from '../model/GameLogic'; import { SourceArea } from '../components/SourceArea'; import { GameBoard } from '../components/GameBoard'; -import { promptAction } from '@kit.ArkUI'; - @Builder export function GameBuilder (){ @@ -22,7 +21,7 @@ struct Game { @State currentShape: ShapeDefinition = GameConstants.ALL_SHAPES[0]; @State displayedScore: number = 0; @Watch('onTargetScoreChange') @State targetScore: number = 0; - @State bestScore: number = AppStorage.get('bestScore') as number; + @State bestScore: number = (AppStorage.get('bestScore') ?? 0); @State cellAnimationState: Map = new Map(); @State dropZoneArea: Area = { width: 0, @@ -30,17 +29,16 @@ struct Game { position: { x: 0, y: 0 }, globalPosition: { x: 0, y: 0 } }; + @State resetCounter: number = 0; private scoreAnimationInterval: number = -1; pageInfos: NavPathStack = new NavPathStack(); - - onGameOverStateChange() { if (this.isGameOver) { this.fillGridForGameOver(); setTimeout(() => { - AppStorage.setOrCreate('score', this.displayedScore); + AppStorage.setOrCreate('score', this.targetScore); this.pageInfos.pushPathByName('GameOver', '') }, 1200) } @@ -55,15 +53,15 @@ struct Game { for (let col = 0; col < GameConstants.GRID_SIZE; col++) { if (this.gridState[row][col] === null) { const delay = (row * GameConstants.GRID_SIZE + col) * GameConstants.GAMEOVER_FILL_DELAY_MS; - this._update(row, col, $r('app.color.game_over_block'), delay) + this.scheduleGridUpdate(row, col, $r('app.color.game_over_block'), delay) } } } } - _update(row: number, col: number, res: Resource, delay: number) { + scheduleGridUpdate(row: number, col: number, res: Resource, delay: number) { setTimeout(() => { - this.gridState[row][col] = $r('app.color.game_over_block'); + this.gridState[row][col] = res; this.gridState = [...this.gridState]; }, delay); } @@ -73,17 +71,15 @@ struct Game { clearInterval(this.scoreAnimationInterval); } - const interval = 20; + const interval = 40; this.scoreAnimationInterval = setInterval(() => { if (this.displayedScore < this.targetScore) { const diff = this.targetScore - this.displayedScore; - const step = Math.max(1, Math.ceil(diff / 15)); + const step = Math.max(1, Math.ceil(diff / 8)); this.displayedScore += step; - if (this.displayedScore > this.targetScore) { this.displayedScore = this.targetScore; } - } else { clearInterval(this.scoreAnimationInterval); this.scoreAnimationInterval = -1; @@ -91,79 +87,104 @@ struct Game { }, interval); } - aboutToAppear() { + resetGame() { + if (this.scoreAnimationInterval !== -1) { + clearInterval(this.scoreAnimationInterval); + this.scoreAnimationInterval = -1; + } + this.isGameOver = false; + this.targetScore = 0; + this.displayedScore = 0; + this.highlightCells = []; + this.cellAnimationState = new Map(); this.initializeGridState(); + this.resetCounter++; } - initializeGridState() { - const gridState: (Resource | null)[][] = [] + aboutToAppear() { + this.initializeGridState(); + } - for (let i = 0; i < GameConstants.GRID_SIZE; i++) { - const grid: (Resource | null)[] = [] - for (let j = 0; j < GameConstants.GRID_SIZE; j++) { - grid.push(null) - } - gridState.push(grid) + aboutToDisappear() { + if (this.scoreAnimationInterval !== -1) { + clearInterval(this.scoreAnimationInterval); + this.scoreAnimationInterval = -1; } - this.gridState = gridState; } - showMessage(msg: string) { - promptAction.openToast({ - message: msg, - duration: 400 - }) + initializeGridState() { + this.gridState = createEmptyGrid(GameConstants.GRID_SIZE) as (Resource | null)[][]; } build() { - NavDestination() { - Column() { - Column() { - Row() { - Image($r('app.media.crown')).width(32).height(32) - Text(`${this.bestScore}`).fontColor('#ffef960a').fontWeight(FontWeight.Bold).fontSize(20) - }.width('100%').height(20).justifyContent(FlexAlign.Center) - - Row() { - Text(this.displayedScore.toString()) - .fontSize(25) - .fontWeight(FontWeight.Bold) - .fontColor(Color.White) - .padding(4) - }.width('100%').height(35).justifyContent(FlexAlign.Center) + Stack() { + Column() { + Column() { + Text(this.displayedScore.toString()) + .fontSize(28) + .fontWeight(FontWeight.Bold) + .fontColor(Color.White) + .padding({ top: 2, bottom: 6 }) + } + .gesture( + LongPressGesture({ duration: 600 }) + .onAction(() => { + this.resetGame(); + }) + ) + + GameBoard({ + gridState: $gridState, + cellAnimationState: $cellAnimationState, + highlightCells: $highlightCells, + currentShape: this.currentShape, + dropZoneArea: $dropZoneArea + }) + + SourceArea({ + currentShape: $currentShape, + isGameOver: $isGameOver, + visualOffsetX: $visualOffsetX, + visualOffsetY: $visualOffsetY, + isDragging: $isDragging, + highlightCells: $highlightCells, + dropZoneArea: $dropZoneArea, + gridState: $gridState, + targetScore: $targetScore, + cellAnimationState: $cellAnimationState, + bestScore: $bestScore, + resetCounter: $resetCounter + }) + .margin({ top: -22 }) + + }.width('100%') + .height('100%') + .padding({ left: 8, right: 8, top: 6, bottom: 8 }) + .backgroundColor('#3E57C5') + + if (this.isGameOver) { + Column() { + Text($r('app.string.game_over')) + .fontSize(28) + .fontWeight(FontWeight.Bold) + .fontColor(Color.White) + .opacity(0.92) + } + .justifyContent(FlexAlign.Center) + .width('100%') + .height('100%') + .hitTestBehavior(HitTestMode.None) + } } - - GameBoard({ - gridState: this.gridState, - cellAnimationState: this.cellAnimationState, - highlightCells: this.highlightCells, - currentShape: this.currentShape, - dropZoneArea: this.dropZoneArea - }) - - SourceArea({ - currentShape: $currentShape, - isGameOver: this.isGameOver, - visualOffsetX: $visualOffsetX, - visualOffsetY: $visualOffsetY, - isDragging: $isDragging, - highlightCells: $highlightCells, - dropZoneArea: this.dropZoneArea, - gridState: this.gridState, - targetScore: this.targetScore, - cellAnimationState: this.cellAnimationState, - bestScore: this.bestScore - }) - - }.width('100%').height('100%').padding(20).backgroundColor('#3E57C5') - - }.width('100%') + .width('100%') + .height('100%') + } + .width('100%') .height('100%') .hideTitleBar(true) .onReady((context: NavDestinationContext) => { this.pageInfos = context.pathStack; }) - } -} \ No newline at end of file +} diff --git a/entry/src/main/ets/pages/GameOver.ets b/entry/src/main/ets/pages/GameOver.ets index e933146..dd38891 100644 --- a/entry/src/main/ets/pages/GameOver.ets +++ b/entry/src/main/ets/pages/GameOver.ets @@ -15,13 +15,26 @@ struct GameOver { private scoreAnimationInterval: number = -1; pageInfos: NavPathStack = new NavPathStack(); + private syncScoresFromStorage(): void { + this.bestScore = AppStorage.get('bestScore') ?? 0; + this.targetScore = AppStorage.get('score') ?? 0; + } + aboutToAppear(): void { - this.bestScore = AppStorage.get('bestScore') as number; + this.syncScoresFromStorage(); + this.score = this.targetScore; + } + + aboutToDisappear(): void { + if (this.scoreAnimationInterval !== -1) { + clearInterval(this.scoreAnimationInterval); + this.scoreAnimationInterval = -1; + } } onPageShow(): void { - this.targetScore = AppStorage.get('score') as number; - this.startScoreAnimation() + this.syncScoresFromStorage(); + this.score = this.targetScore; } startScoreAnimation() { @@ -90,6 +103,7 @@ struct GameOver { fontColor: ColorMetrics.resourceColor($r('app.color.gameover_bg_blue')), backgroundColor: ColorMetrics.resourceColor('#FFF'), onClick: () => { + this.pageInfos.clear(); this.pageInfos.pushPathByName('Game', '') } }) @@ -109,4 +123,4 @@ struct GameOver { }) } -} \ No newline at end of file +} diff --git a/entry/src/main/ets/pages/Splash.ets b/entry/src/main/ets/pages/Splash.ets index d58860c..131f956 100644 --- a/entry/src/main/ets/pages/Splash.ets +++ b/entry/src/main/ets/pages/Splash.ets @@ -1,6 +1,5 @@ import lottie, { AnimationItem } from '@ohos/lottie'; - @Builder export function SplashBuilder (){ Splash() @@ -13,17 +12,22 @@ struct Splash { private canvasRenderingContext: CanvasRenderingContext2D = new CanvasRenderingContext2D(this.renderingSettings) private animateItem: AnimationItem | null = null; private animateName: string = 'animation'; + private splashTimerId: number = -1; + private domLoadedCallback: ((args: Object) => void) | null = null; pageInfos: NavPathStack = new NavPathStack(); - aboutToAppear(): void { - setTimeout(() => { + this.splashTimerId = setTimeout(() => { this.pageInfos.pushPathByName('Game', '') }, 1500) } aboutToDisappear(): void { - lottie.destroy(); + if (this.splashTimerId !== -1) { + clearTimeout(this.splashTimerId); + this.splashTimerId = -1; + } + this.destroy(); } loadAnimation() { @@ -37,14 +41,18 @@ struct Splash { path: 'common/splash_animation.json' }) - this.animateItem.addEventListener('DOMLoaded', (args: Object): void => { + this.domLoadedCallback = (_args: Object): void => { this.animateItem?.changeColor([225, 25, 100, 1]); this.animateItem?.play() - }); + }; + this.animateItem.addEventListener('DOMLoaded', this.domLoadedCallback); } destroy() { - this.animateItem?.removeEventListener('DOMLoaded'); + if (this.domLoadedCallback !== null) { + this.animateItem?.removeEventListener('DOMLoaded', this.domLoadedCallback); + this.domLoadedCallback = null; + } lottie.destroy(this.animateName); this.animateItem = null; } @@ -73,4 +81,4 @@ struct Splash { this.pageInfos = context.pathStack; }) } -} \ No newline at end of file +} diff --git a/entry/src/main/ets/utils/Logger.ets b/entry/src/main/ets/utils/Logger.ets index 5bdd91b..23fd5a4 100644 --- a/entry/src/main/ets/utils/Logger.ets +++ b/entry/src/main/ets/utils/Logger.ets @@ -3,7 +3,6 @@ import { hilog } from '@kit.PerformanceAnalysisKit'; class Logger { private domain: number; private prefix: string; - private format: string = '%{public}s, %{public}s'; constructor(prefix: string) { this.prefix = prefix; @@ -11,20 +10,20 @@ class Logger { } debug(...args: string[]): void { - hilog.debug(this.domain, this.prefix, this.format, args); + hilog.debug(this.domain, this.prefix, args[0] ?? '', ...args.slice(1)); } info(...args: string[]): void { - hilog.info(this.domain, this.prefix, this.format, args); + hilog.info(this.domain, this.prefix, args[0] ?? '', ...args.slice(1)); } warn(...args: string[]): void { - hilog.warn(this.domain, this.prefix, this.format, args); + hilog.warn(this.domain, this.prefix, args[0] ?? '', ...args.slice(1)); } error(...args: string[]): void { - hilog.error(this.domain, this.prefix, this.format, args); + hilog.error(this.domain, this.prefix, args[0] ?? '', ...args.slice(1)); } } -export default new Logger('[CameraKitAVRecorder]'); \ No newline at end of file +export default new Logger('[BlockGame]'); diff --git a/entry/src/main/ets/utils/SecureRandom.ets b/entry/src/main/ets/utils/SecureRandom.ets new file mode 100644 index 0000000..8346b3e --- /dev/null +++ b/entry/src/main/ets/utils/SecureRandom.ets @@ -0,0 +1,26 @@ +import { cryptoFramework } from '@kit.CryptoArchitectureKit'; + +let fallbackSeed: number = Date.now() >>> 0; + +/** + * Generates a cryptographically secure random number between 0 (inclusive) + * and 1 (exclusive). + */ +export function secureRandomNumber(): number { + try { + const rand = cryptoFramework.createRandom(); + const len = 8; + const randData = rand.generateRandomSync(len); + const bytes = randData.data; + let bigIntValue = BigInt(0); + for (let i = 0; i < 8; i++) { + bigIntValue = (bigIntValue << BigInt(8)) | BigInt(bytes[i]); + } + const maxSafeValue = BigInt(1) << BigInt(53); + const normalizedValue = Number(bigIntValue >> BigInt(11)) / Number(maxSafeValue); + return normalizedValue; + } catch (_error) { + fallbackSeed = (1664525 * fallbackSeed + 1013904223) >>> 0; + return fallbackSeed / 0x100000000; + } +} diff --git a/entry/src/main/module.json5 b/entry/src/main/module.json5 index f97d00f..48881b1 100644 --- a/entry/src/main/module.json5 +++ b/entry/src/main/module.json5 @@ -8,10 +8,15 @@ "wearable" ], "deliveryWithInstall": true, - "installationFree": false, - "pages": "$profile:main_pages", - "routerMap": "$profile:route_maps", - "abilities": [ + "installationFree": false, + "pages": "$profile:main_pages", + "routerMap": "$profile:route_maps", + "requestPermissions": [ + { + "name": "ohos.permission.VIBRATE" + } + ], + "abilities": [ { "name": "EntryAbility", "srcEntry": "./ets/entryability/EntryAbility.ets", @@ -39,13 +44,13 @@ "srcEntry": "./ets/entrybackupability/EntryBackupAbility.ets", "type": "backup", "exported": false, - "metadata": [ - { - "name": "ohos.extension.backup", - "resource": "$profile:backup_config" - } - ], - } - ] - } -} \ No newline at end of file + "metadata": [ + { + "name": "ohos.extension.backup", + "resource": "$profile:backup_config" + } + ] + } + ] + } +} diff --git a/entry/src/main/resources/base/element/color.json b/entry/src/main/resources/base/element/color.json index 2684329..8eaaada 100644 --- a/entry/src/main/resources/base/element/color.json +++ b/entry/src/main/resources/base/element/color.json @@ -4,22 +4,22 @@ "name": "start_window_background", "value": "#FFFFFF" }, - { - "name": "bottom_background", - "value": "#0080FF" - }, { "name": "shape_color_I", "value": "#FF338996" }, { "name": "shape_color_O", - "value": "#FFA99B02" + "value": "#FF00ACC1" }, { "name": "shape_color_L", "value": "#FFEA8B00" }, + { + "name": "shape_color_J", + "value": "#FF1565C0" + }, { "name": "shape_color_T", "value": "#FF7F2C8C" @@ -28,6 +28,10 @@ "name": "shape_color_S", "value": "#FF0F8314" }, + { + "name": "shape_color_Z", + "value": "#FFE53935" + }, { "name": "game_over_block", "value": "#D32F2F" @@ -45,4 +49,4 @@ "value": "#FFA726" } ] -} \ No newline at end of file +} diff --git a/entry/src/main/resources/base/element/float.json b/entry/src/main/resources/base/element/float.json deleted file mode 100644 index a0a93dd..0000000 --- a/entry/src/main/resources/base/element/float.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "float": [ - { - "name": "page_text_font_size", - "value": "50fp" - } - ] -} \ No newline at end of file diff --git a/entry/src/test/LocalUnit.test.ets b/entry/src/test/LocalUnit.test.ets index 165fc16..c301e7c 100644 --- a/entry/src/test/LocalUnit.test.ets +++ b/entry/src/test/LocalUnit.test.ets @@ -1,33 +1,143 @@ -import { describe, beforeAll, beforeEach, afterEach, afterAll, it, expect } from '@ohos/hypium'; +import { describe, it, expect } from '@ohos/hypium'; +import { Cell } from '../main/ets/model/Game'; +import { + applyShapeToGrid, + calculateScoreDelta, + canPlaceShape, + canShapeBePlacedAnywhere, + clearCells, + cloneGrid, + createEmptyGrid, + findDuplicateLayouts, + getCompletedLines, + nextRand, + randomShapeIndex, + ShapeLayout +} from '../main/ets/model/GameLogic'; export default function localUnitTest() { describe('localUnitTest', () => { - // Defines a test suite. Two parameters are supported: test suite name and test suite function. - beforeAll(() => { - // Presets an action, which is performed only once before all test cases of the test suite start. - // This API supports only one parameter: preset action function. - }); - beforeEach(() => { - // Presets an action, which is performed before each unit test case starts. - // The number of execution times is the same as the number of test cases defined by **it**. - // This API supports only one parameter: preset action function. - }); - afterEach(() => { - // Presets a clear action, which is performed after each unit test case ends. - // The number of execution times is the same as the number of test cases defined by **it**. - // This API supports only one parameter: clear action function. - }); - afterAll(() => { - // Presets a clear action, which is performed after all test cases of the test suite end. - // This API supports only one parameter: clear action function. - }); - it('assertContain', 0, () => { - // Defines a test case. This API supports three parameters: test case name, filter parameter, and test case function. - let a = 'abc'; - let b = 'b'; - // Defines a variety of assertion methods, which are used to declare expected boolean conditions. - expect(a).assertContain(b); - expect(a).assertEqual(a); + const occupied = {}; + const lineShape: ShapeLayout = { + name: 'line', + cells: [{ row: 0, col: 0 }, { row: 0, col: 1 }] + }; + const lShape: ShapeLayout = { + name: 'l_shape', + cells: [{ row: 0, col: 0 }, { row: 1, col: 0 }, { row: 1, col: 1 }] + }; + + it('createEmptyGrid returns a square grid filled with null', 0, () => { + const grid = createEmptyGrid(3); + expect(grid.length).assertEqual(3); + expect(grid[0].length).assertEqual(3); + expect(grid[2][2]).assertEqual(null); + }); + + it('cloneGrid returns a deep copy', 0, () => { + const grid = createEmptyGrid(2); + const filledGrid = applyShapeToGrid(grid, [{ row: 0, col: 1 }], occupied); + const clonedGrid = cloneGrid(filledGrid); + clonedGrid[0][1] = null; + expect(filledGrid[0][1] !== null).assertTrue(); + }); + + it('nextRand keeps values below one', 0, () => { + const result = nextRand(0xffffffff); + expect(result.value < 1).assertTrue(); + expect(result.seed >= 0).assertTrue(); + }); + + it('randomShapeIndex returns an in-range index', 0, () => { + const result = randomShapeIndex(123456, 5); + expect(result.index >= 0).assertTrue(); + expect(result.index < 5).assertTrue(); + }); + + it('canPlaceShape returns valid target cells for open positions', 0, () => { + const grid = createEmptyGrid(4); + const placement = canPlaceShape(grid, lShape.cells, 1, 1); + const expectedCells: Cell[] = [{ row: 1, col: 1 }, { row: 2, col: 1 }, { row: 2, col: 2 }]; + expect(placement.canPlace).assertTrue(); + expect(JSON.stringify(placement.targetCells)).assertEqual(JSON.stringify(expectedCells)); + }); + + it('canPlaceShape rejects occupied or out-of-bounds positions', 0, () => { + let grid = createEmptyGrid(4); + grid = applyShapeToGrid(grid, [{ row: 1, col: 1 }], occupied); + const blockedPlacement = canPlaceShape(grid, lShape.cells, 1, 1); + const outOfBoundsPlacement = canPlaceShape(grid, lShape.cells, 3, 3); + expect(blockedPlacement.canPlace).assertFalse(); + expect(outOfBoundsPlacement.canPlace).assertFalse(); + }); + + it('canShapeBePlacedAnywhere finds remaining legal placements', 0, () => { + const grid = createEmptyGrid(3); + expect(canShapeBePlacedAnywhere(grid, lineShape.cells, 3)).assertTrue(); + }); + + it('canShapeBePlacedAnywhere returns false when the board is full', 0, () => { + let grid = createEmptyGrid(2); + grid = applyShapeToGrid( + grid, + [{ row: 0, col: 0 }, { row: 0, col: 1 }, { row: 1, col: 0 }, { row: 1, col: 1 }], + occupied + ); + expect(canShapeBePlacedAnywhere(grid, lineShape.cells, 2)).assertFalse(); + }); + + it('getCompletedLines deduplicates row and column intersections', 0, () => { + let grid = createEmptyGrid(3); + grid = applyShapeToGrid( + grid, + [ + { row: 0, col: 1 }, + { row: 1, col: 1 }, + { row: 2, col: 1 }, + { row: 2, col: 0 }, + { row: 2, col: 2 } + ], + occupied + ); + const result = getCompletedLines(grid, [{ row: 2, col: 1 }]); + expect(JSON.stringify(result.clearedRows)).assertEqual('[2]'); + expect(JSON.stringify(result.clearedCols)).assertEqual('[1]'); + expect(result.cellsToClear.length).assertEqual(5); + }); + + it('clearCells only clears requested positions', 0, () => { + let grid = createEmptyGrid(3); + grid = applyShapeToGrid( + grid, + [{ row: 0, col: 0 }, { row: 1, col: 1 }], + occupied + ); + const clearedGrid = clearCells(grid, [{ row: 0, col: 0 }]); + expect(clearedGrid[0][0]).assertEqual(null); + expect(clearedGrid[1][1] !== null).assertTrue(); + }); + + it('calculateScoreDelta includes placement and line clear points', 0, () => { + expect(calculateScoreDelta(0)).assertEqual(10); + expect(calculateScoreDelta(4)).assertEqual(50); + }); + + it('findDuplicateLayouts reports duplicate shapes', 0, () => { + const duplicates = findDuplicateLayouts([ + { + name: 'shape_a', + cells: [{ row: 0, col: 0 }, { row: 0, col: 1 }] + }, + { + name: 'shape_b', + cells: [{ row: 0, col: 1 }, { row: 0, col: 0 }] + }, + { + name: 'shape_c', + cells: [{ row: 0, col: 0 }, { row: 1, col: 0 }] + } + ]); + expect(JSON.stringify(duplicates)).assertEqual('[\"shape_a == shape_b\"]'); }); }); -} \ No newline at end of file +} diff --git a/hvigor/hvigor-config.json5 b/hvigor/hvigor-config.json5 index c2cebe3..7b01813 100644 --- a/hvigor/hvigor-config.json5 +++ b/hvigor/hvigor-config.json5 @@ -1,5 +1,5 @@ { - "modelVersion": "5.1.0", + "modelVersion": "6.0.2", "dependencies": { }, "execution": { @@ -19,4 +19,4 @@ // "maxOldSpaceSize": 8192 /* Enable nodeOptions maxOldSpaceSize compilation. Unit M. Used for the daemon process. Default: 8192*/ // "exposeGC": true /* Enable to trigger garbage collection explicitly. Default: true*/ } -} +} \ No newline at end of file diff --git a/oh-package.json5 b/oh-package.json5 index bf8bd7e..db701f0 100644 --- a/oh-package.json5 +++ b/oh-package.json5 @@ -1,9 +1,10 @@ { - "modelVersion": "5.1.0", - "name": "BlockGame", - "description": "Simple Block Game", - "dependencies": { - "@ohos/lottie": "^2.0.22" - }, - "devDependencies": {} -} \ No newline at end of file + "modelVersion": "6.0.2", + "name": "BlockGame", + "description": "Simple Block Game", + "dependencies": { + "@ohos/lottie": "^2.0.22", + "@ohos/hypium": "1.0.25" + }, + "devDependencies": {} +} diff --git a/screenshots/ss_1.png b/screenshots/ss_1.png index 411f240..7816dc0 100644 Binary files a/screenshots/ss_1.png and b/screenshots/ss_1.png differ diff --git a/screenshots/ss_2.png b/screenshots/ss_2.png index 8b24486..2d4de6b 100644 Binary files a/screenshots/ss_2.png and b/screenshots/ss_2.png differ diff --git a/screenshots/ss_3.png b/screenshots/ss_3.png index 16e0988..1913026 100644 Binary files a/screenshots/ss_3.png and b/screenshots/ss_3.png differ