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
325 changes: 325 additions & 0 deletions src/islands/games/OnetGame.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,325 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { Maximize2, Minimize2, Lightbulb, Shuffle, RotateCcw } from 'lucide-react';
import { Button } from '@/components/ui/Button';
import { useExpand } from '@/hooks/useExpand';
import {
DIFFICULTIES, createBoard, findPath, findHint, hasAnyMove, removePair,
shuffleBoard, tilesLeft, isSolved, pairScore, posEq,
type Grid, type Pos, type Difficulty,
} from '@/tools/games/onet.lib';
import type { Lang } from '@/i18n/config';

/** 24 tiles chosen to stay distinct at small size (different colours + shapes). */
const TILES = [
'🍎', '🍊', '🍋', '🍇', '🍓', '🍑', '🍍', '🥝',
'🐶', '🐱', '🐸', '🐵', '🐼', '🦊', '🐨', '🦁',
'🌸', '🌻', '🌵', '🍀', '⭐', '⚡', '🔔', '🎈',
];

const BEST_KEY = 'gwt-onet-best';
const TIMED_SECONDS: Record<string, number> = { easy: 180, normal: 300, hard: 420 };

const TR: Record<Lang, Record<string, string>> = {
en: {
intro: 'Match pairs of identical tiles that can be joined by a line with at most two turns — the line may also travel around the outside of the board. Clear every tile to win.',
relaxed: 'Relaxed', timed: 'Timed', hint: 'Hint', shuffle: 'Shuffle', newGame: 'New game',
left: 'Tiles left', time: 'Time', best: 'Best', score: 'Score', expand: 'Expand', exit: 'Exit',
won: 'Board cleared! 🎉', lost: 'Time’s up!', noMoves: 'No moves left — shuffling…',
easy: 'Easy', normal: 'Normal', hard: 'Hard',
hintUsed: 'Hints used', tapHint: 'Tap two matching tiles.',
},
id: {
intro: 'Cocokkan pasangan ubin identik yang bisa dihubungkan garis dengan maksimal dua belokan — garisnya juga boleh lewat di luar papan. Habiskan semua ubin untuk menang.',
relaxed: 'Santai', timed: 'Berwaktu', hint: 'Petunjuk', shuffle: 'Acak', newGame: 'Main baru',
left: 'Sisa ubin', time: 'Waktu', best: 'Terbaik', score: 'Skor', expand: 'Perbesar', exit: 'Keluar',
won: 'Papan bersih! 🎉', lost: 'Waktu habis!', noMoves: 'Tidak ada langkah — mengacak…',
easy: 'Mudah', normal: 'Normal', hard: 'Sulit',
hintUsed: 'Petunjuk dipakai', tapHint: 'Ketuk dua ubin yang sama.',
},
};

const fmt = (s: number) => `${Math.floor(s / 60)}:${String(Math.max(0, s % 60)).padStart(2, '0')}`;

const emptyGrid = (d: Difficulty): Grid =>
Array.from({ length: d.rows }, () => Array<number>(d.cols).fill(0));

