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
85 changes: 65 additions & 20 deletions src/islands/games/FlappyBird.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,9 @@ const TR: Record<Lang, Record<string, string>> = {
},
};

const W = 360, H = 540;
// Logical height is fixed; logical width adapts to the container's aspect
// ratio when expanded, so the playfield genuinely fills a phone screen.
const H = 540, BASE_W = 360;
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;
Expand All @@ -31,7 +33,9 @@ export default function FlappyBird({ lang = 'en' }: { lang?: Lang }) {
const t = TR[lang] ?? TR.en;
const { ref: stageRef, expanded, enter, exit } = useExpand<HTMLDivElement>();
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const frameRef = useRef<HTMLDivElement | null>(null);
const g = useRef<GameState>(initial());
const dims = useRef({ W: BASE_W });
const [best, setBest] = useState(0);
const [phase, setPhase] = useState<'ready' | 'playing' | 'dead'>('ready');

Expand All @@ -42,6 +46,28 @@ export default function FlappyBird({ lang = 'en' }: { lang?: Lang }) {
s.v = FLAP;
};

// Size the logical playfield to the container: expanded → match the screen's
// aspect ratio (edge-to-edge play); collapsed → the classic 360×540.
useEffect(() => {
const canvas = canvasRef.current;
const frame = frameRef.current;
if (!canvas || !frame) return;
const apply = () => {
const rect = frame.getBoundingClientRect();
const W = expanded && rect.height > 0
? Math.max(240, Math.round((H * rect.width) / rect.height))
: BASE_W;
if (W !== dims.current.W) {
dims.current.W = W;
canvas.width = W;
}
};
apply();
const ro = new ResizeObserver(apply);
ro.observe(frame);
return () => ro.disconnect();
}, [expanded]);

useEffect(() => {
const canvas = canvasRef.current;
const ctx = canvas?.getContext('2d');
Expand All @@ -50,6 +76,7 @@ export default function FlappyBird({ lang = 'en' }: { lang?: Lang }) {
let last = performance.now();

const draw = (s: GameState) => {
const W = dims.current.W;
// sky
ctx.fillStyle = '#7dd3fc';
ctx.fillRect(0, 0, W, H);
Expand Down Expand Up @@ -92,6 +119,7 @@ export default function FlappyBird({ lang = 'en' }: { lang?: Lang }) {
const dt = Math.min(0.032, (now - last) / 1000);
last = now;
const s = g.current;
const W = dims.current.W;
if (s.phase === 'playing') {
const b = stepBird(s.y, s.v, dt, GRAVITY);
s.y = b.y; s.v = b.v;
Expand Down Expand Up @@ -125,37 +153,43 @@ export default function FlappyBird({ lang = 'en' }: { lang?: Lang }) {
return () => window.removeEventListener('keydown', onKey);
}, []);

const expandBtn = (
<Button
variant="secondary"
onClick={e => { e.currentTarget.blur(); if (expanded) exit(); else enter(); }}
>
{expanded ? <Minimize2 className="h-4 w-4" /> : <Maximize2 className="h-4 w-4" />}
{expanded ? t.exit : t.expand}
</Button>
);

return (
<div className="space-y-4">
<p className="text-sm text-muted-foreground">{t.intro}</p>

{/* Stage: normal flow, or a fullscreen overlay (native fullscreen on
Android, CSS overlay fallback on iOS) for comfortable mobile play. */}
{/* Stage: normal flow, or an edge-to-edge fullscreen overlay (native
fullscreen on Android, CSS overlay fallback on iOS). */}
<div
ref={stageRef}
className={expanded
? 'fixed inset-0 z-[60] flex flex-col items-center justify-center gap-3 overflow-hidden bg-background p-3'
: 'space-y-3'}
className={expanded ? 'fixed inset-0 z-[60] overflow-hidden bg-black' : 'space-y-3'}
>
<div className="flex flex-wrap items-center justify-center gap-3">
<div className="border-2 border-border px-3 py-1 text-sm"><span className="text-muted-foreground">{t.best}:</span> <span className="font-black tabular-nums">{best}</span></div>
<Button
variant="secondary"
onClick={e => { e.currentTarget.blur(); if (expanded) exit(); else enter(); }}
>
{expanded ? <Minimize2 className="h-4 w-4" /> : <Maximize2 className="h-4 w-4" />}
{expanded ? t.exit : t.expand}
</Button>
</div>
{!expanded && (
<div className="flex flex-wrap items-center justify-center gap-3">
<div className="border-2 border-border px-3 py-1 text-sm"><span className="text-muted-foreground">{t.best}:</span> <span className="font-black tabular-nums">{best}</span></div>
{expandBtn}
</div>
)}

{/* Expanded: height-driven sizing; 2:3 aspect keeps width ≤ ~92vw. */}
<div className={expanded ? 'relative aspect-[2/3] h-[min(85vh,138vw)]' : 'relative mx-auto w-full max-w-[360px]'}>
<div
ref={frameRef}
className={expanded ? 'absolute inset-0' : 'relative mx-auto w-full max-w-[360px]'}
>
<canvas
ref={canvasRef}
width={W}
width={BASE_W}
height={H}
onPointerDown={e => { e.preventDefault(); flap(); }}
className={`cursor-pointer touch-none select-none border-2 border-border ${expanded ? 'h-full w-full' : 'w-full'}`}
className={`cursor-pointer touch-none select-none ${expanded ? 'h-full w-full' : 'w-full border-2 border-border'}`}
style={{ imageRendering: 'auto' }}
/>
{phase !== 'playing' && (
Expand All @@ -167,6 +201,17 @@ export default function FlappyBird({ lang = 'en' }: { lang?: Lang }) {
</div>
)}
</div>

{/* Floating controls while fullscreen — kept clear of the notch. */}
{expanded && (
<div
className="absolute left-0 right-0 top-0 flex items-center justify-between gap-3 p-3"
style={{ paddingTop: 'max(0.75rem, env(safe-area-inset-top))' }}
>
<div className="pointer-events-none border-2 border-border bg-background/90 px-3 py-1 text-sm"><span className="text-muted-foreground">{t.best}:</span> <span className="font-black tabular-nums">{best}</span></div>
{expandBtn}
</div>
)}
</div>
</div>
);
Expand Down
130 changes: 94 additions & 36 deletions src/islands/games/Game2048.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { useCallback, useEffect, useRef, useState } from 'react';
import { Maximize2, Minimize2 } from 'lucide-react';
import { Button } from '@/components/ui/Button';
import { useExpand } from '@/hooks/useExpand';
import { move, emptyCells, emptyGrid, hasMoves, maxTile, bestMove, type Grid, type Direction } from '@/tools/games/game2048.lib';
import { moveTiles, tilesToGrid, emptyCells, hasMoves, maxTile, bestMove, type Tile, type Ghost, type Direction } from '@/tools/games/game2048.lib';
import type { Lang } from '@/i18n/config';

const TR: Record<Lang, Record<string, string>> = {
Expand All @@ -27,41 +27,63 @@ const TILE_COLORS: Record<number, string> = {
2048: 'bg-fuchsia-600 text-white',
};

function spawn(grid: Grid): Grid {
const cells = emptyCells(grid);
if (!cells.length) return grid;
// Slide 130ms ease-in-out (transform-only); pop/spawn keyframes fire after the
// slide lands. All motion is disabled under prefers-reduced-motion.
const KEYFRAMES = `
@keyframes gwt2048-pop { 0% { scale: 1; } 55% { scale: 1.18; } 100% { scale: 1; } }
@keyframes gwt2048-spawn { 0% { scale: 0.4; opacity: 0; } 100% { scale: 1; opacity: 1; } }
.gwt2048-pop { animation: gwt2048-pop 170ms ease-out 120ms both; }
.gwt2048-spawn { animation: gwt2048-spawn 160ms ease-out 110ms both; }
@media (prefers-reduced-motion: reduce) {
.gwt2048-pop, .gwt2048-spawn { animation: none; }
}
`;

let idCounter = 1;
const uid = () => idCounter++;

function spawn(tiles: Tile[]): Tile[] {
const cells = emptyCells(tilesToGrid(tiles));
if (!cells.length) return tiles;
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;
return [...tiles, { id: uid(), value: Math.random() < 0.9 ? 2 : 4, r, c, isNew: true }];
}

function fresh(): Grid { return spawn(spawn(emptyGrid())); }
function fresh(): Tile[] { return spawn(spawn([])); }

export default function Game2048({ lang = 'en' }: { lang?: Lang }) {
const t = TR[lang] ?? TR.en;
const { ref: stageRef, expanded, enter, exit } = useExpand<HTMLDivElement>();
const [grid, setGrid] = useState<Grid>(fresh);
const [tiles, setTiles] = useState<Tile[]>(fresh);
const [ghosts, setGhosts] = useState<Ghost[]>([]);
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 [history, setHistory] = useState<{ tiles: Tile[]; score: number }[]>([]);
const ghostTimer = useRef<ReturnType<typeof setTimeout> | null>(null);

const latest = useRef({ tiles, score, over });
latest.current = { tiles, score, over };

const latest = useRef({ grid, score, over });
latest.current = { grid, score, over };
useEffect(() => () => { if (ghostTimer.current) clearTimeout(ghostTimer.current); }, []);

const doMove = useCallback((dir: Direction | null) => {
const cur = latest.current;
if (cur.over || !dir) return;
const r = move(cur.grid, dir);
const r = moveTiles(cur.tiles, dir);
if (!r.moved) return;
const ng = spawn(r.grid);
setHistory(h => [...h.slice(-30), { grid: cur.grid, score: cur.score }]);
setGrid(ng);
const next = spawn(r.tiles);
setHistory(h => [...h.slice(-30), { tiles: cur.tiles, score: cur.score }]);
setTiles(next);
setScore(cur.score + r.gained);
if (maxTile(ng) >= 2048) setWon(true);
if (!hasMoves(ng)) setOver(true);
// Ghosts: swallowed tiles slide to the merge cell, then vanish.
setGhosts(r.ghosts);
if (ghostTimer.current) clearTimeout(ghostTimer.current);
ghostTimer.current = setTimeout(() => setGhosts([]), 170);
const grid = tilesToGrid(next);
if (maxTile(grid) >= 2048) setWon(true);
if (!hasMoves(grid)) setOver(true);
}, []);

useEffect(() => {
Expand All @@ -77,18 +99,18 @@ export default function Game2048({ lang = 'en' }: { lang?: Lang }) {
if (!auto) return;
const id = window.setInterval(() => {
if (latest.current.over) { setAuto(false); return; }
const dir = bestMove(latest.current.grid);
const dir = bestMove(tilesToGrid(latest.current.tiles));
if (dir) doMove(dir); else setAuto(false);
}, 110);
}, 160);
return () => window.clearInterval(id);
}, [auto, doMove]);

const newGame = () => { setGrid(fresh()); setScore(0); setWon(false); setOver(false); setAuto(false); setHistory([]); };
const newGame = () => { setTiles(fresh()); setGhosts([]); 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);
setTiles(last.tiles); setGhosts([]); setScore(last.score); setOver(false); setAuto(false);
return h.slice(0, -1);
});
};
Expand All @@ -106,8 +128,18 @@ export default function Game2048({ lang = 'en' }: { lang?: Lang }) {
else doMove(dy > 0 ? 'down' : 'up');
};

const fontFor = (v: number) => (v >= 1024 ? 'text-base sm:text-xl' : v >= 128 ? 'text-lg sm:text-2xl' : 'text-xl sm:text-2xl');

// A tile occupies exactly 1/4 of the board; translate() is relative to the
// element's own size, so (c*100%, r*100%) lands it on its cell. Animating
// transform only (no top/left) keeps slides on the compositor.
const tileStyle = (r: number, c: number): React.CSSProperties => ({
transform: `translate(${c * 100}%, ${r * 100}%)`,
});

return (
<div className="space-y-4">
<style>{KEYFRAMES}</style>
<p className="text-sm text-muted-foreground">{t.intro}</p>

{/* Stage: normal flow, or a fullscreen overlay (native fullscreen on
Expand All @@ -132,22 +164,48 @@ export default function Game2048({ lang = 'en' }: { lang?: Lang }) {
</Button>
</div>

<div className={expanded ? 'relative w-full max-w-[min(92vw,62vh)]' : '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 className={expanded ? 'relative w-full max-w-[min(92vw,62dvh)]' : 'relative mx-auto w-full max-w-sm'}>
<div
className="relative aspect-square w-full touch-none select-none border-2 border-border bg-muted p-1"
onTouchStart={onTouchStart}
onTouchEnd={onTouchEnd}
>
{/* Background cells */}
<div className="grid h-full w-full grid-cols-4 grid-rows-4">
{Array.from({ length: 16 }, (_, i) => (
<div key={i} className="m-1 border-2 border-border bg-background" />
))}
</div>

{/* Ghosts: swallowed tiles finishing their slide under the survivors. */}
<div className="pointer-events-none absolute inset-1">
{ghosts.map(g => (
<div key={`g${g.id}`} className="absolute left-0 top-0 h-1/4 w-1/4 transition-transform duration-[130ms] ease-in-out will-change-transform motion-reduce:transition-none" style={tileStyle(g.r, g.c)}>
<div className={`m-1 flex h-[calc(100%-0.5rem)] items-center justify-center border-2 border-border font-black tabular-nums ${fontFor(g.value)} ${TILE_COLORS[g.value] ?? 'bg-fuchsia-700 text-white'}`}>
{g.value}
</div>
</div>
))}
</div>

{/* Live tiles: transform slides + pop/spawn keyframes. */}
<div className="pointer-events-none absolute inset-1">
{tiles.map(tile => (
<div key={tile.id} className="absolute left-0 top-0 h-1/4 w-1/4 transition-transform duration-[130ms] ease-in-out will-change-transform motion-reduce:transition-none" style={tileStyle(tile.r, tile.c)}>
<div className={`m-1 flex h-[calc(100%-0.5rem)] items-center justify-center border-2 border-border font-black tabular-nums ${fontFor(tile.value)} ${TILE_COLORS[tile.value] ?? 'bg-fuchsia-700 text-white'} ${tile.justMerged ? 'gwt2048-pop' : ''} ${tile.isNew ? 'gwt2048-spawn' : ''}`}>
{tile.value}
</div>
</div>
))}
</div>

{(over || (won && !over)) && (
<div className="absolute inset-0 z-10 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>
{(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>
Expand Down
18 changes: 10 additions & 8 deletions src/registry/categories.ts
Original file line number Diff line number Diff line change
@@ -1,21 +1,23 @@
import type { Category } from '@/types/tool';
import type { Lang } from '@/i18n/config';

// Homepage order: most-used first (Image, PDF, Documents, Dev), then the
// everyday and fun categories, with the niche ones at the end.
export const categories: Category[] = [
'Dev',
'PDF',
'Image',
'Files',
'PDF',
'Documents',
'Draw',
'Dev',
'Media',
'Files',
'Calculators',
'Testers',
'Draw',
'Network',
'Maps',
'Calculators',
'Games',
'Testers',
'Legacy',
'Playground'
'Playground',
'Legacy'
];

export const categoryColors: Record<Category, string> = {
Expand Down
Loading
Loading