mirror of
https://github.com/sky-systems/sky_phone.git
synced 2026-09-04 17:23:25 +00:00
ADD - number merge phone game
This commit is contained in:
Binary file not shown.
|
After Width: | Height: | Size: 23 KiB |
@@ -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))
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -14,6 +14,7 @@ export type PhoneAppId =
|
||||
| 'settings'
|
||||
| 'snake'
|
||||
| 'memory'
|
||||
| 'number-merge'
|
||||
|
||||
export type AppLaunchOrigin = {
|
||||
borderRadius: number
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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>
|
||||
@@ -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" },
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user