Initial commit — 2048 terminal game
Plain TS + Vite, JetBrains Mono terminal aesthetic. Includes engine (pure logic, direction normalization, tile tracking), animated UI (CSS transitions + keyframes), keyboard/touch input, Dockerfile, nginx config, docker-compose, and Gitea Actions CI workflow. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2
.env.example
Normal file
2
.env.example
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
IMAGE=gitea.example.com/explewd/2048:latest
|
||||||
|
PORT=3009
|
||||||
57
.gitea/workflows/build.yml
Normal file
57
.gitea/workflows/build.yml
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
name: Docker
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-and-push:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Derive image name and tags
|
||||||
|
id: meta
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SERVER_URL="${GITHUB_SERVER_URL:-${GITEA_SERVER_URL:-}}"
|
||||||
|
REPO="${GITHUB_REPOSITORY:-${GITEA_REPOSITORY:-}}"
|
||||||
|
SHA="${GITHUB_SHA:-${GITEA_SHA:-}}"
|
||||||
|
|
||||||
|
if [[ -z "${SERVER_URL}" || -z "${REPO}" || -z "${SHA}" ]]; then
|
||||||
|
echo "Missing SERVER_URL/REPO/SHA env vars." >&2
|
||||||
|
echo "SERVER_URL='${SERVER_URL}' REPO='${REPO}' SHA='${SHA}'" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
HOST=$(echo "${SERVER_URL}" | sed 's|https://||;s|http://||')
|
||||||
|
IMAGE=$(echo "${HOST}/${REPO}" | tr '[:upper:]' '[:lower:]')
|
||||||
|
SHORT_SHA=$(echo "${SHA}" | cut -c1-7)
|
||||||
|
|
||||||
|
echo "host=${HOST}" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "image=${IMAGE}" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "short_sha=${SHORT_SHA}" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
- name: Log in to Gitea container registry
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: ${{ steps.meta.outputs.host }}
|
||||||
|
username: ${{ github.actor }}
|
||||||
|
password: ${{ secrets.TKNTKN }}
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- name: Build and push
|
||||||
|
uses: docker/build-push-action@v6
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
file: Dockerfile
|
||||||
|
push: true
|
||||||
|
tags: |
|
||||||
|
${{ steps.meta.outputs.image }}:latest
|
||||||
|
${{ steps.meta.outputs.image }}:${{ steps.meta.outputs.short_sha }}
|
||||||
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
.env
|
||||||
11
Dockerfile
Normal file
11
Dockerfile
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
FROM node:22-alpine AS build
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package.json ./
|
||||||
|
RUN npm install
|
||||||
|
COPY . .
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
FROM nginx:alpine
|
||||||
|
COPY --from=build /app/dist /usr/share/nginx/html
|
||||||
|
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
|
EXPOSE 80
|
||||||
6
docker-compose.yml
Normal file
6
docker-compose.yml
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
services:
|
||||||
|
app:
|
||||||
|
image: ${IMAGE:-2048:local}
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "${PORT:-3009}:80"
|
||||||
13
index.html
Normal file
13
index.html
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22>▦</text></svg>" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>2048</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/main.ts"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
10
nginx.conf
Normal file
10
nginx.conf
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html;
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
gzip on;
|
||||||
|
gzip_types text/css application/javascript application/json image/svg+xml;
|
||||||
|
}
|
||||||
1284
package-lock.json
generated
Normal file
1284
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
17
package.json
Normal file
17
package.json
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"name": "2048",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.1.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc && vite build",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"test": "vitest run"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"typescript": "~6.0.2",
|
||||||
|
"vite": "^8.1.1",
|
||||||
|
"vitest": "^4.1.9"
|
||||||
|
}
|
||||||
|
}
|
||||||
107
src/engine.ts
Normal file
107
src/engine.ts
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
export type Grid = number[][];
|
||||||
|
export type Dir = 'left' | 'right' | 'up' | 'down';
|
||||||
|
|
||||||
|
export interface MoveResult {
|
||||||
|
grid: Grid;
|
||||||
|
scored: number;
|
||||||
|
moved: boolean;
|
||||||
|
mergedAt: { row: number; col: number }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SpawnResult {
|
||||||
|
grid: Grid;
|
||||||
|
at: { row: number; col: number };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function emptyGrid(): Grid {
|
||||||
|
return [[0,0,0,0],[0,0,0,0],[0,0,0,0],[0,0,0,0]];
|
||||||
|
}
|
||||||
|
|
||||||
|
function transpose(g: Grid): Grid {
|
||||||
|
return g[0].map((_, c) => g.map(row => row[c]));
|
||||||
|
}
|
||||||
|
|
||||||
|
function flipH(g: Grid): Grid {
|
||||||
|
return g.map(row => [...row].reverse());
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SlideResult {
|
||||||
|
row: number[];
|
||||||
|
scored: number;
|
||||||
|
mergedCols: number[]; // destination col indices where merges landed
|
||||||
|
}
|
||||||
|
|
||||||
|
function slideLeft(row: number[]): SlideResult {
|
||||||
|
const vals = row.filter(v => v !== 0);
|
||||||
|
const out: number[] = [];
|
||||||
|
const mergedCols: number[] = [];
|
||||||
|
let scored = 0;
|
||||||
|
let i = 0;
|
||||||
|
while (i < vals.length) {
|
||||||
|
if (i + 1 < vals.length && vals[i] === vals[i + 1]) {
|
||||||
|
const v = vals[i] * 2;
|
||||||
|
mergedCols.push(out.length);
|
||||||
|
out.push(v);
|
||||||
|
scored += v;
|
||||||
|
i += 2;
|
||||||
|
} else {
|
||||||
|
out.push(vals[i++]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
while (out.length < 4) out.push(0);
|
||||||
|
return { row: out, scored, mergedCols };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function move(grid: Grid, dir: Dir): MoveResult {
|
||||||
|
let g = grid;
|
||||||
|
if (dir === 'right') g = flipH(g);
|
||||||
|
else if (dir === 'up') g = transpose(g);
|
||||||
|
else if (dir === 'down') g = flipH(transpose(g));
|
||||||
|
|
||||||
|
let scored = 0;
|
||||||
|
let moved = false;
|
||||||
|
const mergedPositions: { r: number; c: number }[] = [];
|
||||||
|
|
||||||
|
const next = g.map((row, r) => {
|
||||||
|
const { row: newRow, scored: s, mergedCols } = slideLeft(row);
|
||||||
|
scored += s;
|
||||||
|
if (newRow.some((v, i) => v !== row[i])) moved = true;
|
||||||
|
for (const c of mergedCols) mergedPositions.push({ r, c });
|
||||||
|
return newRow;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Undo normalisation
|
||||||
|
let result = next;
|
||||||
|
if (dir === 'right') result = flipH(result);
|
||||||
|
else if (dir === 'up') result = transpose(result);
|
||||||
|
else if (dir === 'down') result = transpose(flipH(result));
|
||||||
|
|
||||||
|
// Map merged positions back through the inverse transform
|
||||||
|
const mergedAt = mergedPositions.map(({ r, c }) => {
|
||||||
|
if (dir === 'right') return { row: r, col: 3 - c };
|
||||||
|
if (dir === 'up') return { row: c, col: r };
|
||||||
|
if (dir === 'down') return { row: 3 - c, col: r };
|
||||||
|
return { row: r, col: c };
|
||||||
|
});
|
||||||
|
|
||||||
|
return { grid: result, scored, moved, mergedAt };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function spawnTile(grid: Grid): SpawnResult {
|
||||||
|
const empty: { row: number; col: number }[] = [];
|
||||||
|
for (let r = 0; r < 4; r++)
|
||||||
|
for (let c = 0; c < 4; c++)
|
||||||
|
if (grid[r][c] === 0) empty.push({ row: r, col: c });
|
||||||
|
const at = empty[Math.floor(Math.random() * empty.length)];
|
||||||
|
const next = grid.map(row => [...row]);
|
||||||
|
next[at.row][at.col] = Math.random() < 0.9 ? 2 : 4;
|
||||||
|
return { grid: next, at };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function canMove(grid: Grid): boolean {
|
||||||
|
return (['left', 'right', 'up', 'down'] as Dir[]).some(d => move(grid, d).moved);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasWon(grid: Grid): boolean {
|
||||||
|
return grid.some(row => row.some(v => v >= 2048));
|
||||||
|
}
|
||||||
131
src/main.ts
Normal file
131
src/main.ts
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
import './style.css';
|
||||||
|
import { canMove, emptyGrid, hasWon, move, spawnTile, type Dir } from './engine.ts';
|
||||||
|
import { clearTiles, initUI, removeOverlay, renderGrid, showOverlay } from './ui.ts';
|
||||||
|
|
||||||
|
const BEST_KEY = '2048-best';
|
||||||
|
|
||||||
|
interface State {
|
||||||
|
grid: ReturnType<typeof emptyGrid>;
|
||||||
|
score: number;
|
||||||
|
best: number;
|
||||||
|
won: boolean;
|
||||||
|
continued: boolean;
|
||||||
|
over: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
let state: State;
|
||||||
|
|
||||||
|
function loadBest(): number {
|
||||||
|
try { return parseInt(localStorage.getItem(BEST_KEY) ?? '0', 10) || 0; }
|
||||||
|
catch { return 0; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveBest(n: number): void {
|
||||||
|
try { localStorage.setItem(BEST_KEY, String(n)); } catch { /* noop */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
function newGame(): State {
|
||||||
|
let grid = emptyGrid();
|
||||||
|
const s1 = spawnTile(grid); grid = s1.grid;
|
||||||
|
const s2 = spawnTile(grid); grid = s2.grid;
|
||||||
|
return { grid, score: 0, best: loadBest(), won: false, continued: false, over: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
function render(
|
||||||
|
options: { newAt?: { row: number; col: number }; mergedAt?: { row: number; col: number }[]; scoreDelta?: number } = {}
|
||||||
|
): void {
|
||||||
|
renderGrid(state.grid, state.score, state.best, options);
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyDir(dir: Dir): void {
|
||||||
|
if (state.over) return;
|
||||||
|
if (state.won && !state.continued) return;
|
||||||
|
|
||||||
|
const result = move(state.grid, dir);
|
||||||
|
if (!result.moved) return;
|
||||||
|
|
||||||
|
state.grid = result.grid;
|
||||||
|
state.score += result.scored;
|
||||||
|
if (state.score > state.best) {
|
||||||
|
state.best = state.score;
|
||||||
|
saveBest(state.best);
|
||||||
|
}
|
||||||
|
|
||||||
|
const spawn = spawnTile(state.grid);
|
||||||
|
state.grid = spawn.grid;
|
||||||
|
|
||||||
|
render({ newAt: spawn.at, mergedAt: result.mergedAt, scoreDelta: result.scored || undefined });
|
||||||
|
|
||||||
|
if (!state.continued && hasWon(state.grid)) {
|
||||||
|
state.won = true;
|
||||||
|
showOverlay(
|
||||||
|
document.getElementById('grid-wrap')!,
|
||||||
|
'win',
|
||||||
|
() => { state.continued = true; },
|
||||||
|
() => startGame()
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!canMove(state.grid)) {
|
||||||
|
state.over = true;
|
||||||
|
showOverlay(
|
||||||
|
document.getElementById('grid-wrap')!,
|
||||||
|
'over',
|
||||||
|
undefined,
|
||||||
|
() => startGame()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function startGame(): void {
|
||||||
|
state = newGame();
|
||||||
|
clearTiles();
|
||||||
|
removeOverlay(document.getElementById('grid-wrap')!);
|
||||||
|
render();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Event wiring ──
|
||||||
|
|
||||||
|
function onKey(e: KeyboardEvent): void {
|
||||||
|
const map: Record<string, Dir> = {
|
||||||
|
ArrowLeft: 'left', ArrowRight: 'right', ArrowUp: 'up', ArrowDown: 'down',
|
||||||
|
a: 'left', d: 'right', w: 'up', s: 'down',
|
||||||
|
};
|
||||||
|
const dir = map[e.key];
|
||||||
|
if (!dir) return;
|
||||||
|
e.preventDefault();
|
||||||
|
applyDir(dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Touch swipe
|
||||||
|
let touchStart: { x: number; y: number } | null = null;
|
||||||
|
|
||||||
|
function onTouchStart(e: TouchEvent): void {
|
||||||
|
const t = e.touches[0];
|
||||||
|
touchStart = { x: t.clientX, y: t.clientY };
|
||||||
|
}
|
||||||
|
|
||||||
|
function onTouchEnd(e: TouchEvent): void {
|
||||||
|
if (!touchStart) return;
|
||||||
|
const t = e.changedTouches[0];
|
||||||
|
const dx = t.clientX - touchStart.x;
|
||||||
|
const dy = t.clientY - touchStart.y;
|
||||||
|
touchStart = null;
|
||||||
|
const abs = Math.max(Math.abs(dx), Math.abs(dy));
|
||||||
|
if (abs < 20) return;
|
||||||
|
if (Math.abs(dx) > Math.abs(dy)) applyDir(dx > 0 ? 'right' : 'left');
|
||||||
|
else applyDir(dy > 0 ? 'down' : 'up');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Init ──
|
||||||
|
|
||||||
|
const app = document.getElementById('app')!;
|
||||||
|
initUI(app);
|
||||||
|
|
||||||
|
document.getElementById('btn-new')!.addEventListener('click', startGame);
|
||||||
|
window.addEventListener('keydown', onKey);
|
||||||
|
window.addEventListener('touchstart', onTouchStart, { passive: true });
|
||||||
|
window.addEventListener('touchend', onTouchEnd, { passive: true });
|
||||||
|
|
||||||
|
startGame();
|
||||||
285
src/style.css
Normal file
285
src/style.css
Normal file
@@ -0,0 +1,285 @@
|
|||||||
|
@import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;700&display=swap');
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--bg: #080808;
|
||||||
|
--panel: #0f0f0f;
|
||||||
|
--border: #1e1e1e;
|
||||||
|
--text: #d6d6d6;
|
||||||
|
--text-dim: #555;
|
||||||
|
--cell-bg: #111;
|
||||||
|
|
||||||
|
/* Tile colour ramp: dim warm → hot → cool terminal green */
|
||||||
|
--t2: #555;
|
||||||
|
--t4: #888;
|
||||||
|
--t8: #cc8844;
|
||||||
|
--t16: #ddaa44;
|
||||||
|
--t32: #e8c400;
|
||||||
|
--t64: #ff9900;
|
||||||
|
--t128: #ff6600;
|
||||||
|
--t256: #ff3300;
|
||||||
|
--t512: #ff0066;
|
||||||
|
--t1024: #aa44ff;
|
||||||
|
--t2048: #00e87a;
|
||||||
|
--thigh: #00e87a;
|
||||||
|
}
|
||||||
|
|
||||||
|
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
|
||||||
|
html, body {
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
font-family: 'JetBrains Mono', ui-monospace, 'SF Mono', Menlo, monospace;
|
||||||
|
font-size: 14px;
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: flex-start;
|
||||||
|
padding: 24px 16px 48px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#app {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 460px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Header ── */
|
||||||
|
.header {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
justify-content: space-between;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
padding-bottom: 10px;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand {
|
||||||
|
font-size: 22px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 2px;
|
||||||
|
color: var(--t32);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cursor {
|
||||||
|
animation: blink 1s steps(1) infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes blink { 50% { opacity: 0; } }
|
||||||
|
|
||||||
|
.scores {
|
||||||
|
display: flex;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.score-box {
|
||||||
|
text-align: right;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.score-label {
|
||||||
|
color: var(--text-dim);
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.score-value {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text);
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.score-delta {
|
||||||
|
position: absolute;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--t32);
|
||||||
|
pointer-events: none;
|
||||||
|
animation: score-pop 0.6s ease-out forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes score-pop {
|
||||||
|
0% { opacity: 1; transform: translateY(0); }
|
||||||
|
100% { opacity: 0; transform: translateY(-28px); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Controls bar ── */
|
||||||
|
.controls {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hint { letter-spacing: 0.04em; }
|
||||||
|
|
||||||
|
.btn-new {
|
||||||
|
background: none;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
color: var(--text-dim);
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 11px;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
padding: 5px 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: border-color 0.15s, color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-new:hover {
|
||||||
|
border-color: var(--text-dim);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Grid ── */
|
||||||
|
.grid-wrap {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid-bg {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, 1fr);
|
||||||
|
gap: 10px;
|
||||||
|
background: var(--panel);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cell {
|
||||||
|
aspect-ratio: 1;
|
||||||
|
background: var(--cell-bg);
|
||||||
|
border: 1px dashed #1a1a1a;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tile layer — absolutely overlays .grid-bg */
|
||||||
|
.tile-layer {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tile {
|
||||||
|
position: absolute;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-weight: 700;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
transition: transform 0.1s ease;
|
||||||
|
/* size and position set via JS */
|
||||||
|
}
|
||||||
|
|
||||||
|
.tile-value {
|
||||||
|
line-height: 1;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tile colours */
|
||||||
|
.tile[data-v="2"] { color: var(--t2); border-color: #222; font-size: 28px; }
|
||||||
|
.tile[data-v="4"] { color: var(--t4); border-color: #2a2a2a; font-size: 28px; }
|
||||||
|
.tile[data-v="8"] { color: var(--t8); border-color: #2a1a0a; font-size: 28px; }
|
||||||
|
.tile[data-v="16"] { color: var(--t16); border-color: #332200; font-size: 26px; }
|
||||||
|
.tile[data-v="32"] { color: var(--t32); border-color: #3a3000; font-size: 26px; }
|
||||||
|
.tile[data-v="64"] { color: var(--t64); border-color: #3a2000; font-size: 26px; }
|
||||||
|
.tile[data-v="128"] { color: var(--t128); border-color: #3a1500; font-size: 22px; }
|
||||||
|
.tile[data-v="256"] { color: var(--t256); border-color: #3a0a00; font-size: 22px; }
|
||||||
|
.tile[data-v="512"] { color: var(--t512); border-color: #3a001a; font-size: 22px; }
|
||||||
|
.tile[data-v="1024"] { color: var(--t1024); border-color: #2a0044; font-size: 18px; }
|
||||||
|
.tile[data-v="2048"] { color: var(--t2048); border-color: #004422; font-size: 18px; font-weight: 700; }
|
||||||
|
.tile[data-v="4096"] { color: var(--thigh); border-color: #004422; font-size: 16px; }
|
||||||
|
.tile[data-v="8192"] { color: var(--thigh); border-color: #004422; font-size: 14px; }
|
||||||
|
|
||||||
|
/* Animations */
|
||||||
|
@keyframes tile-appear {
|
||||||
|
0% { transform: var(--tile-pos) scale(0); opacity: 0; }
|
||||||
|
60% { transform: var(--tile-pos) scale(1.12); opacity: 1; }
|
||||||
|
100% { transform: var(--tile-pos) scale(1); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes tile-merge {
|
||||||
|
0% { transform: var(--tile-pos) scale(1); }
|
||||||
|
30% { transform: var(--tile-pos) scale(1.18); }
|
||||||
|
100% { transform: var(--tile-pos) scale(1); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.tile-new { animation: tile-appear 0.18s ease-out; }
|
||||||
|
.tile-merge { animation: tile-merge 0.15s ease-out; }
|
||||||
|
|
||||||
|
/* ── Overlay (game over / win) ── */
|
||||||
|
.overlay {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(8, 8, 8, 0.88);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 16px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.overlay-title {
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 2px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overlay-title--win { color: var(--t2048); }
|
||||||
|
.overlay-title--over { color: var(--t256); }
|
||||||
|
|
||||||
|
.overlay-sub {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overlay-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-overlay {
|
||||||
|
background: none;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 11px;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
padding: 7px 16px;
|
||||||
|
cursor: pointer;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
color: var(--text-dim);
|
||||||
|
transition: border-color 0.15s, color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-overlay:hover { border-color: var(--text); color: var(--text); }
|
||||||
|
|
||||||
|
.btn-overlay--primary {
|
||||||
|
border-color: var(--t2048);
|
||||||
|
color: var(--t2048);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-overlay--primary:hover {
|
||||||
|
background: var(--t2048);
|
||||||
|
color: #000;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Footer hint ── */
|
||||||
|
.footer {
|
||||||
|
margin-top: 20px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
padding-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Mobile touch ── */
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.hint { display: none; }
|
||||||
|
}
|
||||||
225
src/ui.ts
Normal file
225
src/ui.ts
Normal file
@@ -0,0 +1,225 @@
|
|||||||
|
import type { Grid } from './engine.ts';
|
||||||
|
|
||||||
|
const CELL_SIZE = 100; // px — must match CSS logic
|
||||||
|
const GAP = 10;
|
||||||
|
const PADDING = 10;
|
||||||
|
const STEP = CELL_SIZE + GAP;
|
||||||
|
|
||||||
|
interface TileState {
|
||||||
|
el: HTMLElement;
|
||||||
|
row: number;
|
||||||
|
col: number;
|
||||||
|
value: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
let tileLayer: HTMLElement;
|
||||||
|
let scoreEl: HTMLElement;
|
||||||
|
let bestEl: HTMLElement;
|
||||||
|
let scoreWrap: HTMLElement;
|
||||||
|
let tileIdCounter = 0;
|
||||||
|
const tiles = new Map<number, TileState>();
|
||||||
|
|
||||||
|
export function initUI(container: HTMLElement): void {
|
||||||
|
container.innerHTML = `
|
||||||
|
<div class="header">
|
||||||
|
<div class="brand">2048<span class="cursor">_</span></div>
|
||||||
|
<div class="scores">
|
||||||
|
<div class="score-box" style="position:relative">
|
||||||
|
<div class="score-label">score</div>
|
||||||
|
<div class="score-value" id="score">0</div>
|
||||||
|
<div id="score-wrap" style="position:absolute;top:0;left:0;width:100%;pointer-events:none"></div>
|
||||||
|
</div>
|
||||||
|
<div class="score-box">
|
||||||
|
<div class="score-label">best</div>
|
||||||
|
<div class="score-value" id="best">0</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="controls">
|
||||||
|
<span class="hint">↑ ↓ ← → or w a s d</span>
|
||||||
|
<button class="btn-new" id="btn-new">new game</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid-wrap" id="grid-wrap">
|
||||||
|
<div class="grid-bg" id="grid-bg">
|
||||||
|
${Array.from({ length: 16 }, () => '<div class="cell"></div>').join('')}
|
||||||
|
</div>
|
||||||
|
<div class="tile-layer" id="tile-layer"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="footer">
|
||||||
|
<span>merge tiles to reach <span style="color:var(--t2048)">2048</span></span>
|
||||||
|
<span>swipe on mobile</span>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
tileLayer = container.querySelector('#tile-layer')!;
|
||||||
|
scoreEl = container.querySelector('#score')!;
|
||||||
|
bestEl = container.querySelector('#best')!;
|
||||||
|
scoreWrap = container.querySelector('#score-wrap')!;
|
||||||
|
|
||||||
|
// Measure cell size from grid-bg after layout
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
const cell = container.querySelector('.cell') as HTMLElement;
|
||||||
|
if (cell) {
|
||||||
|
const size = cell.getBoundingClientRect().width;
|
||||||
|
(container.querySelector('.tile-layer') as HTMLElement).style.setProperty('--cs', `${size}px`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function tileTranslate(row: number, col: number): string {
|
||||||
|
// grid-bg has padding:10px, then each step is CELL_SIZE+GAP
|
||||||
|
// We measure dynamically from the first cell's position
|
||||||
|
return `translate(${PADDING + col * STEP}px, ${PADDING + row * STEP}px)`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createTileEl(value: number, row: number, col: number): HTMLElement {
|
||||||
|
const el = document.createElement('div');
|
||||||
|
const translate = tileTranslate(row, col);
|
||||||
|
el.className = 'tile';
|
||||||
|
el.dataset['v'] = String(value);
|
||||||
|
el.style.cssText = `
|
||||||
|
width: ${CELL_SIZE}px;
|
||||||
|
height: ${CELL_SIZE}px;
|
||||||
|
font-size: inherit;
|
||||||
|
--tile-pos: ${translate};
|
||||||
|
transform: ${translate};
|
||||||
|
`;
|
||||||
|
el.innerHTML = `<span class="tile-value">${value}</span>`;
|
||||||
|
return el;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderGrid(
|
||||||
|
grid: Grid,
|
||||||
|
score: number,
|
||||||
|
best: number,
|
||||||
|
options: {
|
||||||
|
newAt?: { row: number; col: number };
|
||||||
|
mergedAt?: { row: number; col: number }[];
|
||||||
|
scoreDelta?: number;
|
||||||
|
} = {}
|
||||||
|
): void {
|
||||||
|
scoreEl.textContent = String(score);
|
||||||
|
bestEl.textContent = String(best);
|
||||||
|
|
||||||
|
if (options.scoreDelta && options.scoreDelta > 0) {
|
||||||
|
const d = document.createElement('div');
|
||||||
|
d.className = 'score-delta';
|
||||||
|
d.textContent = `+${options.scoreDelta}`;
|
||||||
|
scoreWrap.appendChild(d);
|
||||||
|
d.addEventListener('animationend', () => d.remove());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build a set of currently occupied positions
|
||||||
|
const occupied = new Set<string>();
|
||||||
|
for (let r = 0; r < 4; r++)
|
||||||
|
for (let c = 0; c < 4; c++)
|
||||||
|
if (grid[r][c] !== 0) occupied.add(`${r},${c}`);
|
||||||
|
|
||||||
|
// Remove tiles that no longer exist
|
||||||
|
for (const [id, t] of tiles) {
|
||||||
|
const key = `${t.row},${t.col}`;
|
||||||
|
if (!occupied.has(key) || grid[t.row][t.col] !== t.value) {
|
||||||
|
// Might be a merged source or stale — remove
|
||||||
|
t.el.remove();
|
||||||
|
tiles.delete(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update or create tiles for each occupied cell
|
||||||
|
for (let r = 0; r < 4; r++) {
|
||||||
|
for (let c = 0; c < 4; c++) {
|
||||||
|
const val = grid[r][c];
|
||||||
|
if (val === 0) continue;
|
||||||
|
|
||||||
|
// Find existing tile at this position with this value
|
||||||
|
let found: TileState | undefined;
|
||||||
|
for (const t of tiles.values()) {
|
||||||
|
if (t.row === r && t.col === c && t.value === val) {
|
||||||
|
found = t;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (found) {
|
||||||
|
// Update position (for slides — position may not have changed but keep in sync)
|
||||||
|
const translate = tileTranslate(r, c);
|
||||||
|
found.el.style.setProperty('--tile-pos', translate);
|
||||||
|
found.el.style.transform = translate;
|
||||||
|
// Apply merge animation if this is a merge destination
|
||||||
|
if (options.mergedAt?.some(m => m.row === r && m.col === c)) {
|
||||||
|
found.el.classList.remove('tile-merge');
|
||||||
|
void found.el.offsetWidth; // force reflow
|
||||||
|
found.el.classList.add('tile-merge');
|
||||||
|
found.el.addEventListener('animationend', () => {
|
||||||
|
found!.el.classList.remove('tile-merge');
|
||||||
|
}, { once: true });
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// New tile
|
||||||
|
const el = createTileEl(val, r, c);
|
||||||
|
const isNew = options.newAt?.row === r && options.newAt?.col === c;
|
||||||
|
if (isNew) {
|
||||||
|
el.classList.add('tile-new');
|
||||||
|
el.addEventListener('animationend', () => el.classList.remove('tile-new'), { once: true });
|
||||||
|
}
|
||||||
|
tileLayer.appendChild(el);
|
||||||
|
const id = tileIdCounter++;
|
||||||
|
tiles.set(id, { el, row: r, col: c, value: val });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function showOverlay(
|
||||||
|
wrap: HTMLElement,
|
||||||
|
kind: 'win' | 'over',
|
||||||
|
onContinue?: () => void,
|
||||||
|
onNew?: () => void
|
||||||
|
): void {
|
||||||
|
removeOverlay(wrap);
|
||||||
|
const ov = document.createElement('div');
|
||||||
|
ov.className = 'overlay';
|
||||||
|
ov.id = 'overlay';
|
||||||
|
|
||||||
|
if (kind === 'win') {
|
||||||
|
ov.innerHTML = `
|
||||||
|
<div class="overlay-title overlay-title--win">2048 reached</div>
|
||||||
|
<div class="overlay-sub">keep going for higher tiles?</div>
|
||||||
|
<div class="overlay-actions">
|
||||||
|
<button class="btn-overlay btn-overlay--primary" id="ov-continue">keep going</button>
|
||||||
|
<button class="btn-overlay" id="ov-new">new game</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
ov.querySelector('#ov-continue')!.addEventListener('click', () => {
|
||||||
|
removeOverlay(wrap);
|
||||||
|
onContinue?.();
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
ov.innerHTML = `
|
||||||
|
<div class="overlay-title overlay-title--over">game over</div>
|
||||||
|
<div class="overlay-sub">no moves remaining</div>
|
||||||
|
<div class="overlay-actions">
|
||||||
|
<button class="btn-overlay btn-overlay--primary" id="ov-new">try again</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
ov.querySelector('#ov-new')!.addEventListener('click', () => {
|
||||||
|
removeOverlay(wrap);
|
||||||
|
onNew?.();
|
||||||
|
});
|
||||||
|
|
||||||
|
wrap.appendChild(ov);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function removeOverlay(wrap: HTMLElement): void {
|
||||||
|
wrap.querySelector('#overlay')?.remove();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearTiles(): void {
|
||||||
|
for (const t of tiles.values()) t.el.remove();
|
||||||
|
tiles.clear();
|
||||||
|
}
|
||||||
19
tsconfig.json
Normal file
19
tsconfig.json
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "es2023",
|
||||||
|
"module": "esnext",
|
||||||
|
"lib": ["ES2023", "DOM"],
|
||||||
|
"types": ["vite/client"],
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"erasableSyntaxOnly": true,
|
||||||
|
"noFallthroughCasesInSwitch": true
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
6
vite.config.ts
Normal file
6
vite.config.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
server: { port: 5678, strictPort: true },
|
||||||
|
preview: { port: 4588, strictPort: true },
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user