ADD - tower stack phone game

This commit is contained in:
smx.pusha
2026-08-06 09:28:30 +02:00
parent e1931a6527
commit 0dded888a1
18 changed files with 811 additions and 1 deletions
@@ -0,0 +1,42 @@
import fallUrl from '@/assets/audio/tower-stack/fall.wav?url'
import hitUrl from '@/assets/audio/tower-stack/hit.wav?url'
import perfectUrl from '@/assets/audio/tower-stack/perfect.wav?url'
import startUrl from '@/assets/audio/tower-stack/start.wav?url'
export type TowerStackSound = 'fall' | 'hit' | 'perfect' | 'start'
const soundUrls: Record<TowerStackSound, string> = {
fall: fallUrl,
hit: hitUrl,
perfect: perfectUrl,
start: startUrl,
}
const playerPools = new Map<TowerStackSound, HTMLAudioElement[]>()
function getPlayers(sound: TowerStackSound): HTMLAudioElement[] {
const existing = playerPools.get(sound)
if (existing) return existing
const players = Array.from({ length: 3 }, () => {
const player = new Audio(soundUrls[sound])
player.preload = 'auto'
player.volume = 0.84
return player
})
playerPools.set(sound, players)
return players
}
export function playTowerStackSound(
sound: TowerStackSound,
enabled: boolean,
): void {
if (!enabled) return
const players = getPlayers(sound)
const player = players.find((candidate) => candidate.paused) ?? players[0]
player.currentTime = 0
void player.play().catch((error: unknown) => {
console.error(`[Tower Stack audio] Failed to play ${sound}`, error)
})
}
@@ -0,0 +1,79 @@
import { describe, expect, it } from 'vitest'
import {
advanceTowerBlock,
createTowerGame,
placeTowerBlock,
TOWER_BASE_WIDTH,
TOWER_MAX_SPEED,
TOWER_PERFECT_TOLERANCE,
} from './engine'
describe('tower stack engine', () => {
it('creates a centered foundation and moving block', () => {
const state = createTowerGame()
expect(state.blocks).toHaveLength(1)
expect(state.blocks[0].width).toBe(TOWER_BASE_WIDTH)
expect(state.active?.x).toBe(0)
expect(state.status).toBe('playing')
})
it('moves the active block using elapsed time', () => {
const state = createTowerGame()
const moved = advanceTowerBlock(state, 0.5)
expect(moved.active?.x).toBeCloseTo(11.5)
})
it('reflects a moving block at the field edge', () => {
const state = createTowerGame()
const moved = advanceTowerBlock(state, 2)
expect(moved.active?.direction).toBe(-1)
expect(moved.active?.x).toBeGreaterThanOrEqual(0)
})
it('keeps only the overlapping part', () => {
const state = createTowerGame()
state.active = { ...state.active!, x: 25 }
const result = placeTowerBlock(state)
expect(result.outcome).toBe('placed')
expect(result.state.blocks.at(-1)?.width).toBeCloseTo(59)
expect(result.cutWidth).toBeCloseTo(9)
})
it('rewards a perfect placement within tolerance', () => {
const state = createTowerGame()
state.active = {
...state.active!,
x: state.blocks[0].x + TOWER_PERFECT_TOLERANCE / 2,
}
const result = placeTowerBlock(state)
expect(result.outcome).toBe('perfect')
expect(result.state.perfects).toBe(1)
expect(result.state.score).toBe(4)
})
it('ends the round on a complete miss', () => {
const state = createTowerGame()
state.active = { ...state.active!, width: 8, x: 0 }
const result = placeTowerBlock(state)
expect(result.outcome).toBe('missed')
expect(result.state.status).toBe('over')
expect(result.state.active).toBeNull()
})
it('alternates movement direction for each level', () => {
const state = createTowerGame()
state.active = { ...state.active!, x: state.blocks[0].x }
const result = placeTowerBlock(state)
expect(result.state.active?.direction).toBe(-1)
})
it('caps speed at the configured maximum', () => {
let state = createTowerGame()
for (let level = 0; level < 30; level += 1) {
state.active = { ...state.active!, x: state.blocks.at(-1)!.x }
state = placeTowerBlock(state).state
}
expect(state.active?.speed).toBe(TOWER_MAX_SPEED)
})
})
@@ -0,0 +1,139 @@
import type {
TowerActiveBlock,
TowerBlock,
TowerGameState,
TowerPlacement,
} from './types'
export const TOWER_FIELD_WIDTH = 100
export const TOWER_BASE_WIDTH = 68
export const TOWER_PERFECT_TOLERANCE = 1.35
export const TOWER_START_SPEED = 23
export const TOWER_MAX_SPEED = 48
function speedForLevel(level: number): number {
return Math.min(
TOWER_MAX_SPEED,
TOWER_START_SPEED + Math.max(0, level - 1) * 1.65,
)
}
function createActiveBlock(
level: number,
width: number,
direction: -1 | 1,
): TowerActiveBlock {
return {
colorIndex: level % 6,
direction,
id: level,
level,
speed: speedForLevel(level),
width,
x: direction === 1 ? 0 : TOWER_FIELD_WIDTH - width,
}
}
export function createTowerGame(): TowerGameState {
const base: TowerBlock = {
colorIndex: 0,
id: 0,
level: 0,
width: TOWER_BASE_WIDTH,
x: (TOWER_FIELD_WIDTH - TOWER_BASE_WIDTH) / 2,
}
return {
active: createActiveBlock(1, TOWER_BASE_WIDTH, 1),
blocks: [base],
perfects: 0,
score: 0,
status: 'playing',
}
}
export function advanceTowerBlock(
state: TowerGameState,
elapsedSeconds: number,
): TowerGameState {
if (state.status !== 'playing' || !state.active || elapsedSeconds <= 0) {
return state
}
const active = { ...state.active }
let nextX = active.x + active.direction * active.speed * elapsedSeconds
const maximumX = TOWER_FIELD_WIDTH - active.width
while (nextX < 0 || nextX > maximumX) {
if (nextX > maximumX) {
nextX = maximumX - (nextX - maximumX)
active.direction = -1
} else {
nextX = -nextX
active.direction = 1
}
}
active.x = nextX
return { ...state, active }
}
export function placeTowerBlock(state: TowerGameState): TowerPlacement {
if (state.status !== 'playing' || !state.active) {
return { cutSide: null, cutWidth: 0, outcome: 'missed', state }
}
const active = state.active
const support = state.blocks[state.blocks.length - 1]
const overlapStart = Math.max(active.x, support.x)
const overlapEnd = Math.min(active.x + active.width, support.x + support.width)
const overlapWidth = overlapEnd - overlapStart
if (overlapWidth <= 0) {
return {
cutSide: active.x < support.x ? 'left' : 'right',
cutWidth: active.width,
outcome: 'missed',
state: { ...state, active: null, status: 'over' },
}
}
const offset = active.x - support.x
const isPerfect = Math.abs(offset) <= TOWER_PERFECT_TOLERANCE
const placedWidth = isPerfect
? Math.min(TOWER_BASE_WIDTH, support.width + 0.85)
: overlapWidth
const placedX = isPerfect
? support.x - (placedWidth - support.width) / 2
: overlapStart
const block: TowerBlock = {
colorIndex: active.colorIndex,
id: active.id,
level: active.level,
width: placedWidth,
x: placedX,
}
const nextLevel = active.level + 1
const direction: -1 | 1 = nextLevel % 2 === 0 ? -1 : 1
return {
cutSide: isPerfect ? null : offset < 0 ? 'left' : 'right',
cutWidth: isPerfect ? 0 : active.width - overlapWidth,
outcome: isPerfect ? 'perfect' : 'placed',
state: {
active: createActiveBlock(nextLevel, placedWidth, direction),
blocks: [...state.blocks, block],
perfects: state.perfects + (isPerfect ? 1 : 0),
score: state.score + 1 + (isPerfect ? 3 : 0),
status: 'playing',
},
}
}
export function pauseTowerGame(state: TowerGameState): TowerGameState {
return state.status === 'playing' ? { ...state, status: 'paused' } : state
}
export function resumeTowerGame(state: TowerGameState): TowerGameState {
return state.status === 'paused' ? { ...state, status: 'playing' } : state
}
@@ -0,0 +1,96 @@
import { defineStore } from 'pinia'
import { useGamesStore } from '@/features/games/store'
import {
advanceTowerBlock,
createTowerGame,
pauseTowerGame,
placeTowerBlock,
resumeTowerGame,
} from './engine'
import type { TowerGameState, TowerPlacement } from './types'
type TowerStackSave = {
highHeight: number
highScore: number
soundEnabled: boolean
}
export const useTowerStackStore = defineStore('tower-stack', {
state: () => ({
game: null as TowerGameState | null,
highHeight: 0,
highScore: 0,
hydrated: false,
menuOpen: true,
soundEnabled: true,
}),
actions: {
hydrate(): void {
if (this.hydrated) return
const saved = useGamesStore().readGame<Partial<TowerStackSave>>(
'tower-stack',
)
this.highHeight =
typeof saved?.highHeight === 'number' && saved.highHeight >= 0
? Math.floor(saved.highHeight)
: 0
this.highScore =
typeof saved?.highScore === 'number' && saved.highScore >= 0
? Math.floor(saved.highScore)
: 0
if (typeof saved?.soundEnabled === 'boolean') {
this.soundEnabled = saved.soundEnabled
}
this.hydrated = true
},
persist(): void {
useGamesStore().saveGame('tower-stack', {
highHeight: this.highHeight,
highScore: this.highScore,
soundEnabled: this.soundEnabled,
} satisfies TowerStackSave)
},
start(): void {
this.game = createTowerGame()
this.menuOpen = false
},
tick(elapsedSeconds: number): void {
if (this.game) {
this.game = advanceTowerBlock(this.game, elapsedSeconds)
}
},
place(): TowerPlacement | null {
if (!this.game) return null
const result = placeTowerBlock(this.game)
this.game = result.state
if (result.state.status === 'over') {
const height = result.state.blocks.length - 1
this.highHeight = Math.max(this.highHeight, height)
this.highScore = Math.max(this.highScore, result.state.score)
this.persist()
}
return result
},
pause(): void {
if (this.game) this.game = pauseTowerGame(this.game)
},
resume(): void {
if (this.game) {
this.game = resumeTowerGame(this.game)
this.menuOpen = false
}
},
showMenu(): void {
this.pause()
this.menuOpen = true
},
setSoundEnabled(enabled: boolean): void {
this.soundEnabled = enabled
this.persist()
},
},
})
@@ -0,0 +1,29 @@
export type TowerStatus = 'over' | 'paused' | 'playing'
export type TowerBlock = {
colorIndex: number
id: number
level: number
width: number
x: number
}
export type TowerActiveBlock = TowerBlock & {
direction: -1 | 1
speed: number
}
export type TowerGameState = {
active: TowerActiveBlock | null
blocks: TowerBlock[]
perfects: number
score: number
status: TowerStatus
}
export type TowerPlacement = {
cutSide: 'left' | 'right' | null
cutWidth: number
outcome: 'missed' | 'perfect' | 'placed'
state: TowerGameState
}