ADD - number merge phone game

This commit is contained in:
smx.pusha
2026-08-06 08:40:03 +02:00
parent 7a9836317a
commit 8c1a79978c
14 changed files with 1370 additions and 1 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

+6
View File
@@ -38,6 +38,12 @@ describe('app registry', () => {
labelKey: 'Apps.memory.name',
route: '/apps/memory',
})
expect(PHONE_APPS.find((app) => app.id === 'number-merge')).toMatchObject({
dockOrder: null,
gridOrder: 13,
labelKey: 'Apps.numberMerge.name',
route: '/apps/number-merge',
})
expect(
PHONE_APPS.filter((app) => app.dockOrder !== null)
.sort((a, b) => (a.dockOrder ?? 0) - (b.dockOrder ?? 0))
+15
View File
@@ -3,6 +3,7 @@ import {
Camera,
Clock3,
Gamepad2,
Grid2X2,
Brain,
Images,
Mail,
@@ -27,6 +28,7 @@ import phoneIcon from '@/assets/img/app-icons/phone.webp'
import settingsIcon from '@/assets/img/app-icons/settings.webp'
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 weatherIcon from '@/assets/img/app-icons/weather.webp'
import type { PhoneAppDefinition, PhoneAppId } from '@/types/apps'
@@ -200,6 +202,19 @@ export const PHONE_APPS: PhoneAppDefinition[] = [
labelKey: 'Apps.memory.name',
route: '/apps/memory',
},
{
component: markRaw(
defineAsyncComponent(() => import('@/views/apps/NumberMergeApp.vue')),
),
dockOrder: null,
gridOrder: 13,
icon: markRaw(Grid2X2),
iconClass: 'app-icon--number-merge',
iconImage: numberMergeIcon,
id: 'number-merge',
labelKey: 'Apps.numberMerge.name',
route: '/apps/number-merge',
},
]
export const PHONE_APP_IDS = PHONE_APPS.map((app) => app.id)
@@ -0,0 +1,58 @@
export type NumberMergeSound = 'game-over' | 'merge' | 'move' | 'win'
type Tone = {
duration: number
frequency: number
offset: number
type: OscillatorType
volume: number
}
const sounds: Record<NumberMergeSound, Tone[]> = {
move: [{ duration: 0.05, frequency: 260, offset: 0, type: 'sine', volume: 0.045 }],
merge: [
{ duration: 0.09, frequency: 390, offset: 0, type: 'sine', volume: 0.065 },
{ duration: 0.12, frequency: 520, offset: 0.055, type: 'sine', volume: 0.075 },
],
win: [
{ duration: 0.16, frequency: 523.25, offset: 0, type: 'sine', volume: 0.08 },
{ duration: 0.16, frequency: 659.25, offset: 0.1, type: 'sine', volume: 0.085 },
{ duration: 0.16, frequency: 783.99, offset: 0.2, type: 'sine', volume: 0.09 },
{ duration: 0.3, frequency: 1046.5, offset: 0.3, type: 'sine', volume: 0.1 },
],
'game-over': [
{ duration: 0.14, frequency: 260, offset: 0, type: 'triangle', volume: 0.065 },
{ duration: 0.18, frequency: 195, offset: 0.12, type: 'triangle', volume: 0.06 },
{ duration: 0.22, frequency: 146, offset: 0.26, type: 'triangle', volume: 0.055 },
],
}
let audioContext: AudioContext | undefined
export function playNumberMergeSound(
sound: NumberMergeSound,
enabled: boolean,
): void {
if (!enabled) return
audioContext ??= new AudioContext()
if (audioContext.state === 'suspended') void audioContext.resume()
const now = audioContext.currentTime
for (const tone of sounds[sound]) {
const oscillator = audioContext.createOscillator()
const gain = audioContext.createGain()
const start = now + tone.offset
const end = start + tone.duration
oscillator.type = tone.type
oscillator.frequency.setValueAtTime(tone.frequency, start)
gain.gain.setValueAtTime(0.0001, start)
gain.gain.exponentialRampToValueAtTime(tone.volume, start + 0.012)
gain.gain.exponentialRampToValueAtTime(0.0001, end)
oscillator.connect(gain)
gain.connect(audioContext.destination)
oscillator.start(start)
oscillator.stop(end)
}
}
@@ -0,0 +1,156 @@
import { describe, expect, it } from 'vitest'
import {
canMoveNumberMerge,
continueNumberMerge,
createNumberMergeGame,
isNumberMergeGameState,
moveNumberMerge,
} from './engine'
import type { NumberMergeGameState } from './types'
function gameFromGrid(grid: number[][]): NumberMergeGameState {
let nextTileId = 1
return {
hasWon: false,
keepPlaying: false,
nextTileId: grid.flat().filter(Boolean).length + 1,
score: 0,
status: 'playing',
tiles: grid.flatMap((row, rowIndex) =>
row.flatMap((value, columnIndex) =>
value
? [
{
column: columnIndex,
id: nextTileId++,
isNew: false,
merged: false,
row: rowIndex,
value,
},
]
: [],
),
),
}
}
function values(state: NumberMergeGameState): number[][] {
return Array.from({ length: 4 }, (_, row) =>
Array.from(
{ length: 4 },
(_, column) =>
state.tiles.find(
(tile) => tile.row === row && tile.column === column,
)?.value ?? 0,
),
)
}
const spawnFirstTwo = () => 0
describe('number merge engine', () => {
it('starts with two tiles on different cells', () => {
const game = createNumberMergeGame(spawnFirstTwo)
expect(game.tiles).toHaveLength(2)
expect(game.tiles.map((tile) => tile.value)).toEqual([2, 2])
expect(
new Set(game.tiles.map((tile) => `${tile.row}:${tile.column}`)).size,
).toBe(2)
})
it('compresses and merges a row to the left', () => {
const result = moveNumberMerge(
gameFromGrid([
[2, 0, 2, 4],
[0, 0, 0, 0],
[0, 0, 0, 0],
[0, 0, 0, 0],
]),
'left',
spawnFirstTwo,
)
expect(values(result.state)[0].slice(0, 3)).toEqual([4, 4, 2])
expect(result.gained).toBe(4)
})
it('merges each tile only once per move', () => {
const result = moveNumberMerge(
gameFromGrid([
[2, 2, 2, 2],
[0, 0, 0, 0],
[0, 0, 0, 0],
[0, 0, 0, 0],
]),
'left',
spawnFirstTwo,
)
expect(values(result.state)[0].slice(0, 3)).toEqual([4, 4, 2])
expect(result.gained).toBe(8)
})
it('moves columns upward using the same merge rules', () => {
const result = moveNumberMerge(
gameFromGrid([
[2, 0, 0, 0],
[2, 0, 0, 0],
[4, 0, 0, 0],
[4, 0, 0, 0],
]),
'up',
spawnFirstTwo,
)
expect(values(result.state).map((row) => row[0])).toEqual([4, 8, 0, 0])
expect(result.state.tiles).toHaveLength(3)
expect(result.gained).toBe(12)
})
it('does not spawn a tile after an invalid move', () => {
const game = gameFromGrid([
[2, 4, 8, 16],
[0, 0, 0, 0],
[0, 0, 0, 0],
[0, 0, 0, 0],
])
const result = moveNumberMerge(game, 'left', spawnFirstTwo)
expect(result.changed).toBe(false)
expect(result.state).toBe(game)
})
it('recognizes a board with no remaining moves', () => {
const game = gameFromGrid([
[2, 4, 2, 4],
[4, 2, 4, 2],
[2, 4, 2, 4],
[4, 2, 4, 2],
])
expect(canMoveNumberMerge(game)).toBe(false)
})
it('pauses at 2048 and can continue afterward', () => {
const result = moveNumberMerge(
gameFromGrid([
[1024, 1024, 0, 0],
[2, 4, 8, 16],
[0, 0, 0, 0],
[0, 0, 0, 0],
]),
'left',
spawnFirstTwo,
)
expect(result.state.status).toBe('won')
expect(result.state.hasWon).toBe(true)
expect(continueNumberMerge(result.state).status).toBe('playing')
})
it('rejects malformed persisted games', () => {
expect(isNumberMergeGameState(gameFromGrid([[2], [], [], []]))).toBe(true)
expect(
isNumberMergeGameState({
...gameFromGrid([[2], [], [], []]),
tiles: [{ column: 8, id: 1, isNew: false, merged: false, row: 0, value: 3 }],
}),
).toBe(false)
})
})
@@ -0,0 +1,251 @@
import type {
NumberMergeDirection,
NumberMergeGameState,
NumberMergeMoveResult,
NumberMergeTile,
} from './types'
export const NUMBER_MERGE_BOARD_SIZE = 4
export const NUMBER_MERGE_WIN_VALUE = 2048
type Point = {
column: number
row: number
}
function pointKey(point: Point): string {
return `${point.row}:${point.column}`
}
function linePoints(direction: NumberMergeDirection, line: number): Point[] {
return Array.from({ length: NUMBER_MERGE_BOARD_SIZE }, (_, offset) => {
if (direction === 'left') return { column: offset, row: line }
if (direction === 'right') {
return { column: NUMBER_MERGE_BOARD_SIZE - 1 - offset, row: line }
}
if (direction === 'up') return { column: line, row: offset }
return { column: line, row: NUMBER_MERGE_BOARD_SIZE - 1 - offset }
})
}
function availablePoints(tiles: NumberMergeTile[]): Point[] {
const occupied = new Set(tiles.map(pointKey))
const points: Point[] = []
for (let row = 0; row < NUMBER_MERGE_BOARD_SIZE; row += 1) {
for (let column = 0; column < NUMBER_MERGE_BOARD_SIZE; column += 1) {
const point = { column, row }
if (!occupied.has(pointKey(point))) points.push(point)
}
}
return points
}
function isPowerOfTwo(value: number): boolean {
return value >= 2 && Number.isInteger(Math.log2(value))
}
export function spawnNumberMergeTile(
state: NumberMergeGameState,
random: () => number = Math.random,
): NumberMergeGameState {
const points = availablePoints(state.tiles)
if (points.length === 0) return state
const pointIndex = Math.min(
points.length - 1,
Math.floor(random() * points.length),
)
const point = points[pointIndex]
const tile: NumberMergeTile = {
...point,
id: state.nextTileId,
isNew: true,
merged: false,
value: random() < 0.9 ? 2 : 4,
}
return {
...state,
nextTileId: state.nextTileId + 1,
tiles: [...state.tiles, tile],
}
}
export function canMoveNumberMerge(state: NumberMergeGameState): boolean {
if (state.tiles.length < NUMBER_MERGE_BOARD_SIZE ** 2) return true
const values = new Map(state.tiles.map((tile) => [pointKey(tile), tile.value]))
for (const tile of state.tiles) {
const right = values.get(pointKey({ column: tile.column + 1, row: tile.row }))
const down = values.get(pointKey({ column: tile.column, row: tile.row + 1 }))
if (right === tile.value || down === tile.value) return true
}
return false
}
export function createNumberMergeGame(
random: () => number = Math.random,
): NumberMergeGameState {
const empty: NumberMergeGameState = {
hasWon: false,
keepPlaying: false,
nextTileId: 1,
score: 0,
status: 'playing',
tiles: [],
}
return spawnNumberMergeTile(spawnNumberMergeTile(empty, random), random)
}
export function moveNumberMerge(
state: NumberMergeGameState,
direction: NumberMergeDirection,
random: () => number = Math.random,
): NumberMergeMoveResult {
if (state.status !== 'playing') {
return { changed: false, gained: 0, state }
}
const tileByPoint = new Map(state.tiles.map((tile) => [pointKey(tile), tile]))
const nextTiles: NumberMergeTile[] = []
let gained = 0
for (let line = 0; line < NUMBER_MERGE_BOARD_SIZE; line += 1) {
const points = linePoints(direction, line)
const tiles = points
.map((point) => tileByPoint.get(pointKey(point)))
.filter((tile): tile is NumberMergeTile => tile !== undefined)
let sourceIndex = 0
let targetIndex = 0
while (sourceIndex < tiles.length) {
const tile = tiles[sourceIndex]
const nextTile = tiles[sourceIndex + 1]
const target = points[targetIndex]
if (nextTile?.value === tile.value) {
const value = tile.value * 2
nextTiles.push({
...tile,
...target,
isNew: false,
merged: true,
value,
})
gained += value
sourceIndex += 2
} else {
nextTiles.push({
...tile,
...target,
isNew: false,
merged: false,
})
sourceIndex += 1
}
targetIndex += 1
}
}
const changed =
nextTiles.length !== state.tiles.length ||
nextTiles.some((tile) => {
const previous = state.tiles.find((candidate) => candidate.id === tile.id)
return (
!previous ||
previous.column !== tile.column ||
previous.row !== tile.row ||
previous.value !== tile.value
)
})
if (!changed) return { changed: false, gained: 0, state }
let nextState = spawnNumberMergeTile(
{
...state,
score: state.score + gained,
tiles: nextTiles,
},
random,
)
const reachedWin = nextState.tiles.some(
(tile) => tile.value >= NUMBER_MERGE_WIN_VALUE,
)
const firstWin = reachedWin && !state.hasWon
nextState = {
...nextState,
hasWon: state.hasWon || reachedWin,
status: firstWin
? 'won'
: canMoveNumberMerge(nextState)
? 'playing'
: 'game-over',
}
return { changed: true, gained, state: nextState }
}
export function continueNumberMerge(
state: NumberMergeGameState,
): NumberMergeGameState {
if (state.status !== 'won') return state
return {
...state,
keepPlaying: true,
status: canMoveNumberMerge(state) ? 'playing' : 'game-over',
}
}
export function isNumberMergeGameState(
value: unknown,
): value is NumberMergeGameState {
if (!value || typeof value !== 'object') return false
const game = value as Partial<NumberMergeGameState>
if (
typeof game.hasWon !== 'boolean' ||
typeof game.keepPlaying !== 'boolean' ||
!Number.isInteger(game.nextTileId) ||
(game.nextTileId ?? 0) < 1 ||
!Number.isInteger(game.score) ||
(game.score ?? -1) < 0 ||
!['game-over', 'playing', 'won'].includes(game.status ?? '') ||
!Array.isArray(game.tiles) ||
game.tiles.length > NUMBER_MERGE_BOARD_SIZE ** 2
) {
return false
}
const ids = new Set<number>()
const points = new Set<string>()
for (const candidate of game.tiles) {
if (!candidate || typeof candidate !== 'object') return false
const tile = candidate as Partial<NumberMergeTile>
if (
!Number.isInteger(tile.id) ||
(tile.id ?? 0) < 1 ||
!Number.isInteger(tile.column) ||
(tile.column ?? -1) < 0 ||
(tile.column ?? NUMBER_MERGE_BOARD_SIZE) >= NUMBER_MERGE_BOARD_SIZE ||
!Number.isInteger(tile.row) ||
(tile.row ?? -1) < 0 ||
(tile.row ?? NUMBER_MERGE_BOARD_SIZE) >= NUMBER_MERGE_BOARD_SIZE ||
typeof tile.value !== 'number' ||
!isPowerOfTwo(tile.value) ||
typeof tile.isNew !== 'boolean' ||
typeof tile.merged !== 'boolean'
) {
return false
}
const position = pointKey(tile as Point)
if (ids.has(tile.id as number) || points.has(position)) return false
ids.add(tile.id as number)
points.add(position)
}
return Math.max(0, ...ids) < (game.nextTileId as number)
}
@@ -0,0 +1,117 @@
import { defineStore } from 'pinia'
import { useGamesStore } from '@/features/games/store'
import {
continueNumberMerge,
createNumberMergeGame,
isNumberMergeGameState,
moveNumberMerge,
} from './engine'
import type {
NumberMergeDirection,
NumberMergeGameState,
NumberMergeMoveResult,
} from './types'
type NumberMergeSave = {
bestScore: number
game: NumberMergeGameState | null
highestTile: number
soundEnabled: boolean
}
function savedNumber(value: unknown): number {
return typeof value === 'number' && Number.isFinite(value) && value >= 0
? Math.floor(value)
: 0
}
export const useNumberMergeStore = defineStore('number-merge', {
state: () => ({
bestScore: 0,
game: null as NumberMergeGameState | null,
highestTile: 0,
hydrated: false,
menuOpen: true,
soundEnabled: true,
}),
actions: {
hydrate(): void {
if (this.hydrated) return
const saved = useGamesStore().readGame<Partial<NumberMergeSave>>(
'number-merge',
)
this.game = isNumberMergeGameState(saved?.game)
? {
...structuredClone(saved.game),
tiles: saved.game.tiles.map((tile) => ({
...tile,
isNew: false,
merged: false,
})),
}
: null
this.bestScore = Math.max(
savedNumber(saved?.bestScore),
this.game?.score ?? 0,
)
this.highestTile = Math.max(
savedNumber(saved?.highestTile),
...(this.game?.tiles.map((tile) => tile.value) ?? [0]),
)
if (typeof saved?.soundEnabled === 'boolean') {
this.soundEnabled = saved.soundEnabled
}
this.hydrated = true
},
persist(): void {
useGamesStore().saveGame('number-merge', {
bestScore: this.bestScore,
game: this.game,
highestTile: this.highestTile,
soundEnabled: this.soundEnabled,
} satisfies NumberMergeSave)
},
start(): void {
this.game = createNumberMergeGame()
this.highestTile = Math.max(
this.highestTile,
...this.game.tiles.map((tile) => tile.value),
)
this.menuOpen = false
this.persist()
},
resume(): void {
if (this.game) this.menuOpen = false
},
showMenu(): void {
this.menuOpen = true
},
move(direction: NumberMergeDirection): NumberMergeMoveResult | null {
if (!this.game) return null
const result = moveNumberMerge(this.game, direction)
if (!result.changed) return result
this.game = result.state
this.bestScore = Math.max(this.bestScore, result.state.score)
this.highestTile = Math.max(
this.highestTile,
...result.state.tiles.map((tile) => tile.value),
)
this.persist()
return result
},
continueAfterWin(): void {
if (!this.game) return
this.game = continueNumberMerge(this.game)
this.persist()
},
setSoundEnabled(enabled: boolean): void {
this.soundEnabled = enabled
this.persist()
},
},
})
@@ -0,0 +1,26 @@
export type NumberMergeDirection = 'down' | 'left' | 'right' | 'up'
export type NumberMergeStatus = 'game-over' | 'playing' | 'won'
export type NumberMergeTile = {
column: number
id: number
isNew: boolean
merged: boolean
row: number
value: number
}
export type NumberMergeGameState = {
hasWon: boolean
keepPlaying: boolean
nextTileId: number
score: number
status: NumberMergeStatus
tiles: NumberMergeTile[]
}
export type NumberMergeMoveResult = {
changed: boolean
gained: number
state: NumberMergeGameState
}
+35
View File
@@ -175,6 +175,41 @@ const defaultLocales: LocaleTree = {
time: 'Time',
unmute: 'Turn on game sounds',
},
numberMerge: {
name: '2048',
backToMenu: 'Back to game menu',
best: 'Best',
board: '2048 game board',
cancel: 'Keep Current Game',
confirmBody: 'Your current board will be replaced. This cannot be undone.',
confirmNew: 'Start New Game',
confirmTitle: 'Replace current game?',
continueGame: 'Continue Game',
controls: 'Move controls',
directions: {
down: 'Move tiles down',
left: 'Move tiles left',
right: 'Move tiles right',
up: 'Move tiles up',
},
eyebrow: 'Number puzzle',
gameOverBody: 'No empty spaces or matching neighbours remain.',
gameOverTitle: 'No more moves',
highest: 'Highest Tile',
howToBody: 'Swipe to combine matching tiles. Each pair becomes one larger tile.',
howToTitle: 'How to play',
keepPlaying: 'Keep Playing',
mainMenu: 'Main Menu',
menuBody: 'Slide matching numbers together and work your way up to 2048.',
menuTitle: 'Build the 2048 tile',
mute: 'Mute game sounds',
newGame: 'New Game',
score: 'Score',
swipeHint: 'Swipe the board, use the buttons, or press arrow keys / WASD',
unmute: 'Turn on game sounds',
wonBody: 'You reached the legendary tile. Continue climbing or begin again.',
wonTitle: 'You made 2048!',
},
map: {
name: 'Map',
controls: 'Map controls',
+1
View File
@@ -14,6 +14,7 @@ export type PhoneAppId =
| 'settings'
| 'snake'
| 'memory'
| 'number-merge'
export type AppLaunchOrigin = {
borderRadius: number
+1
View File
@@ -52,6 +52,7 @@ const DEFAULT_APP_NOTIFICATIONS: Record<
calculator: { enabled: true, sounds: true },
snake: { enabled: true, sounds: true },
memory: { enabled: true, sounds: true },
'number-merge': { enabled: true, sounds: true },
camera: { enabled: true, sounds: true },
clock: { enabled: true, sounds: true },
weather: { enabled: true, sounds: true },
+686
View File
@@ -0,0 +1,686 @@
<script setup lang="ts">
import {
ArrowDown,
ArrowLeft,
ArrowRight,
ArrowUp,
ChevronLeft,
RotateCcw,
Volume2,
VolumeX,
} from 'lucide-vue-next'
import { computed, onBeforeUnmount, onMounted, ref, type CSSProperties } from 'vue'
import { playNumberMergeSound } from '@/features/games/number-merge/audio'
import { useNumberMergeStore } from '@/features/games/number-merge/store'
import type {
NumberMergeDirection,
NumberMergeTile,
} from '@/features/games/number-merge/types'
import { usePhoneStore } from '@/stores/phone'
const phone = usePhoneStore()
const numberMerge = useNumberMergeStore()
const touchStart = ref<{ x: number; y: number } | null>(null)
const confirmNewGame = ref(false)
const game = computed(() => numberMerge.game)
const canResume = computed(
() => numberMerge.game !== null && numberMerge.game.status !== 'game-over',
)
const currentHighest = computed(() =>
Math.max(0, ...(numberMerge.game?.tiles.map((tile) => tile.value) ?? [])),
)
const directionButtons: Array<{
direction: NumberMergeDirection
icon: typeof ArrowUp
}> = [
{ direction: 'left', icon: ArrowLeft },
{ direction: 'up', icon: ArrowUp },
{ direction: 'down', icon: ArrowDown },
{ direction: 'right', icon: ArrowRight },
]
function formatScore(value: number): string {
return new Intl.NumberFormat('en-US').format(value)
}
function tileStyle(tile: NumberMergeTile): CSSProperties {
return {
'--tile-column': tile.column,
'--tile-row': tile.row,
} as CSSProperties
}
function tileClass(tile: NumberMergeTile): string {
return tile.value <= 2048
? `number-merge-tile--${tile.value}`
: 'number-merge-tile--super'
}
function move(direction: NumberMergeDirection): void {
if (numberMerge.menuOpen || numberMerge.game?.status !== 'playing') return
const result = numberMerge.move(direction)
if (!result?.changed) return
if (result.state.status === 'won') {
playNumberMergeSound('win', numberMerge.soundEnabled)
} else if (result.state.status === 'game-over') {
playNumberMergeSound('game-over', numberMerge.soundEnabled)
} else if (result.gained > 0) {
playNumberMergeSound('merge', numberMerge.soundEnabled)
} else {
playNumberMergeSound('move', numberMerge.soundEnabled)
}
}
function beginSwipe(event: TouchEvent): void {
const touch = event.changedTouches[0]
touchStart.value = touch ? { x: touch.clientX, y: touch.clientY } : null
}
function endSwipe(event: TouchEvent): void {
const start = touchStart.value
const touch = event.changedTouches[0]
touchStart.value = null
if (!start || !touch) return
const deltaX = touch.clientX - start.x
const deltaY = touch.clientY - start.y
if (Math.max(Math.abs(deltaX), Math.abs(deltaY)) < 20) return
if (Math.abs(deltaX) > Math.abs(deltaY)) {
move(deltaX > 0 ? 'right' : 'left')
} else {
move(deltaY > 0 ? 'down' : 'up')
}
}
function handleKeydown(event: KeyboardEvent): void {
const directions: Partial<Record<string, NumberMergeDirection>> = {
ArrowDown: 'down',
ArrowLeft: 'left',
ArrowRight: 'right',
ArrowUp: 'up',
a: 'left',
d: 'right',
s: 'down',
w: 'up',
}
const direction = directions[event.key]
if (!direction) return
event.preventDefault()
move(direction)
}
function requestNewGame(): void {
if (numberMerge.game && numberMerge.game.status !== 'game-over') {
confirmNewGame.value = true
return
}
numberMerge.start()
}
function startNewGame(): void {
confirmNewGame.value = false
numberMerge.start()
}
function toggleSound(): void {
const enabled = !numberMerge.soundEnabled
numberMerge.setSoundEnabled(enabled)
if (enabled) playNumberMergeSound('move', true)
}
numberMerge.hydrate()
onMounted(() => window.addEventListener('keydown', handleKeydown))
onBeforeUnmount(() => window.removeEventListener('keydown', handleKeydown))
</script>
<template>
<main class="number-merge-app" :aria-label="phone.t('Apps.numberMerge.name')">
<header class="number-merge-header">
<div>
<span>{{ phone.t('Apps.numberMerge.eyebrow') }}</span>
<h1>{{ phone.t('Apps.numberMerge.name') }}</h1>
</div>
<button
type="button"
:aria-label="
phone.t(
numberMerge.soundEnabled
? 'Apps.numberMerge.mute'
: 'Apps.numberMerge.unmute',
)
"
:title="
phone.t(
numberMerge.soundEnabled
? 'Apps.numberMerge.mute'
: 'Apps.numberMerge.unmute',
)
"
@click="toggleSound"
>
<Volume2 v-if="numberMerge.soundEnabled" :size="18" aria-hidden="true" />
<VolumeX v-else :size="18" aria-hidden="true" />
</button>
</header>
<section v-if="numberMerge.menuOpen" class="number-merge-menu">
<div class="number-merge-hero" aria-hidden="true">
<span class="number-merge-hero__tile number-merge-hero__tile--2">2</span>
<span class="number-merge-hero__tile number-merge-hero__tile--0">0</span>
<span class="number-merge-hero__tile number-merge-hero__tile--4">4</span>
<span class="number-merge-hero__tile number-merge-hero__tile--8">8</span>
</div>
<div class="number-merge-menu__intro">
<h2>{{ phone.t('Apps.numberMerge.menuTitle') }}</h2>
<p>{{ phone.t('Apps.numberMerge.menuBody') }}</p>
</div>
<div class="number-merge-records">
<div>
<span>{{ phone.t('Apps.numberMerge.best') }}</span>
<strong>{{ formatScore(numberMerge.bestScore) }}</strong>
</div>
<div>
<span>{{ phone.t('Apps.numberMerge.highest') }}</span>
<strong>{{ numberMerge.highestTile || '' }}</strong>
</div>
</div>
<div class="number-merge-menu__actions">
<button
v-if="canResume"
type="button"
class="number-merge-primary"
@click="numberMerge.resume"
>
{{ phone.t('Apps.numberMerge.continueGame') }}
</button>
<button
type="button"
:class="canResume ? 'number-merge-secondary' : 'number-merge-primary'"
@click="requestNewGame"
>
{{ phone.t('Apps.numberMerge.newGame') }}
</button>
</div>
<div class="number-merge-how-to">
<strong>{{ phone.t('Apps.numberMerge.howToTitle') }}</strong>
<p>{{ phone.t('Apps.numberMerge.howToBody') }}</p>
</div>
</section>
<section v-else-if="game" class="number-merge-game">
<div class="number-merge-toolbar">
<button
type="button"
class="number-merge-toolbar__icon"
:aria-label="phone.t('Apps.numberMerge.backToMenu')"
:title="phone.t('Apps.numberMerge.backToMenu')"
@click="numberMerge.showMenu"
>
<ChevronLeft :size="19" :stroke-width="2.7" aria-hidden="true" />
</button>
<div>
<span>{{ phone.t('Apps.numberMerge.score') }}</span>
<strong>{{ formatScore(game.score) }}</strong>
</div>
<div>
<span>{{ phone.t('Apps.numberMerge.best') }}</span>
<strong>{{ formatScore(numberMerge.bestScore) }}</strong>
</div>
<button
type="button"
class="number-merge-toolbar__icon number-merge-toolbar__restart"
:aria-label="phone.t('Apps.numberMerge.newGame')"
:title="phone.t('Apps.numberMerge.newGame')"
@click="requestNewGame"
>
<RotateCcw :size="17" :stroke-width="2.5" aria-hidden="true" />
</button>
</div>
<div
class="number-merge-board"
:aria-label="phone.t('Apps.numberMerge.board')"
@touchstart.passive="beginSwipe"
@touchend.passive="endSwipe"
>
<span v-for="cell in 16" :key="`cell-${cell}`" class="number-merge-cell"></span>
<span
v-for="tile in game.tiles"
:key="tile.id"
class="number-merge-tile"
:class="[
tileClass(tile),
{
'number-merge-tile--new': tile.isNew,
'number-merge-tile--merged': tile.merged,
},
]"
:style="tileStyle(tile)"
>
<strong>{{ tile.value }}</strong>
</span>
<div v-if="game.status !== 'playing'" class="number-merge-overlay">
<span>{{ currentHighest }}</span>
<h2>
{{
phone.t(
game.status === 'won'
? 'Apps.numberMerge.wonTitle'
: 'Apps.numberMerge.gameOverTitle',
)
}}
</h2>
<p>
{{
phone.t(
game.status === 'won'
? 'Apps.numberMerge.wonBody'
: 'Apps.numberMerge.gameOverBody',
)
}}
</p>
<button
v-if="game.status === 'won'"
type="button"
class="number-merge-primary"
@click="numberMerge.continueAfterWin"
>
{{ phone.t('Apps.numberMerge.keepPlaying') }}
</button>
<button
type="button"
:class="
game.status === 'won'
? 'number-merge-secondary'
: 'number-merge-primary'
"
@click="requestNewGame"
>
{{ phone.t('Apps.numberMerge.newGame') }}
</button>
<button type="button" class="number-merge-link" @click="numberMerge.showMenu">
{{ phone.t('Apps.numberMerge.mainMenu') }}
</button>
</div>
</div>
<p class="number-merge-hint">{{ phone.t('Apps.numberMerge.swipeHint') }}</p>
<div class="number-merge-controls" :aria-label="phone.t('Apps.numberMerge.controls')">
<button
v-for="control in directionButtons"
:key="control.direction"
type="button"
:aria-label="phone.t(`Apps.numberMerge.directions.${control.direction}`)"
@click="move(control.direction)"
>
<component :is="control.icon" :size="19" :stroke-width="2.5" aria-hidden="true" />
</button>
</div>
</section>
<div v-if="confirmNewGame" class="number-merge-confirm" role="dialog" aria-modal="true">
<div>
<h2>{{ phone.t('Apps.numberMerge.confirmTitle') }}</h2>
<p>{{ phone.t('Apps.numberMerge.confirmBody') }}</p>
<button type="button" class="number-merge-danger" @click="startNewGame">
{{ phone.t('Apps.numberMerge.confirmNew') }}
</button>
<button type="button" class="number-merge-secondary" @click="confirmNewGame = false">
{{ phone.t('Apps.numberMerge.cancel') }}
</button>
</div>
</div>
</main>
</template>
<style scoped>
.number-merge-app {
position: absolute;
inset: 0;
overflow: hidden;
padding: 52px 17px 27px;
color: #4a302b;
background:
radial-gradient(circle at 86% 8%, rgb(255 192 94 / 28%), transparent 29%),
linear-gradient(155deg, #fff3dc 0%, #f3d8b7 55%, #e8b98d 100%);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
touch-action: none;
user-select: none;
}
.number-merge-header {
height: 55px;
display: flex;
align-items: center;
justify-content: space-between;
}
.number-merge-header span {
display: block;
color: #9c7465;
font-size: 9px;
font-weight: 800;
letter-spacing: 1.2px;
text-transform: uppercase;
}
.number-merge-header h1 {
margin: 0;
font-size: 28px;
line-height: 1;
letter-spacing: -1px;
}
.number-merge-header button,
.number-merge-toolbar__icon {
width: 36px;
height: 36px;
display: grid;
place-items: center;
padding: 0;
border: 1px solid rgb(109 66 52 / 9%);
border-radius: 12px;
color: #784c3c;
background: rgb(255 255 255 / 46%);
box-shadow: 0 4px 10px rgb(95 48 31 / 8%);
cursor: pointer;
}
.number-merge-menu {
height: calc(100% - 55px);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 13px;
text-align: center;
}
.number-merge-hero {
width: 154px;
height: 154px;
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 7px;
padding: 9px;
border-radius: 29px;
background: #7e5147;
box-shadow: 0 17px 30px rgb(93 48 36 / 20%);
transform: rotate(-2deg);
}
.number-merge-hero__tile {
display: grid;
place-items: center;
border-radius: 18px;
font-size: 35px;
font-weight: 900;
box-shadow: inset 0 2px 0 rgb(255 255 255 / 25%), 0 5px 8px rgb(54 24 18 / 17%);
}
.number-merge-hero__tile--2 { color: #765044; background: #f7e1bc; }
.number-merge-hero__tile--0 { color: #fff7e4; background: #eea62e; }
.number-merge-hero__tile--4 { color: #fff7e4; background: #e96c2c; }
.number-merge-hero__tile--8 { color: #fff7e4; background: #713c31; }
.number-merge-menu__intro h2 { margin: 0; font-size: 22px; letter-spacing: -0.5px; }
.number-merge-menu__intro p { max-width: 270px; margin: 5px 0 0; color: #8b675b; font-size: 11px; line-height: 1.4; }
.number-merge-records {
width: 100%;
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 7px;
}
.number-merge-records div {
display: grid;
gap: 1px;
padding: 8px;
border: 1px solid rgb(112 66 49 / 8%);
border-radius: 13px;
background: rgb(255 255 255 / 38%);
}
.number-merge-records span,
.number-merge-toolbar span {
color: #9d7567;
font-size: 8px;
font-weight: 800;
text-transform: uppercase;
}
.number-merge-records strong { font-size: 17px; }
.number-merge-menu__actions { width: 100%; display: grid; gap: 7px; }
.number-merge-primary,
.number-merge-secondary,
.number-merge-danger {
min-height: 42px;
padding: 0 18px;
border-radius: 14px;
font-size: 12px;
font-weight: 850;
cursor: pointer;
}
.number-merge-primary {
border: 0;
color: #fffaf0;
background: linear-gradient(135deg, #d76b32, #a8462d);
box-shadow: 0 8px 17px rgb(142 61 37 / 20%);
}
.number-merge-secondary {
border: 1px solid rgb(104 61 47 / 13%);
color: #70493e;
background: rgb(255 255 255 / 42%);
}
.number-merge-how-to {
padding: 9px 15px;
border-radius: 14px;
color: #795549;
background: rgb(255 255 255 / 28%);
}
.number-merge-how-to strong { font-size: 10px; text-transform: uppercase; }
.number-merge-how-to p { margin: 3px 0 0; font-size: 9px; line-height: 1.35; }
.number-merge-game { padding-top: 5px; }
.number-merge-toolbar {
height: 48px;
display: grid;
grid-template-columns: 36px 1fr 1fr 36px;
align-items: center;
gap: 7px;
}
.number-merge-toolbar div {
min-width: 0;
display: grid;
justify-items: center;
line-height: 1.05;
}
.number-merge-toolbar strong { max-width: 80px; overflow: hidden; font-size: 16px; text-overflow: ellipsis; }
.number-merge-toolbar__restart { justify-self: end; }
.number-merge-board {
--board-gap: 7px;
--board-padding: 9px;
position: relative;
width: 100%;
aspect-ratio: 1;
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: var(--board-gap);
padding: var(--board-padding);
border: 1px solid rgb(78 41 32 / 9%);
border-radius: 21px;
background: #80564c;
box-shadow: inset 0 2px 2px rgb(255 255 255 / 10%), 0 17px 28px rgb(101 52 38 / 18%);
}
.number-merge-cell {
aspect-ratio: 1;
border-radius: 13px;
background: rgb(255 235 207 / 16%);
box-shadow: inset 0 1px 3px rgb(53 24 19 / 12%);
}
.number-merge-tile {
position: absolute;
z-index: 2;
left: var(--board-padding);
top: var(--board-padding);
width: calc((100% - var(--board-padding) * 2 - var(--board-gap) * 3) / 4);
aspect-ratio: 1;
transform: translate(
calc(var(--tile-column) * (100% + var(--board-gap))),
calc(var(--tile-row) * (100% + var(--board-gap)))
);
transition: transform 145ms cubic-bezier(0.22, 0.68, 0.3, 1);
will-change: transform;
}
.number-merge-tile strong {
width: 100%;
height: 100%;
display: grid;
place-items: center;
border-radius: 13px;
color: #725044;
background: #f5dfbc;
box-shadow: inset 0 2px 0 rgb(255 255 255 / 30%), 0 4px 7px rgb(47 20 15 / 18%);
font-size: 25px;
font-weight: 900;
letter-spacing: -1px;
}
.number-merge-tile--4 strong { background: #f4ce91; }
.number-merge-tile--8 strong { color: #fff9eb; background: #ee9c4a; }
.number-merge-tile--16 strong { color: #fff9eb; background: #ec7e38; }
.number-merge-tile--32 strong { color: #fff9eb; background: #e96335; }
.number-merge-tile--64 strong { color: #fff9eb; background: #d94b2e; }
.number-merge-tile--128 strong { color: #fff9e6; background: #d5a333; font-size: 21px; }
.number-merge-tile--256 strong { color: #fff9e6; background: #c98d29; font-size: 21px; }
.number-merge-tile--512 strong { color: #fff9e6; background: #b97624; font-size: 21px; }
.number-merge-tile--1024 strong { color: #fff; background: #8f4935; font-size: 17px; }
.number-merge-tile--2048 strong { color: #fff; background: #642f3a; font-size: 17px; box-shadow: 0 0 17px rgb(255 191 69 / 55%); }
.number-merge-tile--super strong { color: #fff; background: #3e2634; font-size: 14px; }
.number-merge-tile--new strong { animation: number-merge-spawn 210ms cubic-bezier(0.2, 0.9, 0.3, 1.35); }
.number-merge-tile--merged strong { animation: number-merge-pop 250ms cubic-bezier(0.2, 0.9, 0.3, 1.35); }
@keyframes number-merge-spawn {
0% { opacity: 0; transform: scale(0.35); }
100% { opacity: 1; transform: scale(1); }
}
@keyframes number-merge-pop {
0% { transform: scale(1); }
48% { transform: scale(1.16); }
100% { transform: scale(1); }
}
.number-merge-overlay {
position: absolute;
z-index: 8;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8px;
padding: 22px;
border-radius: 20px;
background: rgb(74 42 35 / 86%);
backdrop-filter: blur(5px);
color: #fff7ea;
text-align: center;
}
.number-merge-overlay > span { color: #ffc75b; font-size: 29px; font-weight: 900; }
.number-merge-overlay h2 { margin: 0; font-size: 25px; }
.number-merge-overlay p { margin: -3px 0 4px; color: #e7cabd; font-size: 11px; line-height: 1.35; }
.number-merge-overlay button { min-width: 160px; }
.number-merge-overlay .number-merge-secondary { color: #fff1df; background: rgb(255 255 255 / 8%); border-color: rgb(255 255 255 / 13%); }
.number-merge-link {
min-height: 28px;
border: 0;
color: #e9cec2;
background: transparent;
font-size: 10px;
font-weight: 800;
}
.number-merge-hint { margin: 10px 0 7px; color: #976f61; font-size: 9px; text-align: center; }
.number-merge-controls {
display: grid;
grid-template-columns: repeat(4, 38px);
justify-content: center;
gap: 7px;
}
.number-merge-controls button {
width: 38px;
height: 38px;
display: grid;
place-items: center;
padding: 0;
border: 1px solid rgb(97 56 44 / 10%);
border-radius: 12px;
color: #71483b;
background: rgb(255 255 255 / 38%);
}
.number-merge-confirm {
position: absolute;
z-index: 20;
inset: 0;
display: grid;
place-items: center;
padding: 30px;
background: rgb(54 29 25 / 54%);
backdrop-filter: blur(6px);
}
.number-merge-confirm > div {
width: 100%;
display: grid;
gap: 9px;
padding: 21px;
border-radius: 21px;
color: #4a302b;
background: #fff1dd;
box-shadow: 0 20px 45px rgb(52 23 18 / 28%);
text-align: center;
}
.number-merge-confirm h2 { margin: 0; font-size: 21px; }
.number-merge-confirm p { margin: 0 0 5px; color: #876359; font-size: 11px; line-height: 1.4; }
.number-merge-danger { border: 0; color: #fff; background: #b64f39; }
button:active { transform: scale(0.97); }
@media (prefers-reduced-motion: reduce) {
.number-merge-tile { transition-duration: 1ms; }
.number-merge-tile--new strong,
.number-merge-tile--merged strong { animation: none; }
}
</style>
+17
View File
@@ -80,6 +80,23 @@ Locales["en"] = {
noBest = "No best score yet", playAgain = "Play Again", restart = "Restart",
revealedCard = "Revealed card", time = "Time", unmute = "Turn on game sounds",
},
numberMerge = {
name = "2048", backToMenu = "Back to game menu", best = "Best", board = "2048 game board",
cancel = "Keep Current Game", confirmBody = "Your current board will be replaced. This cannot be undone.",
confirmNew = "Start New Game", confirmTitle = "Replace current game?", continueGame = "Continue Game",
controls = "Move controls", directions = {
down = "Move tiles down", left = "Move tiles left", right = "Move tiles right", up = "Move tiles up",
},
eyebrow = "Number puzzle", gameOverBody = "No empty spaces or matching neighbours remain.",
gameOverTitle = "No more moves", highest = "Highest Tile",
howToBody = "Swipe to combine matching tiles. Each pair becomes one larger tile.", howToTitle = "How to play",
keepPlaying = "Keep Playing", mainMenu = "Main Menu",
menuBody = "Slide matching numbers together and work your way up to 2048.", menuTitle = "Build the 2048 tile",
mute = "Mute game sounds", newGame = "New Game", score = "Score",
swipeHint = "Swipe the board, use the buttons, or press arrow keys / WASD",
unmute = "Turn on game sounds",
wonBody = "You reached the legendary tile. Continue climbing or begin again.", wonTitle = "You made 2048!",
},
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" },
+1 -1
View File
@@ -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-EnyJP-0P.js"></script>
<script type="module" crossorigin src="./assets/sky-index-DUWYfXgl.js"></script>
<link rel="stylesheet" crossorigin href="./assets/sky-index-yBie1g9x.css">
</head>
<body>