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:
Fredrik Johansson
2026-07-07 19:57:48 +02:00
commit 5629e05a72
15 changed files with 2176 additions and 0 deletions

2
.env.example Normal file
View File

@@ -0,0 +1,2 @@
IMAGE=gitea.example.com/explewd/2048:latest
PORT=3009

View 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
View File

@@ -0,0 +1,3 @@
node_modules/
dist/
.env

11
Dockerfile Normal file
View 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
View File

@@ -0,0 +1,6 @@
services:
app:
image: ${IMAGE:-2048:local}
restart: unless-stopped
ports:
- "${PORT:-3009}:80"

13
index.html Normal file
View 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
View 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

File diff suppressed because it is too large Load Diff

17
package.json Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View File

@@ -0,0 +1,6 @@
import { defineConfig } from 'vite'
export default defineConfig({
server: { port: 5678, strictPort: true },
preview: { port: 4588, strictPort: true },
})