mirror of
https://github.com/sky-systems/sky_phone.git
synced 2026-08-29 01:01:31 +00:00
ADD - tower stack phone game
This commit is contained in:
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 28 KiB |
@@ -50,6 +50,12 @@ describe('app registry', () => {
|
||||
labelKey: 'Apps.minesweeper.name',
|
||||
route: '/apps/minesweeper',
|
||||
})
|
||||
expect(PHONE_APPS.find((app) => app.id === 'tower-stack')).toMatchObject({
|
||||
dockOrder: null,
|
||||
gridOrder: 15,
|
||||
labelKey: 'Apps.towerStack.name',
|
||||
route: '/apps/tower-stack',
|
||||
})
|
||||
expect(
|
||||
PHONE_APPS.filter((app) => app.dockOrder !== null)
|
||||
.sort((a, b) => (a.dockOrder ?? 0) - (b.dockOrder ?? 0))
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
Grid2X2,
|
||||
Brain,
|
||||
Images,
|
||||
Layers3,
|
||||
Mail,
|
||||
MapPinned,
|
||||
NotebookPen,
|
||||
@@ -31,6 +32,7 @@ import snakeIcon from '@/assets/img/app-icons/snake.webp'
|
||||
import memoryIcon from '@/assets/img/app-icons/memory.webp'
|
||||
import numberMergeIcon from '@/assets/img/app-icons/number-merge.webp'
|
||||
import minesweeperIcon from '@/assets/img/app-icons/minesweeper.webp'
|
||||
import towerStackIcon from '@/assets/img/app-icons/tower-stack.webp'
|
||||
import weatherIcon from '@/assets/img/app-icons/weather.webp'
|
||||
import type { PhoneAppDefinition, PhoneAppId } from '@/types/apps'
|
||||
|
||||
@@ -230,6 +232,19 @@ export const PHONE_APPS: PhoneAppDefinition[] = [
|
||||
labelKey: 'Apps.minesweeper.name',
|
||||
route: '/apps/minesweeper',
|
||||
},
|
||||
{
|
||||
component: markRaw(
|
||||
defineAsyncComponent(() => import('@/views/apps/TowerStackApp.vue')),
|
||||
),
|
||||
dockOrder: null,
|
||||
gridOrder: 15,
|
||||
icon: markRaw(Layers3),
|
||||
iconClass: 'app-icon--tower-stack',
|
||||
iconImage: towerStackIcon,
|
||||
id: 'tower-stack',
|
||||
labelKey: 'Apps.towerStack.name',
|
||||
route: '/apps/tower-stack',
|
||||
},
|
||||
]
|
||||
|
||||
export const PHONE_APP_IDS = PHONE_APPS.map((app) => app.id)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -243,6 +243,35 @@ const defaultLocales: LocaleTree = {
|
||||
wonBody: 'Minefield cleared in {time}.',
|
||||
wonTitle: 'Field cleared!',
|
||||
},
|
||||
towerStack: {
|
||||
name: 'Tower Stack',
|
||||
backToMenu: 'Back to game menu',
|
||||
bestHeight: 'Best Height',
|
||||
bestScore: 'Best Score',
|
||||
blocks: 'Blocks',
|
||||
eyebrow: 'Timing challenge',
|
||||
finalScore: 'Final Score',
|
||||
gameHint: 'Tap the playfield to place the moving block',
|
||||
gameOver: 'Tower collapsed',
|
||||
height: 'Height',
|
||||
mainMenu: 'Main Menu',
|
||||
menuBody:
|
||||
'Stop each moving block above the tower. Only the overlapping part remains.',
|
||||
menuTitle: 'How high can you build?',
|
||||
mute: 'Mute game sounds',
|
||||
newGame: 'Start New Game',
|
||||
pause: 'Pause or resume game',
|
||||
paused: 'Paused',
|
||||
perfect: 'PERFECT!',
|
||||
placeBlock: 'Place moving block',
|
||||
playAgain: 'Play Again',
|
||||
ready: 'Ready to stack?',
|
||||
resume: 'Continue Game',
|
||||
score: 'Score',
|
||||
start: 'Start Game',
|
||||
tapHint: 'Every tap places one block · perfect hits earn bonus points',
|
||||
unmute: 'Turn on game sounds',
|
||||
},
|
||||
map: {
|
||||
name: 'Map',
|
||||
controls: 'Map controls',
|
||||
|
||||
@@ -16,6 +16,7 @@ export type PhoneAppId =
|
||||
| 'memory'
|
||||
| 'number-merge'
|
||||
| 'minesweeper'
|
||||
| 'tower-stack'
|
||||
|
||||
export type AppLaunchOrigin = {
|
||||
borderRadius: number
|
||||
|
||||
@@ -54,6 +54,7 @@ const DEFAULT_APP_NOTIFICATIONS: Record<
|
||||
memory: { enabled: true, sounds: true },
|
||||
'number-merge': { enabled: true, sounds: true },
|
||||
minesweeper: { enabled: true, sounds: true },
|
||||
'tower-stack': { enabled: true, sounds: true },
|
||||
camera: { enabled: true, sounds: true },
|
||||
clock: { enabled: true, sounds: true },
|
||||
weather: { enabled: true, sounds: true },
|
||||
|
||||
@@ -0,0 +1,360 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
ChevronLeft,
|
||||
Layers3,
|
||||
Pause,
|
||||
Play,
|
||||
RotateCcw,
|
||||
Volume2,
|
||||
VolumeX,
|
||||
} from 'lucide-vue-next'
|
||||
import {
|
||||
computed,
|
||||
onBeforeUnmount,
|
||||
ref,
|
||||
watch,
|
||||
type CSSProperties,
|
||||
} from 'vue'
|
||||
|
||||
import { playTowerStackSound } from '@/features/games/tower-stack/audio'
|
||||
import { useTowerStackStore } from '@/features/games/tower-stack/store'
|
||||
import type {
|
||||
TowerActiveBlock,
|
||||
TowerBlock,
|
||||
} from '@/features/games/tower-stack/types'
|
||||
import { usePhoneStore } from '@/stores/phone'
|
||||
|
||||
const phone = usePhoneStore()
|
||||
const tower = useTowerStackStore()
|
||||
const game = computed(() => tower.game)
|
||||
const visibleBlocks = computed(() => game.value?.blocks.slice(-9) ?? [])
|
||||
const placementEffect = ref<'missed' | 'perfect' | 'placed' | null>(null)
|
||||
const fallingBlock = ref<TowerActiveBlock | null>(null)
|
||||
const fallingStyle = ref<CSSProperties>({})
|
||||
const gameOverVisible = ref(true)
|
||||
let animationFrame: number | undefined
|
||||
let previousFrameTime = 0
|
||||
let effectTimer: ReturnType<typeof setTimeout> | undefined
|
||||
|
||||
const blockColors = [
|
||||
'#ff6b68',
|
||||
'#ffbf3f',
|
||||
'#39c9d3',
|
||||
'#8c63e8',
|
||||
'#ff8454',
|
||||
'#54d5a5',
|
||||
]
|
||||
|
||||
function blockStyle(block: TowerBlock, index: number): CSSProperties {
|
||||
return {
|
||||
'--tower-block-color': blockColors[block.colorIndex % blockColors.length],
|
||||
bottom: `${24 + index * 35}px`,
|
||||
left: `${block.x}%`,
|
||||
width: `${block.width}%`,
|
||||
} as CSSProperties
|
||||
}
|
||||
|
||||
const activeStyle = computed<CSSProperties>(() => {
|
||||
const active = game.value?.active
|
||||
if (!active) return {}
|
||||
return {
|
||||
'--tower-block-color': blockColors[active.colorIndex % blockColors.length],
|
||||
bottom: `${24 + visibleBlocks.value.length * 35}px`,
|
||||
left: `${active.x}%`,
|
||||
width: `${active.width}%`,
|
||||
} as CSSProperties
|
||||
})
|
||||
|
||||
function runFrame(time: number): void {
|
||||
if (game.value?.status !== 'playing') return
|
||||
|
||||
if (previousFrameTime > 0) {
|
||||
tower.tick(Math.min(0.04, (time - previousFrameTime) / 1000))
|
||||
}
|
||||
previousFrameTime = time
|
||||
animationFrame = requestAnimationFrame(runFrame)
|
||||
}
|
||||
|
||||
function startLoop(): void {
|
||||
if (animationFrame !== undefined) cancelAnimationFrame(animationFrame)
|
||||
previousFrameTime = 0
|
||||
animationFrame = requestAnimationFrame(runFrame)
|
||||
}
|
||||
|
||||
function stopLoop(): void {
|
||||
if (animationFrame !== undefined) cancelAnimationFrame(animationFrame)
|
||||
animationFrame = undefined
|
||||
previousFrameTime = 0
|
||||
}
|
||||
|
||||
function startGame(): void {
|
||||
clearEffects()
|
||||
tower.start()
|
||||
playTowerStackSound('start', tower.soundEnabled)
|
||||
}
|
||||
|
||||
function placeBlock(): void {
|
||||
if (game.value?.status !== 'playing' || !game.value.active) return
|
||||
|
||||
const activeBeforePlacement = { ...game.value.active }
|
||||
const visibleLevel = visibleBlocks.value.length
|
||||
const result = tower.place()
|
||||
if (!result) return
|
||||
|
||||
placementEffect.value = result.outcome
|
||||
gameOverVisible.value = result.outcome !== 'missed'
|
||||
if (result.outcome === 'perfect') {
|
||||
playTowerStackSound('perfect', tower.soundEnabled)
|
||||
} else if (result.outcome === 'placed') {
|
||||
playTowerStackSound('hit', tower.soundEnabled)
|
||||
} else {
|
||||
playTowerStackSound('fall', tower.soundEnabled)
|
||||
}
|
||||
|
||||
if (result.cutWidth > 0) {
|
||||
fallingBlock.value = {
|
||||
...activeBeforePlacement,
|
||||
width: result.cutWidth,
|
||||
x:
|
||||
result.cutSide === 'left'
|
||||
? activeBeforePlacement.x
|
||||
: activeBeforePlacement.x + activeBeforePlacement.width - result.cutWidth,
|
||||
}
|
||||
fallingStyle.value = {
|
||||
'--tower-block-color':
|
||||
blockColors[activeBeforePlacement.colorIndex % blockColors.length],
|
||||
bottom: `${24 + visibleLevel * 35}px`,
|
||||
left: `${fallingBlock.value.x}%`,
|
||||
width: `${fallingBlock.value.width}%`,
|
||||
} as CSSProperties
|
||||
}
|
||||
|
||||
if (effectTimer) clearTimeout(effectTimer)
|
||||
effectTimer = setTimeout(() => {
|
||||
placementEffect.value = null
|
||||
fallingBlock.value = null
|
||||
gameOverVisible.value = true
|
||||
effectTimer = undefined
|
||||
}, result.outcome === 'missed' ? 950 : 650)
|
||||
}
|
||||
|
||||
function clearEffects(): void {
|
||||
if (effectTimer) clearTimeout(effectTimer)
|
||||
effectTimer = undefined
|
||||
placementEffect.value = null
|
||||
fallingBlock.value = null
|
||||
gameOverVisible.value = true
|
||||
}
|
||||
|
||||
function toggleSound(): void {
|
||||
const enabled = !tower.soundEnabled
|
||||
tower.setSoundEnabled(enabled)
|
||||
if (enabled) playTowerStackSound('hit', true)
|
||||
}
|
||||
|
||||
function togglePause(): void {
|
||||
if (game.value?.status === 'playing') tower.pause()
|
||||
else if (game.value?.status === 'paused') tower.resume()
|
||||
}
|
||||
|
||||
tower.hydrate()
|
||||
watch(
|
||||
() => game.value?.status,
|
||||
(status) => {
|
||||
if (status === 'playing') startLoop()
|
||||
else stopLoop()
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
stopLoop()
|
||||
clearEffects()
|
||||
tower.pause()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="tower-app" :aria-label="phone.t('Apps.towerStack.name')">
|
||||
<header class="tower-header">
|
||||
<div>
|
||||
<span>{{ phone.t('Apps.towerStack.eyebrow') }}</span>
|
||||
<h1>{{ phone.t('Apps.towerStack.name') }}</h1>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
:aria-label="phone.t(tower.soundEnabled ? 'Apps.towerStack.mute' : 'Apps.towerStack.unmute')"
|
||||
@click="toggleSound"
|
||||
>
|
||||
<Volume2 v-if="tower.soundEnabled" :size="18" aria-hidden="true" />
|
||||
<VolumeX v-else :size="18" aria-hidden="true" />
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<section v-if="tower.menuOpen" class="tower-menu">
|
||||
<div class="tower-menu__preview" aria-hidden="true">
|
||||
<i v-for="index in 7" :key="index" :style="{ '--preview-index': index }"></i>
|
||||
<span></span>
|
||||
</div>
|
||||
<div class="tower-menu__copy">
|
||||
<span>{{ phone.t('Apps.towerStack.ready') }}</span>
|
||||
<h2>{{ phone.t('Apps.towerStack.menuTitle') }}</h2>
|
||||
<p>{{ phone.t('Apps.towerStack.menuBody') }}</p>
|
||||
</div>
|
||||
<div class="tower-records">
|
||||
<div><span>{{ phone.t('Apps.towerStack.bestHeight') }}</span><strong>{{ tower.highHeight }}</strong></div>
|
||||
<div><span>{{ phone.t('Apps.towerStack.bestScore') }}</span><strong>{{ tower.highScore }}</strong></div>
|
||||
</div>
|
||||
<button
|
||||
v-if="game?.status === 'paused'"
|
||||
type="button"
|
||||
class="tower-primary"
|
||||
@click="tower.resume()"
|
||||
>
|
||||
<Play :size="17" fill="currentColor" />
|
||||
{{ phone.t('Apps.towerStack.resume') }}
|
||||
</button>
|
||||
<button type="button" class="tower-secondary" @click="startGame">
|
||||
{{ phone.t(game ? 'Apps.towerStack.newGame' : 'Apps.towerStack.start') }}
|
||||
</button>
|
||||
<p class="tower-menu__hint">{{ phone.t('Apps.towerStack.tapHint') }}</p>
|
||||
</section>
|
||||
|
||||
<section v-else-if="game" class="tower-game">
|
||||
<div class="tower-toolbar">
|
||||
<button
|
||||
type="button"
|
||||
:aria-label="phone.t('Apps.towerStack.backToMenu')"
|
||||
@pointerdown.stop="tower.showMenu()"
|
||||
@click.stop="tower.showMenu()"
|
||||
><ChevronLeft :size="19" /></button>
|
||||
<div><span>{{ phone.t('Apps.towerStack.height') }}</span><strong>{{ game.blocks.length - 1 }}</strong></div>
|
||||
<div><span>{{ phone.t('Apps.towerStack.score') }}</span><strong>{{ game.score }}</strong></div>
|
||||
<button type="button" :aria-label="phone.t('Apps.towerStack.pause')" @click="togglePause">
|
||||
<Pause v-if="game.status === 'playing'" :size="17" fill="currentColor" />
|
||||
<Play v-else :size="17" fill="currentColor" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="tower-stage"
|
||||
:class="{
|
||||
'tower-stage--perfect': placementEffect === 'perfect',
|
||||
'tower-stage--missed': placementEffect === 'missed',
|
||||
}"
|
||||
:aria-label="phone.t('Apps.towerStack.placeBlock')"
|
||||
@pointerdown.stop.prevent="placeBlock"
|
||||
>
|
||||
<div class="tower-sky" aria-hidden="true"><i v-for="star in 13" :key="star"></i></div>
|
||||
<span v-if="placementEffect === 'perfect'" class="tower-perfect">
|
||||
{{ phone.t('Apps.towerStack.perfect') }}
|
||||
</span>
|
||||
<div class="tower-stack" aria-hidden="true">
|
||||
<span
|
||||
v-for="(block, index) in visibleBlocks"
|
||||
:key="block.id"
|
||||
class="tower-block tower-block--placed"
|
||||
:style="blockStyle(block, index)"
|
||||
></span>
|
||||
<span
|
||||
v-if="game.active"
|
||||
class="tower-block tower-block--active"
|
||||
:style="activeStyle"
|
||||
></span>
|
||||
<span
|
||||
v-if="fallingBlock"
|
||||
class="tower-block tower-block--falling"
|
||||
:style="fallingStyle"
|
||||
></span>
|
||||
</div>
|
||||
<div class="tower-ground" aria-hidden="true"></div>
|
||||
</button>
|
||||
|
||||
<p class="tower-game__hint">{{ phone.t('Apps.towerStack.gameHint') }}</p>
|
||||
|
||||
<div v-if="game.status === 'paused'" class="tower-overlay">
|
||||
<Pause :size="30" />
|
||||
<h2>{{ phone.t('Apps.towerStack.paused') }}</h2>
|
||||
<button type="button" class="tower-primary" @click="tower.resume()">
|
||||
{{ phone.t('Apps.towerStack.resume') }}
|
||||
</button>
|
||||
<button type="button" class="tower-secondary" @click="tower.showMenu()">
|
||||
{{ phone.t('Apps.towerStack.mainMenu') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="game.status === 'over' && gameOverVisible"
|
||||
class="tower-overlay tower-overlay--over"
|
||||
>
|
||||
<Layers3 :size="34" />
|
||||
<span>{{ phone.t('Apps.towerStack.gameOver') }}</span>
|
||||
<h2>{{ game.blocks.length - 1 }} {{ phone.t('Apps.towerStack.blocks') }}</h2>
|
||||
<p>{{ phone.t('Apps.towerStack.finalScore') }}: {{ game.score }}</p>
|
||||
<button type="button" class="tower-primary" @click="startGame">
|
||||
<RotateCcw :size="16" /> {{ phone.t('Apps.towerStack.playAgain') }}
|
||||
</button>
|
||||
<button type="button" class="tower-secondary" @click="tower.showMenu()">
|
||||
{{ phone.t('Apps.towerStack.mainMenu') }}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.tower-app { position: absolute; inset: 0; overflow: hidden; padding: 52px 16px 27px; color: #eef5ff; background: radial-gradient(circle at 75% 8%, #7146c866, transparent 35%), linear-gradient(170deg, #161634, #242054 52%, #10132c); font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; user-select: none; touch-action: manipulation; }
|
||||
.tower-header { height: 55px; display: flex; align-items: center; justify-content: space-between; }
|
||||
.tower-header span { display: block; color: #a69dd3; font-size: 9px; font-weight: 850; letter-spacing: 1.1px; text-transform: uppercase; }
|
||||
.tower-header h1 { margin: 0; font-size: 24px; line-height: 1; letter-spacing: -0.8px; }
|
||||
.tower-header button, .tower-toolbar button { width: 36px; height: 36px; display: grid; place-items: center; padding: 0; border: 1px solid #ffffff14; border-radius: 12px; color: #f2edff; background: #ffffff0d; }
|
||||
.tower-menu { height: calc(100% - 55px); display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 13px; text-align: center; }
|
||||
.tower-menu__preview { position: relative; width: 190px; height: 235px; }
|
||||
.tower-menu__preview i { position: absolute; right: 12px; bottom: calc(var(--preview-index) * 25px); left: 25px; height: 29px; border-radius: 7px; background: hsl(calc(var(--preview-index) * 49deg + 5deg) 82% 62%); box-shadow: inset 0 4px 0 #ffffff35, 0 8px 15px #08091c5c; transform: perspective(200px) rotateX(5deg); }
|
||||
.tower-menu__preview i:nth-child(even) { right: 25px; left: 12px; }
|
||||
.tower-menu__preview span { position: absolute; top: 4px; left: 2px; width: 120px; height: 29px; border-radius: 7px; background: #ff6b68; box-shadow: 0 0 24px #ff6b6877; animation: tower-preview-slide 1.5s ease-in-out infinite alternate; }
|
||||
.tower-menu__copy > span { color: #ffbd45; font-size: 9px; font-weight: 900; letter-spacing: 1px; text-transform: uppercase; }
|
||||
.tower-menu__copy h2 { margin: 3px 0 5px; font-size: 21px; }
|
||||
.tower-menu__copy p { max-width: 275px; margin: 0; color: #aca7cc; font-size: 10px; line-height: 1.4; }
|
||||
.tower-records { width: 100%; display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
|
||||
.tower-records div { display: grid; gap: 2px; padding: 9px; border: 1px solid #ffffff0e; border-radius: 13px; background: #ffffff0a; }
|
||||
.tower-records span { color: #9790bd; font-size: 8px; font-weight: 800; text-transform: uppercase; }
|
||||
.tower-records strong { font-size: 18px; }
|
||||
.tower-primary, .tower-secondary { width: 100%; min-height: 43px; display: flex; align-items: center; justify-content: center; gap: 7px; border-radius: 14px; font-size: 11px; font-weight: 850; }
|
||||
.tower-primary { border: 0; color: #1e1839; background: linear-gradient(135deg, #ffca4f, #ff8760); box-shadow: 0 8px 18px #ff895330; }
|
||||
.tower-secondary { border: 1px solid #ffffff16; color: #eeeaff; background: #ffffff0a; }
|
||||
.tower-menu__hint, .tower-game__hint { margin: 0; color: #8d87b3; font-size: 9px; }
|
||||
.tower-game { position: relative; height: calc(100% - 55px); }
|
||||
.tower-toolbar { height: 47px; display: grid; grid-template-columns: 36px 1fr 1fr 36px; align-items: center; gap: 7px; }
|
||||
.tower-toolbar div { display: grid; justify-items: center; line-height: 1.05; }
|
||||
.tower-toolbar span { color: #918ab9; font-size: 8px; font-weight: 850; text-transform: uppercase; }
|
||||
.tower-toolbar strong { font-size: 16px; }
|
||||
.tower-stage { position: relative; width: 100%; height: 500px; display: block; overflow: hidden; padding: 0; border: 1px solid #ffffff12; border-radius: 22px; background: linear-gradient(#17173b, #322361 60%, #70426c); box-shadow: inset 0 0 35px #08091d99, 0 16px 28px #08091c66; touch-action: manipulation; }
|
||||
.tower-sky { position: absolute; inset: 0; pointer-events: none; }
|
||||
.tower-sky i { position: absolute; width: 3px; height: 3px; border-radius: 50%; background: #fff; box-shadow: 0 0 7px #c5c2ff; opacity: .65; }
|
||||
.tower-sky i:nth-child(1) { top: 8%; left: 12%; } .tower-sky i:nth-child(2) { top: 17%; left: 72%; } .tower-sky i:nth-child(3) { top: 28%; left: 42%; } .tower-sky i:nth-child(4) { top: 37%; left: 88%; } .tower-sky i:nth-child(5) { top: 46%; left: 18%; } .tower-sky i:nth-child(6) { top: 58%; left: 64%; } .tower-sky i:nth-child(7) { top: 70%; left: 31%; } .tower-sky i:nth-child(8) { top: 11%; left: 91%; } .tower-sky i:nth-child(9) { top: 22%; left: 25%; } .tower-sky i:nth-child(10) { top: 50%; left: 78%; } .tower-sky i:nth-child(11) { top: 64%; left: 8%; } .tower-sky i:nth-child(12) { top: 77%; left: 92%; } .tower-sky i:nth-child(13) { top: 33%; left: 58%; }
|
||||
.tower-stack { position: absolute; inset: 0 14px; }
|
||||
.tower-block { position: absolute; height: 31px; border-radius: 7px; background: linear-gradient(180deg, color-mix(in srgb, var(--tower-block-color), white 18%), var(--tower-block-color)); box-shadow: inset 0 4px 0 #ffffff35, inset 0 -4px 0 #00000016, 0 7px 10px #08091d55; }
|
||||
.tower-block--active { z-index: 3; box-shadow: inset 0 4px 0 #ffffff45, 0 0 18px color-mix(in srgb, var(--tower-block-color), transparent 45%); }
|
||||
.tower-block--placed { animation: tower-land 180ms ease-out; }
|
||||
.tower-block--falling { z-index: 4; animation: tower-fall 850ms ease-in forwards; }
|
||||
.tower-ground { position: absolute; right: 0; bottom: 0; left: 0; height: 35px; background: linear-gradient(#31224f, #16152e); box-shadow: 0 -8px 20px #a9547928; }
|
||||
.tower-perfect { position: absolute; z-index: 8; top: 24%; left: 50%; padding: 8px 17px; border-radius: 18px; color: #332149; background: #ffdc65; box-shadow: 0 0 24px #ffcf64aa; font-size: 13px; font-weight: 950; letter-spacing: .8px; transform: translateX(-50%); animation: tower-perfect-pop 650ms ease-out forwards; }
|
||||
.tower-stage--perfect { animation: tower-perfect-glow 500ms ease-out; }
|
||||
.tower-stage--missed { animation: tower-stage-shake 500ms ease-out; }
|
||||
.tower-game__hint { margin-top: 8px; text-align: center; }
|
||||
.tower-overlay { position: absolute; z-index: 15; inset: 47px 0 25px; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 9px; padding: 28px; border-radius: 22px; color: #f7f2ff; background: #11122bd9; backdrop-filter: blur(7px); text-align: center; }
|
||||
.tower-overlay > svg { color: #ffbd4d; }
|
||||
.tower-overlay > span { color: #ff9b65; font-size: 9px; font-weight: 900; letter-spacing: 1.2px; text-transform: uppercase; }
|
||||
.tower-overlay h2 { margin: 0; font-size: 25px; }
|
||||
.tower-overlay p { margin: -3px 0 5px; color: #a9a3c9; font-size: 10px; }
|
||||
@keyframes tower-preview-slide { from { transform: translateX(0) rotate(-2deg); } to { transform: translateX(66px) rotate(2deg); } }
|
||||
@keyframes tower-land { from { filter: brightness(1.8); transform: translateY(-8px) scaleY(.85); } to { filter: brightness(1); transform: translateY(0) scaleY(1); } }
|
||||
@keyframes tower-fall { 0% { opacity: 1; transform: translate(0) rotate(0); } 100% { opacity: 0; transform: translate(45px, 390px) rotate(145deg); } }
|
||||
@keyframes tower-perfect-pop { 0% { opacity: 0; transform: translateX(-50%) scale(.4); } 35% { opacity: 1; transform: translateX(-50%) scale(1.2); } 100% { opacity: 0; transform: translateX(-50%) scale(1); } }
|
||||
@keyframes tower-perfect-glow { 0%, 100% { box-shadow: inset 0 0 35px #08091d99, 0 16px 28px #08091c66; } 45% { box-shadow: inset 0 0 45px #ffd75c55, 0 0 30px #ffd75c66; } }
|
||||
@keyframes tower-stage-shake { 0%, 100% { transform: translate(0); } 20% { transform: translate(-5px, 2px); } 40% { transform: translate(5px, -2px); } 60% { transform: translate(-3px, 1px); } 80% { transform: translate(2px); } }
|
||||
button:active { transform: scale(.97); }
|
||||
@media (prefers-reduced-motion: reduce) { .tower-menu__preview span, .tower-block, .tower-perfect, .tower-stage { animation: none; } }
|
||||
</style>
|
||||
@@ -112,6 +112,19 @@ Locales["en"] = {
|
||||
playAgain = "Play Again", restart = "Restart", resume = "Continue Game", time = "Time",
|
||||
unmute = "Turn on game sounds", wonBody = "Minefield cleared in {time}.", wonTitle = "Field cleared!",
|
||||
},
|
||||
towerStack = {
|
||||
name = "Tower Stack", backToMenu = "Back to game menu", bestHeight = "Best Height",
|
||||
bestScore = "Best Score", blocks = "Blocks", eyebrow = "Timing challenge", finalScore = "Final Score",
|
||||
gameHint = "Tap the playfield to place the moving block", gameOver = "Tower collapsed",
|
||||
height = "Height", mainMenu = "Main Menu",
|
||||
menuBody = "Stop each moving block above the tower. Only the overlapping part remains.",
|
||||
menuTitle = "How high can you build?", mute = "Mute game sounds", newGame = "Start New Game",
|
||||
pause = "Pause or resume game", paused = "Paused", perfect = "PERFECT!",
|
||||
placeBlock = "Place moving block", playAgain = "Play Again", ready = "Ready to stack?",
|
||||
resume = "Continue Game", score = "Score", start = "Start Game",
|
||||
tapHint = "Every tap places one block · perfect hits earn bonus points",
|
||||
unmute = "Turn on game sounds",
|
||||
},
|
||||
camera = {
|
||||
name = "Camera", shutter = "Take photo", flip = "Flip camera", flash = "Toggle flash", controls = "Camera controls",
|
||||
modes = { timelapse = "Timelapse", slowMo = "Slow-Mo", cinematic = "Cinematic", video = "Video", photo = "Photo", portrait = "Portrait", pano = "Pano" },
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Sky Phone</title>
|
||||
<script type="module" crossorigin src="./assets/sky-index-lJl_5aH2.js"></script>
|
||||
<script type="module" crossorigin src="./assets/sky-index-2wOQNR-p.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="./assets/sky-index-D9eWeoNZ.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
Reference in New Issue
Block a user