From c177c9f734cf92ad7d278e7642090edfd800d4f3 Mon Sep 17 00:00:00 2001 From: Kresna <13603341+slaveofcode@users.noreply.github.com> Date: Mon, 17 Aug 2026 21:17:27 +0700 Subject: [PATCH] feat(games): add 2048 (with cheats) and Flying Bird MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 2048: full tile game with keyboard + swipe, Undo, and an Auto-play 'cheat' that simulates the best move each turn (one-ply heuristic). Pure game2048.lib.ts (slide/merge/move/bestMove) unit-tested (14 tests). Flying Bird: a flappy-bird style canvas arcade game β€” tap/click/Space to flap through pipe gaps, score + best. Pure flappy.lib.ts (physics + collision) unit-tested (7 tests). Both in the Games category. EN + ID SEO. --- src/islands/games/FlappyBird.tsx | 146 +++++++++++++++++++++++++++ src/islands/games/Game2048.tsx | 138 +++++++++++++++++++++++++ src/registry/tool-seo.ts | 72 +++++++++++++ src/registry/tools.ts | 24 ++++- src/tools/games/flappy.lib.test.ts | 40 ++++++++ src/tools/games/flappy.lib.ts | 36 +++++++ src/tools/games/game2048.lib.test.ts | 81 +++++++++++++++ src/tools/games/game2048.lib.ts | 111 ++++++++++++++++++++ 8 files changed, 647 insertions(+), 1 deletion(-) create mode 100644 src/islands/games/FlappyBird.tsx create mode 100644 src/islands/games/Game2048.tsx create mode 100644 src/tools/games/flappy.lib.test.ts create mode 100644 src/tools/games/flappy.lib.ts create mode 100644 src/tools/games/game2048.lib.test.ts create mode 100644 src/tools/games/game2048.lib.ts diff --git a/src/islands/games/FlappyBird.tsx b/src/islands/games/FlappyBird.tsx new file mode 100644 index 0000000..ea9d2b6 --- /dev/null +++ b/src/islands/games/FlappyBird.tsx @@ -0,0 +1,146 @@ +import { useEffect, useRef, useState } from 'react'; +import { stepBird, outOfBounds, hitsPipe, type Pipe } from '@/tools/games/flappy.lib'; +import type { Lang } from '@/i18n/config'; + +const TR: Record> = { + en: { + intro: 'A flappy-bird style game. Click, tap or press Space to flap and fly through the gaps. How far can you get?', + ready: 'Tap / Space to start', dead: 'Game over', best: 'Best', restart: 'Tap to restart', + }, + id: { + intro: 'Game bergaya flappy bird. Klik, ketuk, atau tekan Spasi untuk mengepak dan terbang melewati celah. Sejauh apa Anda bisa?', + ready: 'Ketuk / Spasi untuk mulai', dead: 'Permainan selesai', best: 'Terbaik', restart: 'Ketuk untuk ulang', + }, +}; + +const W = 360, H = 540; +const GROUND = H - 40; +const BIRD_X = 92, R = 14; +const GRAVITY = 1500, FLAP = -440, SPEED = 150, PIPE_W = 58, GAP = 150, SPAWN = 1.5; + +interface GameState { phase: 'ready' | 'playing' | 'dead'; y: number; v: number; pipes: Pipe[]; spawnT: number; score: number; } + +function initial(): GameState { return { phase: 'ready', y: H / 2, v: 0, pipes: [], spawnT: SPAWN, score: 0 }; } + +export default function FlappyBird({ lang = 'en' }: { lang?: Lang }) { + const t = TR[lang] ?? TR.en; + const canvasRef = useRef(null); + const g = useRef(initial()); + const [best, setBest] = useState(0); + const [phase, setPhase] = useState<'ready' | 'playing' | 'dead'>('ready'); + + const flap = () => { + const s = g.current; + if (s.phase === 'ready') { s.phase = 'playing'; setPhase('playing'); s.v = FLAP; return; } + if (s.phase === 'dead') { g.current = initial(); setPhase('ready'); return; } + s.v = FLAP; + }; + + useEffect(() => { + const canvas = canvasRef.current; + const ctx = canvas?.getContext('2d'); + if (!canvas || !ctx) return; + let raf = 0; + let last = performance.now(); + + const draw = (s: GameState) => { + // sky + ctx.fillStyle = '#7dd3fc'; + ctx.fillRect(0, 0, W, H); + // pipes + ctx.fillStyle = '#16a34a'; + ctx.strokeStyle = '#14532d'; + ctx.lineWidth = 3; + for (const p of s.pipes) { + ctx.fillRect(p.x, 0, PIPE_W, p.gapTop); + ctx.strokeRect(p.x, 0, PIPE_W, p.gapTop); + ctx.fillRect(p.x, p.gapBottom, PIPE_W, GROUND - p.gapBottom); + ctx.strokeRect(p.x, p.gapBottom, PIPE_W, GROUND - p.gapBottom); + } + // ground + ctx.fillStyle = '#ca8a04'; + ctx.fillRect(0, GROUND, W, H - GROUND); + // bird + ctx.beginPath(); + ctx.arc(BIRD_X, s.y, R, 0, Math.PI * 2); + ctx.fillStyle = '#facc15'; + ctx.fill(); + ctx.strokeStyle = '#000'; + ctx.lineWidth = 2; + ctx.stroke(); + ctx.beginPath(); + ctx.arc(BIRD_X + 5, s.y - 4, 2.5, 0, Math.PI * 2); + ctx.fillStyle = '#000'; + ctx.fill(); + // score + ctx.fillStyle = '#fff'; + ctx.strokeStyle = '#000'; + ctx.lineWidth = 4; + ctx.font = 'bold 40px sans-serif'; + ctx.textAlign = 'center'; + ctx.strokeText(String(s.score), W / 2, 70); + ctx.fillText(String(s.score), W / 2, 70); + }; + + const frame = (now: number) => { + const dt = Math.min(0.032, (now - last) / 1000); + last = now; + const s = g.current; + if (s.phase === 'playing') { + const b = stepBird(s.y, s.v, dt, GRAVITY); + s.y = b.y; s.v = b.v; + for (const p of s.pipes) p.x -= SPEED * dt; + s.spawnT += dt; + if (s.spawnT >= SPAWN) { + s.spawnT = 0; + const gapTop = 50 + Math.random() * (GROUND - GAP - 100); + s.pipes.push({ x: W, gapTop, gapBottom: gapTop + GAP, scored: false }); + } + s.pipes = s.pipes.filter(p => p.x + PIPE_W > -10); + for (const p of s.pipes) { + if (!p.scored && p.x + PIPE_W < BIRD_X) { + p.scored = true; s.score += 1; + setBest(prev => (s.score > prev ? s.score : prev)); + } + } + const dead = outOfBounds(s.y, R, GROUND) || s.pipes.some(p => hitsPipe(BIRD_X, s.y, R, p.x, PIPE_W, p.gapTop, p.gapBottom)); + if (dead) { s.phase = 'dead'; setPhase('dead'); } + } + draw(s); + raf = requestAnimationFrame(frame); + }; + raf = requestAnimationFrame(frame); + return () => cancelAnimationFrame(raf); + }, []); + + useEffect(() => { + const onKey = (e: KeyboardEvent) => { if (e.code === 'Space') { e.preventDefault(); flap(); } }; + window.addEventListener('keydown', onKey); + return () => window.removeEventListener('keydown', onKey); + }, []); + + return ( +
+

{t.intro}

+ +
+ { e.preventDefault(); flap(); }} + className="w-full cursor-pointer touch-none select-none border-2 border-border" + style={{ imageRendering: 'auto' }} + /> + {phase !== 'playing' && ( +
+
+
{phase === 'dead' ? t.dead : t.ready}
+ {phase === 'dead' &&
{t.best}: {best} Β· {t.restart}
} +
+
+ )} +
+
+ ); +} diff --git a/src/islands/games/Game2048.tsx b/src/islands/games/Game2048.tsx new file mode 100644 index 0000000..ac05389 --- /dev/null +++ b/src/islands/games/Game2048.tsx @@ -0,0 +1,138 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import { Button } from '@/components/ui/Button'; +import { move, emptyCells, emptyGrid, hasMoves, maxTile, bestMove, type Grid, type Direction } from '@/tools/games/game2048.lib'; +import type { Lang } from '@/i18n/config'; + +const TR: Record> = { + en: { + intro: 'Play 2048 β€” combine tiles to reach 2048. Use arrow keys or swipe. Includes cheats: Undo and an Auto-play that simulates the best moves for you.', + score: 'Score', newGame: 'New game', undo: 'Undo', auto: 'Auto-play (cheat)', stop: 'Stop', won: 'You made 2048! πŸŽ‰', over: 'Game over', keepGoing: 'Keep going', + hint: 'Arrow keys or swipe to move.', + }, + id: { + intro: 'Main 2048 β€” gabungkan ubin untuk mencapai 2048. Pakai tombol panah atau geser. Termasuk cheat: Urungkan dan Auto-play yang mensimulasikan langkah terbaik untuk Anda.', + score: 'Skor', newGame: 'Main baru', undo: 'Urungkan', auto: 'Auto-play (cheat)', stop: 'Berhenti', won: 'Anda mencapai 2048! πŸŽ‰', over: 'Permainan selesai', keepGoing: 'Lanjutkan', + hint: 'Tombol panah atau geser untuk bergerak.', + }, +}; + +const TILE_COLORS: Record = { + 2: 'bg-stone-200 text-stone-800', 4: 'bg-stone-300 text-stone-800', + 8: 'bg-orange-300 text-white', 16: 'bg-orange-400 text-white', + 32: 'bg-orange-500 text-white', 64: 'bg-red-500 text-white', + 128: 'bg-yellow-400 text-white', 256: 'bg-yellow-500 text-white', + 512: 'bg-lime-500 text-white', 1024: 'bg-emerald-500 text-white', + 2048: 'bg-fuchsia-600 text-white', +}; + +function spawn(grid: Grid): Grid { + const cells = emptyCells(grid); + if (!cells.length) return grid; + const [r, c] = cells[Math.floor(Math.random() * cells.length)]; + const ng = grid.map(row => [...row]); + ng[r][c] = Math.random() < 0.9 ? 2 : 4; + return ng; +} + +function fresh(): Grid { return spawn(spawn(emptyGrid())); } + +export default function Game2048({ lang = 'en' }: { lang?: Lang }) { + const t = TR[lang] ?? TR.en; + const [grid, setGrid] = useState(fresh); + const [score, setScore] = useState(0); + const [won, setWon] = useState(false); + const [over, setOver] = useState(false); + const [auto, setAuto] = useState(false); + const [history, setHistory] = useState<{ grid: Grid; score: number }[]>([]); + + const latest = useRef({ grid, score, over }); + latest.current = { grid, score, over }; + + const doMove = useCallback((dir: Direction | null) => { + const cur = latest.current; + if (cur.over || !dir) return; + const r = move(cur.grid, dir); + if (!r.moved) return; + const ng = spawn(r.grid); + setHistory(h => [...h.slice(-30), { grid: cur.grid, score: cur.score }]); + setGrid(ng); + setScore(cur.score + r.gained); + if (maxTile(ng) >= 2048) setWon(true); + if (!hasMoves(ng)) setOver(true); + }, []); + + useEffect(() => { + const map: Record = { ArrowLeft: 'left', ArrowRight: 'right', ArrowUp: 'up', ArrowDown: 'down' }; + const onKey = (e: KeyboardEvent) => { + if (map[e.key]) { e.preventDefault(); doMove(map[e.key]); } + }; + window.addEventListener('keydown', onKey); + return () => window.removeEventListener('keydown', onKey); + }, [doMove]); + + useEffect(() => { + if (!auto) return; + const id = window.setInterval(() => { + if (latest.current.over) { setAuto(false); return; } + const dir = bestMove(latest.current.grid); + if (dir) doMove(dir); else setAuto(false); + }, 110); + return () => window.clearInterval(id); + }, [auto, doMove]); + + const newGame = () => { setGrid(fresh()); setScore(0); setWon(false); setOver(false); setAuto(false); setHistory([]); }; + const undo = () => { + setHistory(h => { + if (!h.length) return h; + const last = h[h.length - 1]; + setGrid(last.grid); setScore(last.score); setOver(false); setAuto(false); + return h.slice(0, -1); + }); + }; + + // Swipe handling. + const touch = useRef<{ x: number; y: number } | null>(null); + const onTouchStart = (e: React.TouchEvent) => { touch.current = { x: e.touches[0].clientX, y: e.touches[0].clientY }; }; + const onTouchEnd = (e: React.TouchEvent) => { + if (!touch.current) return; + const dx = e.changedTouches[0].clientX - touch.current.x; + const dy = e.changedTouches[0].clientY - touch.current.y; + touch.current = null; + if (Math.max(Math.abs(dx), Math.abs(dy)) < 24) return; + if (Math.abs(dx) > Math.abs(dy)) doMove(dx > 0 ? 'right' : 'left'); + else doMove(dy > 0 ? 'down' : 'up'); + }; + + return ( +
+

{t.intro}

+ +
+
{t.score}: {score}
+ + + +
+ +
+
+ {grid.flat().map((v, i) => ( +
+ {v || ''} +
+ ))} +
+ {(over || (won && !over)) && ( +
+ {over ? t.over : t.won} + {over ? : } +
+ )} +
+ +

{t.hint}

+
+ ); +} diff --git a/src/registry/tool-seo.ts b/src/registry/tool-seo.ts index 8a52668..cff0743 100644 --- a/src/registry/tool-seo.ts +++ b/src/registry/tool-seo.ts @@ -385,6 +385,42 @@ const en: Record = { { q: 'Does it work offline?', a: 'Yes. GoodWebTools is a PWA, so once loaded the calculator works with no internet connection.' }, ], }, + '2048': { + title: 'Play 2048 Online β€” Free, with Undo & Auto-Play Cheat', + description: 'Play the 2048 tile puzzle in your browser: combine tiles to reach 2048. Includes cheats β€” Undo and an Auto-play that simulates the best moves. Free, no ads, works offline.', + intro: 'Play the classic 2048 puzzle: use the arrow keys or swipe to slide the tiles, and matching tiles merge into their sum β€” reach the 2048 tile to win, then keep going for a high score. This version adds two cheats: Undo to take back a move, and an Auto-play that simulates the best move each turn and plays for you. Everything runs in your browser.', + howTo: [ + 'Use the arrow keys, or swipe on touch, to slide all tiles one way.', + 'Tiles with the same number merge when they touch.', + 'Reach the 2048 tile to win β€” then keep going for a higher score.', + 'Use Undo to take back a move, or Auto-play to let the cheat AI play.', + ], + faqs: [ + { q: 'How do I win 2048?', a: 'Keep merging tiles until you create a tile worth 2048. Keeping your biggest tile in a corner and building in one direction is the classic strategy.' }, + { q: 'What does the Auto-play cheat do?', a: 'It simulates each possible move, scores the resulting board, and plays the best one every turn β€” a hands-free way to watch the game solve itself.' }, + { q: 'Is there an undo?', a: 'Yes. Undo steps back through your recent moves, so you can recover from a mistake.' }, + { q: 'Is it free and ad-free?', a: 'Yes β€” completely free with no ads, no account and no limits.' }, + { q: 'Does it work offline?', a: 'Yes. GoodWebTools is a PWA, so once loaded the game works with no internet connection.' }, + ], + }, + 'flappy-bird': { + title: 'Flying Bird β€” Free Flappy-Bird Style Game Online', + description: 'Play a free flappy-bird style arcade game in your browser: tap, click or press Space to flap and fly through the gaps between pipes. No ads, works offline.', + intro: 'A simple, addictive flappy-bird style game: your bird falls under gravity, and each tap, click or Space press makes it flap upward. Steer through the gaps between the pipes β€” one point per pipe β€” and see how far you can get before you crash. It runs entirely in your browser with no ads and no account.', + howTo: [ + 'Click, tap or press Space to make the bird flap upward.', + 'Fly through the gap in each pair of pipes.', + 'Score one point per pipe you pass.', + 'Crash and it’s game over β€” tap to restart and beat your best.', + ], + faqs: [ + { q: 'How do I play?', a: 'Tap the screen, click, or press Space to flap. Time your taps to keep the bird flying through the gaps between pipes.' }, + { q: 'How is the score counted?', a: 'You earn one point for every pair of pipes you fly through. Your best score of the session is shown when you crash.' }, + { q: 'Does it work on mobile?', a: 'Yes. Tap anywhere on the game to flap β€” it works on phones, tablets and desktops.' }, + { q: 'Is it free and ad-free?', a: 'Yes β€” completely free with no ads and no account.' }, + { q: 'Does it work offline?', a: 'Yes. GoodWebTools is a PWA, so once loaded the game works with no internet connection.' }, + ], + }, 'wheel-spinner': { title: 'Wheel Spinner β€” Random Name Picker & Decision Wheel', description: 'Spin a wheel of names to pick a winner at random β€” for giveaways, classrooms, or deciding who goes first. Add your entries and spin. Free, in your browser.', @@ -2772,6 +2808,42 @@ const id: Record = { { q: 'Apakah bekerja offline?', a: 'Ya. GoodWebTools adalah PWA, jadi setelah dimuat kalkulator bekerja tanpa koneksi internet.' }, ], }, + '2048': { + title: 'Main 2048 Online β€” Gratis, dengan Urungkan & Cheat Auto-Play', + description: 'Main puzzle ubin 2048 di browser: gabungkan ubin untuk mencapai 2048. Termasuk cheat β€” Urungkan dan Auto-play yang mensimulasikan langkah terbaik. Gratis, tanpa iklan, bisa offline.', + intro: 'Main puzzle klasik 2048: pakai tombol panah atau geser untuk menggeser ubin, dan ubin yang sama akan bergabung menjadi jumlahnya β€” capai ubin 2048 untuk menang, lalu lanjutkan untuk skor tinggi. Versi ini menambahkan dua cheat: Urungkan untuk membatalkan langkah, dan Auto-play yang mensimulasikan langkah terbaik tiap giliran dan bermain untuk Anda. Semuanya berjalan di browser Anda.', + howTo: [ + 'Pakai tombol panah, atau geser di layar sentuh, untuk menggeser semua ubin ke satu arah.', + 'Ubin dengan angka sama bergabung saat bersentuhan.', + 'Capai ubin 2048 untuk menang β€” lalu lanjutkan untuk skor lebih tinggi.', + 'Pakai Urungkan untuk membatalkan langkah, atau Auto-play agar AI cheat yang bermain.', + ], + faqs: [ + { q: 'Bagaimana cara menang 2048?', a: 'Terus gabungkan ubin sampai membuat ubin bernilai 2048. Menjaga ubin terbesar di sudut dan membangun ke satu arah adalah strategi klasiknya.' }, + { q: 'Apa fungsi cheat Auto-play?', a: 'Ia mensimulasikan setiap langkah yang mungkin, menilai papan hasilnya, dan memainkan yang terbaik tiap giliran β€” cara bebas-tangan untuk melihat game menyelesaikan dirinya.' }, + { q: 'Apakah ada urungkan?', a: 'Ya. Urungkan mundur melalui langkah-langkah terakhir Anda, jadi Anda bisa memperbaiki kesalahan.' }, + { q: 'Apakah gratis dan tanpa iklan?', a: 'Ya β€” sepenuhnya gratis tanpa iklan, tanpa akun, dan tanpa batas.' }, + { q: 'Apakah bekerja offline?', a: 'Ya. GoodWebTools adalah PWA, jadi setelah dimuat game bekerja tanpa koneksi internet.' }, + ], + }, + 'flappy-bird': { + title: 'Burung Terbang β€” Game Gaya Flappy Bird Gratis Online', + description: 'Main game arcade gaya flappy bird gratis di browser: ketuk, klik, atau tekan Spasi untuk mengepak dan terbang melewati celah antar pipa. Tanpa iklan, bisa offline.', + intro: 'Game gaya flappy bird yang sederhana dan bikin ketagihan: burung Anda jatuh karena gravitasi, dan tiap ketukan, klik, atau tekan Spasi membuatnya mengepak ke atas. Kemudikan melewati celah antar pipa β€” satu poin per pipa β€” dan lihat sejauh apa Anda bisa sebelum menabrak. Berjalan sepenuhnya di browser Anda tanpa iklan dan tanpa akun.', + howTo: [ + 'Klik, ketuk, atau tekan Spasi untuk membuat burung mengepak ke atas.', + 'Terbang melewati celah di tiap pasang pipa.', + 'Dapatkan satu poin per pipa yang Anda lewati.', + 'Menabrak berarti selesai β€” ketuk untuk mulai lagi dan pecahkan rekor Anda.', + ], + faqs: [ + { q: 'Bagaimana cara mainnya?', a: 'Ketuk layar, klik, atau tekan Spasi untuk mengepak. Atur waktu ketukan agar burung tetap terbang melewati celah antar pipa.' }, + { q: 'Bagaimana skor dihitung?', a: 'Anda mendapat satu poin untuk tiap pasang pipa yang dilewati. Skor terbaik sesi ditampilkan saat Anda menabrak.' }, + { q: 'Apakah bisa di ponsel?', a: 'Ya. Ketuk di mana saja pada game untuk mengepak β€” bekerja di ponsel, tablet, dan desktop.' }, + { q: 'Apakah gratis dan tanpa iklan?', a: 'Ya β€” sepenuhnya gratis tanpa iklan dan tanpa akun.' }, + { q: 'Apakah bekerja offline?', a: 'Ya. GoodWebTools adalah PWA, jadi setelah dimuat game bekerja tanpa koneksi internet.' }, + ], + }, 'wheel-spinner': { title: 'Roda Putar β€” Pemilih Nama Acak & Roda Keputusan', description: 'Putar roda berisi nama untuk memilih pemenang secara acak β€” untuk giveaway, kelas, atau menentukan giliran. Tambahkan entri lalu putar. Gratis, di browser Anda.', diff --git a/src/registry/tools.ts b/src/registry/tools.ts index ba62e2d..ef4a84d 100644 --- a/src/registry/tools.ts +++ b/src/registry/tools.ts @@ -1,4 +1,4 @@ -import { Hash, Braces, Binary, Link, KeyRound, Fingerprint, KeySquare, FileDiff, Table, FileText, QrCode, ScanLine, Clock, Calculator, Palette, FilePlus2, Scissors, RotateCw, FileImage, FileX, Stamp, Image, Replace, Minimize2, Maximize2, Eraser, Archive, Lock, Unlock, Crop, Droplet, PenTool, Combine, ShieldCheck, FileCode, FileCode2, FileCog, FileArchive, FolderArchive, Sparkles, ScanFace, Scaling, Aperture, Wand2, PenLine, Shapes, Film, FileVideo, Music, AudioLines, MonitorPlay, Camera, Code2, Database, Keyboard, Contrast, Eye, ScanText, Receipt, Webcam, Mic, Send, Video, Wrench, Compass, Map, Waypoints, ImageDown, ScrollText, Ghost, FileSpreadsheet, BookOpen, FileType2, FileDown, GitCompare, FileOutput, CalendarClock, ClipboardPaste, PlugZap, Regex, Contact, Wallet, Network, Subtitles, Presentation, SquareUser, WholeWord, Percent, Baseline, CaseSensitive, Brush, AppWindow, ListOrdered, FileSignature, Shrink, Cake, Ruler, Timer, Highlighter, Gauge, Speech, Accessibility, Tags, Link2Off, Home, HeartHandshake, Gift, Barcode, Disc3, Sticker, Glasses, HeartPulse, BookCopy, Users, Grip, MailOpen, Scan, Activity } from 'lucide-react'; +import { Hash, Braces, Binary, Link, KeyRound, Fingerprint, KeySquare, FileDiff, Table, FileText, QrCode, ScanLine, Clock, Calculator, Palette, FilePlus2, Scissors, RotateCw, FileImage, FileX, Stamp, Image, Replace, Minimize2, Maximize2, Eraser, Archive, Lock, Unlock, Crop, Droplet, PenTool, Combine, ShieldCheck, FileCode, FileCode2, FileCog, FileArchive, FolderArchive, Sparkles, ScanFace, Scaling, Aperture, Wand2, PenLine, Shapes, Film, FileVideo, Music, AudioLines, MonitorPlay, Camera, Code2, Database, Keyboard, Contrast, Eye, ScanText, Receipt, Webcam, Mic, Send, Video, Wrench, Compass, Map, Waypoints, ImageDown, ScrollText, Ghost, FileSpreadsheet, BookOpen, FileType2, FileDown, GitCompare, FileOutput, CalendarClock, ClipboardPaste, PlugZap, Regex, Contact, Wallet, Network, Subtitles, Presentation, SquareUser, WholeWord, Percent, Baseline, CaseSensitive, Brush, AppWindow, ListOrdered, FileSignature, Shrink, Cake, Ruler, Timer, Highlighter, Gauge, Speech, Accessibility, Tags, Link2Off, Home, HeartHandshake, Gift, Barcode, Disc3, Sticker, Glasses, HeartPulse, BookCopy, Users, Grip, MailOpen, Scan, Activity, Grid3x3, Bird } from 'lucide-react'; import type { ToolDef } from '@/types/tool'; export const tools: ToolDef[] = [ @@ -542,6 +542,28 @@ export const tools: ToolDef[] = [ load: () => import('@/islands/games/WheelSpinner'), status: 'beta' }, + { + id: '2048', + name: '2048 Game', + category: 'Games', + route: '/tools/2048', + keywords: ['2048', 'game', 'puzzle', 'tiles', 'merge', 'number game', 'cheat', 'auto solve', 'main 2048'], + icon: Grid3x3, + summary: 'Play 2048 with undo and an auto-play cheat', + load: () => import('@/islands/games/Game2048'), + status: 'beta' + }, + { + id: 'flappy-bird', + name: 'Flying Bird Game', + category: 'Games', + route: '/tools/flappy-bird', + keywords: ['flappy bird', 'flying bird', 'game', 'arcade', 'tap game', 'burung terbang', 'game burung'], + icon: Bird, + summary: 'A flappy-bird style arcade game β€” tap to fly through the gaps', + load: () => import('@/islands/games/FlappyBird'), + status: 'beta' + }, { id: 'pdf-organize', name: 'Organize PDF', diff --git a/src/tools/games/flappy.lib.test.ts b/src/tools/games/flappy.lib.test.ts new file mode 100644 index 0000000..857cfa4 --- /dev/null +++ b/src/tools/games/flappy.lib.test.ts @@ -0,0 +1,40 @@ +import { describe, it, expect } from 'vitest'; +import { stepBird, outOfBounds, hitsPipe } from './flappy.lib'; + +describe('stepBird', () => { + it('applies gravity to velocity then position', () => { + const r = stepBird(0, 0, 0.1, 1000); + expect(r.v).toBeCloseTo(100, 6); // 0 + 1000*0.1 + expect(r.y).toBeCloseTo(10, 6); // 0 + 100*0.1 + }); + it('a flap (negative velocity) moves the bird up', () => { + const r = stepBird(200, -500, 0.1, 1000); + expect(r.v).toBeCloseTo(-400, 6); + expect(r.y).toBeLessThan(200); + }); +}); + +describe('outOfBounds', () => { + it('detects hitting the ceiling and floor', () => { + expect(outOfBounds(5, 10, 600)).toBe(true); // top + expect(outOfBounds(595, 10, 600)).toBe(true); // bottom + expect(outOfBounds(300, 10, 600)).toBe(false); + }); +}); + +describe('hitsPipe', () => { + // pipe at x=100 width=60, gap between y=200 and y=380 + const call = (bx: number, by: number) => hitsPipe(bx, by, 12, 100, 60, 200, 380); + it('passes through the gap', () => { + expect(call(120, 290)).toBe(false); + }); + it('hits the top pipe', () => { + expect(call(120, 150)).toBe(true); + }); + it('hits the bottom pipe', () => { + expect(call(120, 420)).toBe(true); + }); + it('misses when not overlapping the pipe horizontally', () => { + expect(call(300, 150)).toBe(false); + }); +}); diff --git a/src/tools/games/flappy.lib.ts b/src/tools/games/flappy.lib.ts new file mode 100644 index 0000000..eafbc67 --- /dev/null +++ b/src/tools/games/flappy.lib.ts @@ -0,0 +1,36 @@ +/** + * Pure physics and collision for the flying-bird (flappy) game. The render loop + * and input live in the island; the maths is here and unit-tested. + */ + +export interface Pipe { + x: number; + gapTop: number; + gapBottom: number; + scored: boolean; +} + +/** Advance the bird by dt seconds under gravity (px, px/s, s, px/sΒ²). */ +export function stepBird(y: number, v: number, dt: number, gravity: number): { y: number; v: number } { + const nv = v + gravity * dt; + return { y: y + nv * dt, v: nv }; +} + +/** True when the bird (centre y, radius) hits the ceiling or floor of a world of height h. */ +export function outOfBounds(y: number, radius: number, worldHeight: number): boolean { + return y - radius <= 0 || y + radius >= worldHeight; +} + +/** + * True when the bird circle (birdX, birdY, radius) collides with a pipe at + * `pipeX` of width `pipeWidth` whose gap runs from `gapTop` to `gapBottom`. + */ +export function hitsPipe( + birdX: number, birdY: number, radius: number, + pipeX: number, pipeWidth: number, gapTop: number, gapBottom: number, +): boolean { + const overlapX = birdX + radius > pipeX && birdX - radius < pipeX + pipeWidth; + if (!overlapX) return false; + const insideGap = birdY - radius > gapTop && birdY + radius < gapBottom; + return !insideGap; +} diff --git a/src/tools/games/game2048.lib.test.ts b/src/tools/games/game2048.lib.test.ts new file mode 100644 index 0000000..95fcb5d --- /dev/null +++ b/src/tools/games/game2048.lib.test.ts @@ -0,0 +1,81 @@ +import { describe, it, expect } from 'vitest'; +import { slideLine, move, emptyCells, hasMoves, maxTile, bestMove, type Grid } from './game2048.lib'; + +describe('slideLine', () => { + it('slides tiles toward index 0', () => { + expect(slideLine([0, 2, 0, 2]).line).toEqual([4, 0, 0, 0]); + }); + it('merges one pair and reports the gain', () => { + const r = slideLine([2, 2, 0, 0]); + expect(r.line).toEqual([4, 0, 0, 0]); + expect(r.gained).toBe(4); + }); + it('merges only the first pair of three equal tiles', () => { + expect(slideLine([2, 2, 2, 0]).line).toEqual([4, 2, 0, 0]); + }); + it('merges two pairs independently', () => { + const r = slideLine([4, 4, 4, 4]); + expect(r.line).toEqual([8, 8, 0, 0]); + expect(r.gained).toBe(16); + }); + it('reports no move when nothing changes', () => { + expect(slideLine([2, 4, 8, 16]).moved).toBe(false); + }); +}); + +describe('move', () => { + const g: Grid = [ + [2, 2, 0, 0], + [0, 0, 0, 0], + [4, 0, 4, 0], + [0, 0, 0, 8], + ]; + it('moves left', () => { + expect(move(g, 'left').grid[0]).toEqual([4, 0, 0, 0]); + expect(move(g, 'left').grid[2]).toEqual([8, 0, 0, 0]); + }); + it('moves right', () => { + expect(move(g, 'right').grid[0]).toEqual([0, 0, 0, 4]); + expect(move(g, 'right').grid[3]).toEqual([0, 0, 0, 8]); + }); + it('moves down (stacks a column)', () => { + const col: Grid = [[2, 0, 0, 0], [2, 0, 0, 0], [0, 0, 0, 0], [4, 0, 0, 0]]; + expect(move(col, 'down').grid.map(r => r[0])).toEqual([0, 0, 4, 4]); + }); + it('flags moved=false when the direction changes nothing', () => { + const packed: Grid = [[2, 4, 8, 16], [0, 0, 0, 0], [0, 0, 0, 0], [0, 0, 0, 0]]; + expect(move(packed, 'left').moved).toBe(false); + }); +}); + +describe('helpers', () => { + it('emptyCells lists blank positions', () => { + expect(emptyCells([[2, 0, 0, 0], [0, 0, 0, 0], [0, 0, 0, 0], [0, 0, 0, 0]]).length).toBe(15); + }); + it('maxTile finds the largest tile', () => { + expect(maxTile([[2, 4, 8, 16], [0, 0, 0, 0], [0, 0, 0, 0], [0, 0, 0, 0]])).toBe(16); + }); + it('hasMoves is false on a full, unmergeable board', () => { + const stuck: Grid = [ + [2, 4, 2, 4], + [4, 2, 4, 2], + [2, 4, 2, 4], + [4, 2, 4, 2], + ]; + expect(hasMoves(stuck)).toBe(false); + expect(hasMoves([[2, 2, 4, 8], [0, 0, 0, 0], [0, 0, 0, 0], [0, 0, 0, 0]])).toBe(true); + }); +}); + +describe('bestMove (cheat AI)', () => { + it('returns a direction that actually changes the board', () => { + const g: Grid = [[2, 2, 0, 0], [0, 0, 0, 0], [0, 0, 0, 0], [0, 0, 0, 0]]; + const dir = bestMove(g)!; + expect(dir).not.toBeNull(); + expect(move(g, dir).moved).toBe(true); + }); + it('returns null when no move is possible', () => { + const stuck: Grid = [[2, 4, 2, 4], [4, 2, 4, 2], [2, 4, 2, 4], [4, 2, 4, 2]]; + expect(bestMove(stuck)).toBeNull(); + }); +}); diff --git a/src/tools/games/game2048.lib.ts b/src/tools/games/game2048.lib.ts new file mode 100644 index 0000000..53da189 --- /dev/null +++ b/src/tools/games/game2048.lib.ts @@ -0,0 +1,111 @@ +/** + * Pure 2048 game logic: sliding/merging, move application in any direction, + * board helpers and a simple look-ahead "cheat" AI. The board is a 4Γ—4 grid of + * numbers (0 = empty). Randomness (spawning tiles) lives in the island. + */ + +export type Grid = number[][]; +export type Direction = 'left' | 'right' | 'up' | 'down'; +export const SIZE = 4; + +export interface SlideResult { line: number[]; gained: number; moved: boolean; } + +/** Slide and merge a single line toward index 0 (i.e. "left"). */ +export function slideLine(line: number[]): SlideResult { + const nonzero = line.filter(v => v !== 0); + const out: number[] = []; + let gained = 0; + for (let i = 0; i < nonzero.length; i++) { + if (i + 1 < nonzero.length && nonzero[i] === nonzero[i + 1]) { + const merged = nonzero[i] * 2; + out.push(merged); + gained += merged; + i++; // consume the pair + } else { + out.push(nonzero[i]); + } + } + while (out.length < line.length) out.push(0); + const moved = out.some((v, i) => v !== line[i]); + return { line: out, gained, moved }; +} + +const clone = (g: Grid): Grid => g.map(r => [...r]); +const transpose = (g: Grid): Grid => g[0].map((_, c) => g.map(r => r[c])); +const reverseRows = (g: Grid): Grid => g.map(r => [...r].reverse()); + +export interface MoveResult { grid: Grid; gained: number; moved: boolean; } + +/** Apply a move in the given direction. Returns a new grid (input unchanged). */ +export function move(grid: Grid, dir: Direction): MoveResult { + let work = clone(grid); + if (dir === 'right') work = reverseRows(work); + else if (dir === 'up') work = transpose(work); + else if (dir === 'down') work = reverseRows(transpose(work)); + + let gained = 0; + let moved = false; + work = work.map(row => { + const r = slideLine(row); + gained += r.gained; + if (r.moved) moved = true; + return r.line; + }); + + if (dir === 'right') work = reverseRows(work); + else if (dir === 'up') work = transpose(work); + else if (dir === 'down') work = transpose(reverseRows(work)); + + return { grid: work, gained, moved }; +} + +export function emptyCells(grid: Grid): [number, number][] { + const cells: [number, number][] = []; + for (let r = 0; r < grid.length; r++) + for (let c = 0; c < grid[r].length; c++) + if (grid[r][c] === 0) cells.push([r, c]); + return cells; +} + +export function maxTile(grid: Grid): number { + let m = 0; + for (const row of grid) for (const v of row) if (v > m) m = v; + return m; +} + +const DIRECTIONS: Direction[] = ['down', 'left', 'right', 'up']; + +export function hasMoves(grid: Grid): boolean { + return DIRECTIONS.some(d => move(grid, d).moved); +} + +/** Empty grid of the standard size. */ +export function emptyGrid(): Grid { + return Array.from({ length: SIZE }, () => Array(SIZE).fill(0)); +} + +/** Heuristic score for a board: prefer empty cells, big merges and a corner max. */ +function score(grid: Grid, gained: number): number { + const empties = emptyCells(grid).length; + const max = maxTile(grid); + // Bonus if the max tile sits in a corner (keeps the board organised). + const corners = [grid[0][0], grid[0][SIZE - 1], grid[SIZE - 1][0], grid[SIZE - 1][SIZE - 1]]; + const cornerBonus = corners.includes(max) ? max : 0; + return empties * 128 + gained + cornerBonus; +} + +/** + * Cheat / auto-play AI: pick the legal move that leaves the best board by a + * shallow one-ply heuristic. Returns null when no move is possible. + */ +export function bestMove(grid: Grid): Direction | null { + let best: Direction | null = null; + let bestScore = -Infinity; + for (const dir of DIRECTIONS) { + const r = move(grid, dir); + if (!r.moved) continue; + const s = score(r.grid, r.gained); + if (s > bestScore) { bestScore = s; best = dir; } + } + return best; +}