Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
146 changes: 146 additions & 0 deletions src/islands/games/FlappyBird.tsx
Original file line number Diff line number Diff line change
@@ -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<Lang, Record<string, string>> = {
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<HTMLCanvasElement | null>(null);
const g = useRef<GameState>(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 (
<div className="space-y-4">
<p className="text-sm text-muted-foreground">{t.intro}</p>

<div className="relative mx-auto w-full max-w-[360px]">
<canvas
ref={canvasRef}
width={W}
height={H}
onPointerDown={e => { e.preventDefault(); flap(); }}
className="w-full cursor-pointer touch-none select-none border-2 border-border"
style={{ imageRendering: 'auto' }}
/>
{phase !== 'playing' && (
<div className="pointer-events-none absolute inset-0 flex flex-col items-center justify-center gap-1 text-center text-white">
<div className="rounded bg-black/60 px-4 py-2">
<div className="text-lg font-black">{phase === 'dead' ? t.dead : t.ready}</div>
{phase === 'dead' && <div className="text-sm">{t.best}: {best} · {t.restart}</div>}
</div>
</div>
)}
</div>
</div>
);
}
138 changes: 138 additions & 0 deletions src/islands/games/Game2048.tsx
Original file line number Diff line number Diff line change
@@ -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<Lang, Record<string, string>> = {
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<number, string> = {
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<Grid>(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<string, Direction> = { 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 (
<div className="space-y-4">
<p className="text-sm text-muted-foreground">{t.intro}</p>

<div className="flex flex-wrap items-center gap-3">
<div className="border-2 border-border px-3 py-1 text-sm"><span className="text-muted-foreground">{t.score}:</span> <span className="font-black tabular-nums">{score}</span></div>
<Button onClick={newGame}>{t.newGame}</Button>
<Button variant="secondary" onClick={undo} disabled={!history.length}>{t.undo}</Button>
<Button variant={auto ? 'ghost' : 'secondary'} onClick={() => setAuto(a => !a)}>{auto ? t.stop : t.auto}</Button>
</div>

<div className="relative mx-auto w-full max-w-sm">
<div className="grid grid-cols-4 gap-2 border-2 border-border bg-muted p-2 touch-none select-none"
onTouchStart={onTouchStart} onTouchEnd={onTouchEnd}>
{grid.flat().map((v, i) => (
<div key={i}
className={`flex aspect-square items-center justify-center border-2 border-border text-xl font-black tabular-nums sm:text-2xl ${v ? TILE_COLORS[v] ?? 'bg-fuchsia-700 text-white' : 'bg-background'}`}>
{v || ''}
</div>
))}
</div>
{(over || (won && !over)) && (
<div className="absolute inset-0 flex flex-col items-center justify-center gap-2 bg-black/60 text-white">
<span className="text-2xl font-black">{over ? t.over : t.won}</span>
{over ? <Button onClick={newGame}>{t.newGame}</Button> : <Button variant="secondary" onClick={() => setWon(false)}>{t.keepGoing}</Button>}
</div>
)}
</div>

<p className="text-center text-xs text-muted-foreground">{t.hint}</p>
</div>
);
}
72 changes: 72 additions & 0 deletions src/registry/tool-seo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -385,6 +385,42 @@ const en: Record<string, ToolSeoContent> = {
{ 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.',
Expand Down Expand Up @@ -2772,6 +2808,42 @@ const id: Record<string, ToolSeoContent> = {
{ 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.',
Expand Down
Loading
Loading