export default function OnetGame({ lang = 'en' }: { lang?: Lang }) {
const t = TR[lang] ?? TR.en;
const { ref: stageRef, expanded, enter, exit } = useExpand<HTMLDivElement>();

const [diff, setDiff] = useState<Difficulty>(DIFFICULTIES[1]);
const [timed, setTimed] = useState(false);
// Start with a deterministic empty board: dealing a random one here would
// differ between the server render and the client, causing a hydration
// mismatch. The real board is dealt on mount.
const [grid, setGrid] = useState<Grid>(() => emptyGrid(DIFFICULTIES[1]));
const [ready, setReady] = useState(false);
const [selected, setSelected] = useState<Pos | null>(null);
const [path, setPath] = useState<Pos[] | null>(null);
const [hintPair, setHintPair] = useState<[Pos, Pos] | null>(null);
const [score, setScore] = useState(0);
const [streak, setStreak] = useState(0);
const [hintsUsed, setHintsUsed] = useState(0);
const [elapsed, setElapsed] = useState(0);
const [status, setStatus] = useState<'playing' | 'won' | 'lost'>('playing');
const [notice, setNotice] = useState('');
const [best, setBest] = useState<Record<string, number>>({});

const pathTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const noticeTimer = useRef<ReturnType<typeof setTimeout> | null>(null);

useEffect(() => {
try { setBest(JSON.parse(localStorage.getItem(BEST_KEY) ?? '{}')); } catch { /* blocked */ }
setGrid(createBoard(DIFFICULTIES[1]));
setReady(true);
return () => {
if (pathTimer.current) clearTimeout(pathTimer.current);
if (noticeTimer.current) clearTimeout(noticeTimer.current);
};
}, []);

const limit = TIMED_SECONDS[diff.id] ?? 300;
const remaining = Math.max(0, limit - elapsed);

// Clock: counts up in relaxed mode, down in timed mode.
useEffect(() => {
if (status !== 'playing') return;
const id = setInterval(() => setElapsed((e) => e + 1), 1000);
return () => clearInterval(id);
}, [status]);

useEffect(() => {
if (timed && status === 'playing' && remaining <= 0) setStatus('lost');
}, [timed, remaining, status]);

const saveBest = useCallback((secs: number) => {
setBest((prev) => {
const key = `${diff.id}${timed ? '-timed' : ''}`;
if (prev[key] !== undefined && prev[key] <= secs) return prev;
const next = { ...prev, [key]: secs };
try { localStorage.setItem(BEST_KEY, JSON.stringify(next)); } catch { /* blocked */ }
return next;
});
}, [diff.id, timed]);

const start = useCallback((d: Difficulty, useTimer: boolean) => {
setReady(true);
if (pathTimer.current) clearTimeout(pathTimer.current);
setGrid(createBoard(d));
setDiff(d);
setTimed(useTimer);
setSelected(null);
setPath(null);
setHintPair(null);
setScore(0);
setStreak(0);
setHintsUsed(0);
setElapsed(0);
setStatus('playing');
setNotice('');
}, []);

const flashNotice = (msg: string) => {
setNotice(msg);
if (noticeTimer.current) clearTimeout(noticeTimer.current);
noticeTimer.current = setTimeout(() => setNotice(''), 1600);
};

/** After a removal: win, or reshuffle when the board has no legal move. */
const settle = useCallback((next: Grid, secs: number) => {
if (isSolved(next)) {
setStatus('won');
saveBest(secs);
return next;
}
if (!hasAnyMove(next)) {
flashNotice(t.noMoves);
return shuffleBoard(next);
}
return next;
}, [saveBest, t.noMoves]);

const tap = (r: number, c: number) => {
if (status !== 'playing' || grid[r][c] === 0) return;
const here: Pos = { r, c };
setHintPair(null);

if (!selected) { setSelected(here); return; }
if (posEq(selected, here)) { setSelected(null); return; }

// Tapping a different symbol just moves the selection.
if (grid[selected.r][selected.c] !== grid[r][c]) { setSelected(here); return; }

const found = findPath(grid, selected, here);
if (!found) { setSelected(here); setStreak(0); return; }

// Show the connecting line briefly, then clear the pair.
setPath(found);
const cleared = removePair(grid, selected, here);
setSelected(null);
const nextStreak = streak + 1;
setStreak(nextStreak);
setScore((s) => s + pairScore(nextStreak));
if (pathTimer.current) clearTimeout(pathTimer.current);
pathTimer.current = setTimeout(() => {
setPath(null);
setGrid(settle(cleared, elapsed));
}, 240);
};

const useHint = () => {
if (status !== 'playing') return;
const found = findHint(grid);
if (!found) { flashNotice(t.noMoves); setGrid(shuffleBoard(grid)); return; }
setHintPair(found);
setHintsUsed((n) => n + 1);
setScore((s) => Math.max(0, s - 5));
};

const doShuffle = () => {
if (status !== 'playing') return;
setSelected(null);
setGrid(shuffleBoard(grid));
setScore((s) => Math.max(0, s - 10));
};

const isHinted = (r: number, c: number) =>
!!hintPair && (posEq(hintPair[0], { r, c }) || posEq(hintPair[1], { r, c }));

const bestKey = `${diff.id}${timed ? '-timed' : ''}`;
const bestTime = best[bestKey];

const seg = (active: boolean) =>
`border-2 px-3 py-1 text-sm font-medium transition-all ${
active ? 'border-border bg-accent text-accent-foreground shadow-brutal' : 'border-border hover:shadow-brutal'
}`;

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

<div
ref={stageRef}
className={expanded
? 'fixed inset-0 z-[60] flex flex-col items-center justify-center gap-3 overflow-auto bg-background p-3'
: 'space-y-3'}
>
<div className="flex flex-wrap items-center justify-center gap-2">
{DIFFICULTIES.map((d) => (
<button key={d.id} onClick={() => start(d, timed)} aria-pressed={diff.id === d.id} className={seg(diff.id === d.id)}>
{t[d.id]}
</button>
))}
<button onClick={() => start(diff, false)} aria-pressed={!timed} className={seg(!timed)}>{t.relaxed}</button>
<button onClick={() => start(diff, true)} aria-pressed={timed} className={seg(timed)}>{t.timed}</button>
</div>

<div className="flex flex-wrap items-center justify-center gap-3 text-sm">
<span className="border-2 border-border px-3 py-1">
<span className="text-muted-foreground">{t.left}:</span> <span className="font-black tabular-nums">{tilesLeft(grid)}</span>
</span>
<span className="border-2 border-border px-3 py-1">
<span className="text-muted-foreground">{t.score}:</span> <span className="font-black tabular-nums">{score}</span>
</span>
<span className={`border-2 px-3 py-1 ${timed && remaining <= 30 ? 'border-red-500 text-red-600 dark:text-red-400' : 'border-border'}`}>
<span className="text-muted-foreground">{t.time}:</span>{' '}
<span className="font-black tabular-nums">{fmt(timed ? remaining : elapsed)}</span>
</span>
{bestTime !== undefined && (
<span className="border-2 border-border px-3 py-1">
<span className="text-muted-foreground">{t.best}:</span> <span className="font-black tabular-nums">{fmt(bestTime)}</span>
</span>
)}
</div>

{timed && (
<div className="h-2 w-full max-w-md overflow-hidden rounded-full bg-muted">
<div
className={`h-full rounded-full transition-[width] duration-1000 ease-linear ${remaining <= 30 ? 'bg-red-500' : 'bg-accent'}`}
style={{ width: `${(remaining / limit) * 100}%` }}
/>
</div>
)}

<div className="relative w-full" style={{ maxWidth: expanded ? 'min(96vw, 60rem)' : '34rem' }}>
<div
className="relative grid gap-[2px] border-2 border-border bg-muted p-[2px]"
style={{ gridTemplateColumns: `repeat(${diff.cols}, minmax(0, 1fr))` }}
>
{grid.map((row, r) =>
row.map((v, c) => {
const sel = selected && posEq(selected, { r, c });
return (
<button
key={`${r}-${c}`}
onClick={() => tap(r, c)}
disabled={v === 0 || status !== 'playing'}
aria-label={v === 0 ? 'empty' : `tile ${v}`}
className={`flex aspect-square select-none items-center justify-center border-2 text-[clamp(0.75rem,4vw,1.5rem)] leading-none transition-colors ${
v === 0
? 'border-transparent bg-transparent'
: sel
? 'border-accent bg-accent/20 shadow-brutal-sm'
: isHinted(r, c)
? 'border-emerald-500 bg-emerald-500/20'
: 'border-border bg-background hover:bg-muted'
}`}
>
{v === 0 ? '' : TILES[(v - 1) % TILES.length]}
</button>
);
}),
)}

{/* Connecting line, drawn in cell units with a one-cell margin so
routes that leave the board are visible. */}
{path && (
<svg
className="pointer-events-none absolute"
style={{ left: '-8%', top: '-6%', width: '116%', height: '112%' }}
viewBox={`-1 -1 ${diff.cols + 2} ${diff.rows + 2}`}
preserveAspectRatio="none"
>
<polyline
points={path.map((p) => `${p.c + 0.5},${p.r + 0.5}`).join(' ')}
fill="none"
stroke="rgb(74,222,128)"
strokeWidth={0.14}
strokeLinecap="round"
strokeLinejoin="round"
vectorEffect="non-scaling-stroke"
/>
</svg>
)}

{ready && status !== 'playing' && (
<div className="absolute inset-0 z-10 flex flex-col items-center justify-center gap-2 bg-black/60 text-center text-white">
<span className="text-2xl font-black">{status === 'won' ? t.won : t.lost}</span>
<span className="text-sm">{t.score}: {score} · {t.time}: {fmt(timed ? limit - remaining : elapsed)}</span>
<Button onClick={() => start(diff, timed)}>{t.newGame}</Button>
</div>
)}
</div>
</div>

<div className="flex flex-wrap items-center justify-center gap-2">
<Button variant="secondary" onClick={useHint} disabled={status !== 'playing'}>
<Lightbulb className="h-4 w-4" /> {t.hint}
</Button>
<Button variant="secondary" onClick={doShuffle} disabled={status !== 'playing'}>
<Shuffle className="h-4 w-4" /> {t.shuffle}
</Button>
<Button onClick={() => start(diff, timed)}><RotateCcw className="h-4 w-4" /> {t.newGame}</Button>
<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>

<p className="min-h-[1.25rem] text-center text-xs text-muted-foreground">
{notice || (hintsUsed > 0 ? `${t.hintUsed}: ${hintsUsed}` : t.tapHint)}
</p>
</div>
</div>
);
}
36 changes: 36 additions & 0 deletions src/registry/tool-seo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -865,6 +865,24 @@ const en: Record<string, ToolSeoContent> = {
{ q: 'Does it work offline?', a: 'Yes. GoodWebTools is a PWA, so once loaded the game runs with no internet connection — fitting, for this one.' },
],
},
'onet': {
title: 'Onet Connect — Free Tile Matching Game Online',
description: 'Play Onet free in your browser: match pairs of identical tiles that a line can join with at most two turns. Three difficulties, relaxed or timed. No ads, works offline.',
intro: 'Onet Connect is the classic tile-matching puzzle: clear the board by pairing identical tiles that can be joined by a line with at most two turns. The line can also travel around the outside of the board, which is what makes edge tiles matchable. Pick a difficulty, play relaxed or against the clock, and use a hint or shuffle when you get stuck. Everything runs in your browser — no ads, no account.',
howTo: [
'Tap one tile, then tap a matching tile to connect them.',
'The connecting line may bend at most twice, and may pass around the outside of the board.',
'Use Hint to reveal a valid pair, or Shuffle to redeal when you are stuck.',
'Clear every tile to win — try Timed mode for a countdown challenge.',
],
faqs: [
{ q: 'What are the matching rules?', a: 'Two tiles must show the same picture and be joinable by a line that turns at most twice and passes only through empty space. The line may also route around the outside edge of the board.' },
{ q: 'What happens when there are no moves left?', a: 'The board reshuffles automatically so you can keep playing — the remaining tiles stay in their cells, only the pictures move.' },
{ q: 'Is there a timer?', a: 'Only if you want one. Relaxed mode has no clock; Timed mode gives you a countdown that depends on the difficulty. Your best time is saved on this device.' },
{ q: 'Does it work on mobile?', a: 'Yes — tap tiles to select them, and use Expand for a bigger fullscreen board, which helps a lot on the harder sizes.' },
{ q: 'Does it work offline?', a: 'Yes. GoodWebTools is a PWA, so once loaded the game works with no internet connection.' },
],
},
'snake': {
title: 'Snake Game — Play the Classic Snake Online Free',
description: 'Play classic snake in your browser: swipe or use arrow keys, grab golden bonus food, and optionally wrap through the walls. Free, no ads, works offline.',
Expand Down Expand Up @@ -3805,6 +3823,24 @@ const id: Record<string, ToolSeoContent> = {
{ q: 'Apakah bekerja offline?', a: 'Ya. GoodWebTools adalah PWA, jadi setelah dimuat game berjalan tanpa koneksi internet — cocok untuk game yang satu ini.' },
],
},
'onet': {
title: 'Onet Connect — Game Cocokkan Ubin Gratis Online',
description: 'Main Onet gratis di browser: cocokkan pasangan ubin identik yang bisa dihubungkan garis dengan maksimal dua belokan. Tiga tingkat kesulitan, santai atau berwaktu. Tanpa iklan, bekerja offline.',
intro: 'Onet Connect adalah puzzle cocokkan ubin klasik: bersihkan papan dengan memasangkan ubin identik yang bisa dihubungkan garis dengan maksimal dua belokan. Garisnya juga boleh lewat di luar papan — itulah yang membuat ubin di tepi tetap bisa dipasangkan. Pilih tingkat kesulitan, main santai atau berpacu dengan waktu, dan gunakan petunjuk atau acak saat buntu. Semuanya berjalan di browser Anda — tanpa iklan, tanpa akun.',
howTo: [
'Ketuk satu ubin, lalu ketuk ubin yang sama untuk menghubungkannya.',
'Garis penghubung boleh berbelok maksimal dua kali, dan boleh lewat di luar papan.',
'Gunakan Petunjuk untuk menampilkan pasangan yang valid, atau Acak saat buntu.',
'Habiskan semua ubin untuk menang — coba mode Berwaktu untuk tantangan hitung mundur.',
],
faqs: [
{ q: 'Apa aturan pencocokannya?', a: 'Dua ubin harus bergambar sama dan bisa dihubungkan garis yang berbelok maksimal dua kali serta hanya melewati ruang kosong. Garisnya juga boleh memutar lewat tepi luar papan.' },
{ q: 'Apa yang terjadi kalau tidak ada langkah tersisa?', a: 'Papan diacak ulang otomatis agar Anda bisa terus bermain — ubin tetap di selnya, hanya gambarnya yang berpindah.' },
{ q: 'Apakah ada timer?', a: 'Hanya jika Anda mau. Mode Santai tanpa jam; mode Berwaktu memberi hitung mundur sesuai tingkat kesulitan. Waktu terbaik Anda tersimpan di perangkat ini.' },
{ q: 'Apakah bisa di ponsel?', a: 'Ya — ketuk ubin untuk memilih, dan gunakan Perbesar untuk papan layar penuh yang lebih besar, sangat membantu di ukuran sulit.' },
{ q: 'Apakah bekerja offline?', a: 'Ya. GoodWebTools adalah PWA, jadi setelah dimuat game bekerja tanpa koneksi internet.' },
],
},
'snake': {
title: 'Game Ular — Main Snake Klasik Online Gratis',
description: 'Main snake klasik di browser: geser atau pakai tombol panah, ambil makanan bonus emas, dan opsional tembus dinding. Gratis, tanpa iklan, bekerja offline.',
Expand Down
Loading
Loading