refactor(frontend): move to frontend/, port to Vite + TypeScript
Splits the single game.js god-class into layered modules (types, data, engine, game, preview, ui, main) and converts every source file to TypeScript, built and served by Vite instead of a plain static server. No behavior change: verified against the previous JS version via a full play-through (theme select, preview countdown, coloring, reveal) in a real browser.
This commit is contained in:
parent
467447b51a
commit
6f9a298aef
25 changed files with 2153 additions and 695 deletions
15
.gitignore
vendored
Normal file
15
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
# Dependencies
|
||||
**/node_modules/
|
||||
|
||||
# Build output
|
||||
**/dist/
|
||||
**/.vite/
|
||||
|
||||
# Env files
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
# OS/editor cruft
|
||||
.DS_Store
|
||||
*.local
|
||||
|
|
@ -7,7 +7,6 @@
|
|||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Press+Start+2P&display=swap" rel="stylesheet" />
|
||||
<link rel="stylesheet" href="style.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="app">
|
||||
|
|
@ -107,6 +106,6 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<script type="module" src="src/main.js"></script>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
1062
frontend/package-lock.json
generated
Normal file
1062
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
17
frontend/package.json
Normal file
17
frontend/package.json
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
{
|
||||
"name": "theme-guess-frontend",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "Canvas-based syntax-theme guessing game (Vite + TypeScript, no framework).",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc --noEmit && vite build",
|
||||
"preview": "vite preview",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.6.3",
|
||||
"vite": "^5.4.11"
|
||||
}
|
||||
}
|
||||
|
|
@ -6,7 +6,9 @@
|
|||
// Tokyo Night (enkia/tokyo-night-vscode palette), Dracula and Catppuccin
|
||||
// Mocha (official published palettes).
|
||||
|
||||
export const CATEGORY_META = [
|
||||
import type { CategoryMeta, Themes } from '../types';
|
||||
|
||||
export const CATEGORY_META: CategoryMeta[] = [
|
||||
{ id: 'background', label: 'Background', icon: '🌑' },
|
||||
{ id: 'keyword', label: 'Keywords', icon: '🔑' },
|
||||
{ id: 'string', label: 'Strings', icon: '🧵' },
|
||||
|
|
@ -21,7 +23,7 @@ export const CATEGORY_META = [
|
|||
{ id: 'comment', label: 'Comments', icon: '💬' },
|
||||
];
|
||||
|
||||
export const THEMES = {
|
||||
export const THEMES: Themes = {
|
||||
gruvbox: {
|
||||
name: 'Gruvbox Dark',
|
||||
colors: {
|
||||
|
|
@ -106,6 +108,7 @@ if (accent.hue > 180 && !primary.locked) {
|
|||
|
||||
export default palette;
|
||||
`;
|
||||
|
||||
// A short, different snippet used for the pre-game theme reveal. It's
|
||||
// deliberately missing some token kinds the real game asks about (no
|
||||
// numeric literals, no true/false/null/undefined/this) so the preview
|
||||
|
|
@ -1,23 +1,25 @@
|
|||
// Color math: hex/rgb helpers, interpolation, and a perceptual (CIELAB)
|
||||
// distance used to score how close a guessed color is to the real one.
|
||||
|
||||
export function hexToRgb(hex) {
|
||||
import type { RGB } from '../types';
|
||||
|
||||
export function hexToRgb(hex: string): RGB {
|
||||
const h = hex.replace('#', '').trim();
|
||||
const full = h.length === 3 ? h.split('').map((c) => c + c).join('') : h;
|
||||
const int = parseInt(full, 16) || 0;
|
||||
return { r: (int >> 16) & 255, g: (int >> 8) & 255, b: int & 255 };
|
||||
}
|
||||
|
||||
export function rgbToHex({ r, g, b }) {
|
||||
const c = (v) => Math.max(0, Math.min(255, Math.round(v))).toString(16).padStart(2, '0');
|
||||
export function rgbToHex({ r, g, b }: RGB): string {
|
||||
const c = (v: number) => Math.max(0, Math.min(255, Math.round(v))).toString(16).padStart(2, '0');
|
||||
return `#${c(r)}${c(g)}${c(b)}`;
|
||||
}
|
||||
|
||||
export function isValidHex(hex) {
|
||||
export function isValidHex(hex: string): boolean {
|
||||
return /^#?[0-9a-f]{6}$/i.test(hex);
|
||||
}
|
||||
|
||||
export function lerpRgb(a, b, t) {
|
||||
export function lerpRgb(a: RGB, b: RGB, t: number): RGB {
|
||||
return {
|
||||
r: a.r + (b.r - a.r) * t,
|
||||
g: a.g + (b.g - a.g) * t,
|
||||
|
|
@ -25,16 +27,28 @@ export function lerpRgb(a, b, t) {
|
|||
};
|
||||
}
|
||||
|
||||
export function easeOutCubic(t) {
|
||||
export function easeOutCubic(t: number): number {
|
||||
return 1 - Math.pow(1 - t, 3);
|
||||
}
|
||||
|
||||
function srgbToLinear(c) {
|
||||
interface Lab {
|
||||
l: number;
|
||||
a: number;
|
||||
b: number;
|
||||
}
|
||||
|
||||
interface XYZ {
|
||||
x: number;
|
||||
y: number;
|
||||
z: number;
|
||||
}
|
||||
|
||||
function srgbToLinear(c: number): number {
|
||||
const cs = c / 255;
|
||||
return cs <= 0.04045 ? cs / 12.92 : Math.pow((cs + 0.055) / 1.055, 2.4);
|
||||
}
|
||||
|
||||
function rgbToXyz({ r, g, b }) {
|
||||
function rgbToXyz({ r, g, b }: RGB): XYZ {
|
||||
const R = srgbToLinear(r);
|
||||
const G = srgbToLinear(g);
|
||||
const B = srgbToLinear(b);
|
||||
|
|
@ -45,26 +59,27 @@ function rgbToXyz({ r, g, b }) {
|
|||
};
|
||||
}
|
||||
|
||||
const REF_WHITE = { x: 0.95047, y: 1.0, z: 1.08883 };
|
||||
const REF_WHITE: XYZ = { x: 0.95047, y: 1.0, z: 1.08883 };
|
||||
|
||||
function xyzToLab({ x, y, z }) {
|
||||
const f = (t) => (t > 0.008856 ? Math.cbrt(t) : 7.787 * t + 16 / 116);
|
||||
function xyzToLab({ x, y, z }: XYZ): Lab {
|
||||
const f = (t: number) => (t > 0.008856 ? Math.cbrt(t) : 7.787 * t + 16 / 116);
|
||||
const fx = f(x / REF_WHITE.x);
|
||||
const fy = f(y / REF_WHITE.y);
|
||||
const fz = f(z / REF_WHITE.z);
|
||||
return { l: 116 * fy - 16, a: 500 * (fx - fy), b: 200 * (fy - fz) };
|
||||
}
|
||||
|
||||
export function hexToLab(hex) {
|
||||
export function hexToLab(hex: string): Lab {
|
||||
return xyzToLab(rgbToXyz(hexToRgb(hex)));
|
||||
}
|
||||
|
||||
export function deltaE76(labA, labB) {
|
||||
export function deltaE76(labA: Lab, labB: Lab): number {
|
||||
return Math.sqrt((labA.l - labB.l) ** 2 + (labA.a - labB.a) ** 2 + (labA.b - labB.b) ** 2);
|
||||
}
|
||||
|
||||
// 100 at identical colors, decaying smoothly as perceptual distance grows.
|
||||
export function similarityFromHex(hexA, hexB) {
|
||||
export function similarityFromHex(hexA: string | null, hexB: string): number {
|
||||
if (!hexA) return 0;
|
||||
const dE = deltaE76(hexToLab(hexA), hexToLab(hexB));
|
||||
const sim = 100 * Math.exp(-dE / 45);
|
||||
return Math.max(0, Math.min(100, sim));
|
||||
|
|
@ -3,17 +3,39 @@
|
|||
|
||||
const CONFETTI_COLORS = ['#fb4934', '#b8bb26', '#fabd2f', '#83a598', '#d3869b', '#8ec07c', '#fe8019'];
|
||||
|
||||
export class ParticleSystem {
|
||||
constructor() {
|
||||
this.ripples = [];
|
||||
this.particles = [];
|
||||
}
|
||||
interface Ripple {
|
||||
x: number;
|
||||
y: number;
|
||||
r: number;
|
||||
maxR: number;
|
||||
life: number;
|
||||
color: string;
|
||||
}
|
||||
|
||||
spawnRipple(x, y, color = '#ebdbb2') {
|
||||
interface Particle {
|
||||
x: number;
|
||||
y: number;
|
||||
vx: number;
|
||||
vy: number;
|
||||
life: number;
|
||||
decay: number;
|
||||
size: number;
|
||||
gravity: number;
|
||||
color: string;
|
||||
confetti: boolean;
|
||||
rot: number;
|
||||
vr: number;
|
||||
}
|
||||
|
||||
export class ParticleSystem {
|
||||
private ripples: Ripple[] = [];
|
||||
private particles: Particle[] = [];
|
||||
|
||||
spawnRipple(x: number, y: number, color = '#ebdbb2'): void {
|
||||
this.ripples.push({ x, y, r: 2, maxR: 46, life: 1, color });
|
||||
}
|
||||
|
||||
spawnBurst(x, y, color, count = 14) {
|
||||
spawnBurst(x: number, y: number, color: string, count = 14): void {
|
||||
for (let i = 0; i < count; i++) {
|
||||
const angle = Math.random() * Math.PI * 2;
|
||||
const speed = 50 + Math.random() * 110;
|
||||
|
|
@ -26,11 +48,14 @@ export class ParticleSystem {
|
|||
size: 1.5 + Math.random() * 2.5,
|
||||
gravity: 90,
|
||||
color,
|
||||
confetti: false,
|
||||
rot: 0,
|
||||
vr: 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
spawnConfetti(count, width) {
|
||||
spawnConfetti(count: number, width: number): void {
|
||||
for (let i = 0; i < count; i++) {
|
||||
this.particles.push({
|
||||
x: Math.random() * width,
|
||||
|
|
@ -49,12 +74,12 @@ export class ParticleSystem {
|
|||
}
|
||||
}
|
||||
|
||||
clear() {
|
||||
clear(): void {
|
||||
this.particles.length = 0;
|
||||
this.ripples.length = 0;
|
||||
}
|
||||
|
||||
update(dtMs) {
|
||||
update(dtMs: number): void {
|
||||
const s = dtMs / 1000;
|
||||
for (const r of this.ripples) {
|
||||
r.r += (r.maxR - r.r) * 0.18;
|
||||
|
|
@ -65,14 +90,14 @@ export class ParticleSystem {
|
|||
for (const p of this.particles) {
|
||||
p.x += p.vx * s;
|
||||
p.y += p.vy * s;
|
||||
p.vy += (p.gravity || 100) * s;
|
||||
p.vy += p.gravity * s;
|
||||
if (p.confetti) p.rot += p.vr * s;
|
||||
p.life -= p.decay * s;
|
||||
}
|
||||
this.particles = this.particles.filter((p) => p.life > 0);
|
||||
}
|
||||
|
||||
draw(ctx) {
|
||||
draw(ctx: CanvasRenderingContext2D): void {
|
||||
for (const r of this.ripples) {
|
||||
ctx.globalAlpha = Math.max(r.life, 0) * 0.6;
|
||||
ctx.strokeStyle = r.color;
|
||||
|
|
@ -1,13 +1,17 @@
|
|||
// Tiny synthesized SFX — no audio assets, just oscillators with a
|
||||
// quick exponential envelope. Created lazily on first user gesture.
|
||||
|
||||
class Sound {
|
||||
constructor() {
|
||||
this.ctx = null;
|
||||
this.muted = false;
|
||||
declare global {
|
||||
interface Window {
|
||||
webkitAudioContext?: typeof AudioContext;
|
||||
}
|
||||
}
|
||||
|
||||
ensure() {
|
||||
class Sound {
|
||||
private ctx: AudioContext | null = null;
|
||||
muted = false;
|
||||
|
||||
private ensure(): AudioContext | null {
|
||||
if (!this.ctx) {
|
||||
const Ctx = window.AudioContext || window.webkitAudioContext;
|
||||
if (!Ctx) return null;
|
||||
|
|
@ -17,7 +21,7 @@ class Sound {
|
|||
return this.ctx;
|
||||
}
|
||||
|
||||
tone(freq, dur = 0.12, type = 'sine', gain = 0.05, when = 0) {
|
||||
private tone(freq: number, dur = 0.12, type: OscillatorType = 'sine', gain = 0.05, when = 0): void {
|
||||
if (this.muted) return;
|
||||
const ctx = this.ensure();
|
||||
if (!ctx) return;
|
||||
|
|
@ -35,14 +39,16 @@ class Sound {
|
|||
osc.stop(t0 + dur + 0.03);
|
||||
}
|
||||
|
||||
playOpen() { this.tone(660, 0.07, 'triangle', 0.035); }
|
||||
playOpen(): void {
|
||||
this.tone(660, 0.07, 'triangle', 0.035);
|
||||
}
|
||||
|
||||
playApply() {
|
||||
playApply(): void {
|
||||
this.tone(523.25, 0.09, 'sine', 0.05);
|
||||
this.tone(783.99, 0.12, 'sine', 0.045, 0.06);
|
||||
}
|
||||
|
||||
playReveal(score) {
|
||||
playReveal(score: number): void {
|
||||
const notes = score >= 85
|
||||
? [523.25, 659.25, 783.99, 1046.5]
|
||||
: score >= 60
|
||||
|
|
@ -51,7 +57,7 @@ class Sound {
|
|||
notes.forEach((f, i) => this.tone(f, 0.2, 'triangle', 0.05, i * 0.1));
|
||||
}
|
||||
|
||||
toggleMute() {
|
||||
toggleMute(): boolean {
|
||||
this.muted = !this.muted;
|
||||
return this.muted;
|
||||
}
|
||||
|
|
@ -2,7 +2,13 @@
|
|||
// just consistent enough to bucket every word in the sample into the
|
||||
// same "kind" categories a syntax theme would color.
|
||||
|
||||
const KEYWORDS = new Set([
|
||||
import type { Token, TokenType } from '../types';
|
||||
|
||||
function toLookup(words: readonly string[]): Record<string, true> {
|
||||
return Object.fromEntries(words.map((w) => [w, true] as const));
|
||||
}
|
||||
|
||||
const KEYWORDS = toLookup([
|
||||
'const', 'let', 'var', 'function', 'return', 'if', 'else', 'for', 'while',
|
||||
'of', 'in', 'new', 'class', 'extends', 'constructor', 'import', 'from',
|
||||
'export', 'default', 'static', 'async', 'await', 'try', 'catch', 'finally',
|
||||
|
|
@ -10,15 +16,18 @@ const KEYWORDS = new Set([
|
|||
'yield', 'super', 'do',
|
||||
]);
|
||||
|
||||
const CONSTANTS = new Set(['true', 'false', 'null', 'undefined', 'this']);
|
||||
const CONSTANTS = toLookup(['true', 'false', 'null', 'undefined', 'this']);
|
||||
|
||||
const OPS3 = ['===', '!==', '**='];
|
||||
const OPS2 = ['=>', '==', '!=', '<=', '>=', '&&', '||', '+=', '-=', '*=', '/=', '++', '--'];
|
||||
const PUNCT = '{}()[];,.';
|
||||
const OP_CHARS = '+-*/%=<>!?:&|^~';
|
||||
|
||||
function scan(src) {
|
||||
const raw = [];
|
||||
/** Token shape mid-scan, before line/col layout has been assigned. */
|
||||
type RawToken = Pick<Token, 'type' | 'text'>;
|
||||
|
||||
function scan(src: string): RawToken[] {
|
||||
const raw: RawToken[] = [];
|
||||
let i = 0;
|
||||
const n = src.length;
|
||||
while (i < n) {
|
||||
|
|
@ -70,8 +79,8 @@ function scan(src) {
|
|||
|
||||
// Defensive: split any token that smuggled in a newline (block comments,
|
||||
// multi-line strings) so layout math stays a simple line/col grid.
|
||||
function expandNewlines(raw) {
|
||||
const tokens = [];
|
||||
function expandNewlines(raw: RawToken[]): RawToken[] {
|
||||
const tokens: RawToken[] = [];
|
||||
for (const t of raw) {
|
||||
if (t.type !== 'newline' && t.text.includes('\n')) {
|
||||
const parts = t.text.split('\n');
|
||||
|
|
@ -86,40 +95,45 @@ function expandNewlines(raw) {
|
|||
return tokens;
|
||||
}
|
||||
|
||||
function classify(tokens) {
|
||||
function classifyIdentifier(tokens: RawToken[], k: number): TokenType {
|
||||
const word = tokens[k].text;
|
||||
let p = k - 1;
|
||||
while (p >= 0 && (tokens[p].type === 'whitespace' || tokens[p].type === 'newline')) p--;
|
||||
let nx = k + 1;
|
||||
while (nx < tokens.length && (tokens[nx].type === 'whitespace' || tokens[nx].type === 'newline')) nx++;
|
||||
const prev = p >= 0 ? tokens[p] : null;
|
||||
const next = nx < tokens.length ? tokens[nx] : null;
|
||||
const isCall = !!next && next.type === 'punctuation' && next.text === '(';
|
||||
const isProp = !!prev && prev.type === 'punctuation' && prev.text === '.';
|
||||
const isClassCtx = !!prev && prev.type === 'keyword' && ['class', 'new', 'extends'].includes(prev.text);
|
||||
|
||||
if (KEYWORDS[word]) return 'keyword';
|
||||
if (CONSTANTS[word]) return 'constant';
|
||||
if (/^[A-Z]/.test(word) && (isClassCtx || !isCall)) return 'type';
|
||||
if (isCall) return 'function';
|
||||
if (isProp) return 'property';
|
||||
return 'variable';
|
||||
}
|
||||
|
||||
function classify(tokens: RawToken[]): Token[] {
|
||||
const result: Token[] = [];
|
||||
let line = 0;
|
||||
let col = 0;
|
||||
for (let k = 0; k < tokens.length; k++) {
|
||||
const t = tokens[k];
|
||||
if (t.type === 'newline') { line++; col = 0; continue; }
|
||||
|
||||
if (t.type === 'identifier') {
|
||||
const word = t.text;
|
||||
let p = k - 1;
|
||||
while (p >= 0 && (tokens[p].type === 'whitespace' || tokens[p].type === 'newline')) p--;
|
||||
let nx = k + 1;
|
||||
while (nx < tokens.length && (tokens[nx].type === 'whitespace' || tokens[nx].type === 'newline')) nx++;
|
||||
const prev = p >= 0 ? tokens[p] : null;
|
||||
const next = nx < tokens.length ? tokens[nx] : null;
|
||||
const isCall = !!next && next.type === 'punctuation' && next.text === '(';
|
||||
const isProp = !!prev && prev.type === 'punctuation' && prev.text === '.';
|
||||
const isClassCtx = !!prev && prev.type === 'keyword' && ['class', 'new', 'extends'].includes(prev.text);
|
||||
|
||||
if (KEYWORDS.has(word)) t.type = 'keyword';
|
||||
else if (CONSTANTS.has(word)) t.type = 'constant';
|
||||
else if (/^[A-Z]/.test(word) && (isClassCtx || !isCall)) t.type = 'type';
|
||||
else if (isCall) t.type = 'function';
|
||||
else if (isProp) t.type = 'property';
|
||||
else t.type = 'variable';
|
||||
if (t.type === 'newline') {
|
||||
result.push({ ...t, line, col });
|
||||
line++;
|
||||
col = 0;
|
||||
continue;
|
||||
}
|
||||
|
||||
t.line = line;
|
||||
t.col = col;
|
||||
const type = t.type === 'identifier' ? classifyIdentifier(tokens, k) : t.type;
|
||||
result.push({ type, text: t.text, line, col });
|
||||
col += t.text.length;
|
||||
}
|
||||
return tokens;
|
||||
return result;
|
||||
}
|
||||
|
||||
export function tokenize(source) {
|
||||
export function tokenize(source: string): Token[] {
|
||||
return classify(expandNewlines(scan(source)));
|
||||
}
|
||||
401
frontend/src/game/ThemeGuessGame.ts
Normal file
401
frontend/src/game/ThemeGuessGame.ts
Normal file
|
|
@ -0,0 +1,401 @@
|
|||
import { tokenize } from '../engine/tokenizer';
|
||||
import { CATEGORY_META, THEMES, CODE_SAMPLE } from '../data/themes';
|
||||
import { easeOutCubic, hexToRgb, isValidHex, lerpRgb, rgbToHex } from '../engine/colorUtils';
|
||||
import { ParticleSystem } from '../engine/particles';
|
||||
import { sound } from '../engine/sound';
|
||||
import type { CategoryId, CategoryStateMap, RGB, ThemeId, Token } from '../types';
|
||||
import { FONT_SIZE, FONT_STACK, GUTTER, LINE_HEIGHT, PAD, TRANSITION_MS, UNSET_BG_RGB, UNSET_FG_RGB } from './layoutConstants';
|
||||
import { drawFrame } from './renderer';
|
||||
import { computeMatchResult } from './scoring';
|
||||
import { renderCategoryPanel, setActiveCard, markCategoryAssigned, getCardRect } from './panelView';
|
||||
import { renderBreakdown, renderScoreMeter, verdictFor } from './resultView';
|
||||
|
||||
type CompareMode = 'yours' | 'original';
|
||||
|
||||
/** Grabs a required element by id, throwing early with a useful message
|
||||
* instead of failing later on a null dereference. */
|
||||
function requireEl<T extends HTMLElement>(id: string): T {
|
||||
const el = document.getElementById(id);
|
||||
if (!el) throw new Error(`Missing required element #${id}`);
|
||||
return el as T;
|
||||
}
|
||||
|
||||
export class ThemeGuessGame {
|
||||
private readonly canvas = requireEl<HTMLCanvasElement>('code-canvas');
|
||||
private readonly ctx = this.canvas.getContext('2d')!;
|
||||
private readonly panel = requireEl<HTMLElement>('category-panel');
|
||||
private readonly popover = requireEl<HTMLElement>('picker-popover');
|
||||
private readonly popoverLabel = requireEl<HTMLElement>('popover-label');
|
||||
private readonly popoverColor = requireEl<HTMLInputElement>('popover-color');
|
||||
private readonly popoverHex = requireEl<HTMLInputElement>('popover-hex');
|
||||
private readonly progressFill = requireEl<HTMLElement>('progress-fill');
|
||||
private readonly progressLabel = requireEl<HTMLElement>('progress-label');
|
||||
private readonly revealBtn = requireEl<HTMLButtonElement>('reveal-btn');
|
||||
private readonly resetBtn = requireEl<HTMLButtonElement>('reset-btn');
|
||||
private readonly resultModal = requireEl<HTMLElement>('result-modal');
|
||||
private readonly scoreNumber = requireEl<HTMLElement>('score-number');
|
||||
private readonly scoreMeter = requireEl<HTMLElement>('score-meter');
|
||||
private readonly scoreVerdict = requireEl<HTMLElement>('score-verdict');
|
||||
private readonly breakdownEl = requireEl<HTMLElement>('score-breakdown');
|
||||
private readonly compareToggle = requireEl<HTMLElement>('compare-toggle');
|
||||
private readonly closeResultBtn = requireEl<HTMLButtonElement>('close-result-btn');
|
||||
private readonly playAgainBtn = requireEl<HTMLButtonElement>('play-again-btn');
|
||||
|
||||
private readonly fx = new ParticleSystem();
|
||||
|
||||
private themeId: ThemeId;
|
||||
private activeCategory: CategoryId | null = null;
|
||||
private compareMode: CompareMode = 'yours';
|
||||
private lastTime = performance.now();
|
||||
private pickerOrigin: RGB | null = null;
|
||||
|
||||
private tokens: Token[] = [];
|
||||
private lineCount = 1;
|
||||
private maxCols = 0;
|
||||
private charWidth = 0;
|
||||
private width = 0;
|
||||
private height = 0;
|
||||
private categories!: CategoryStateMap;
|
||||
|
||||
constructor(themeId: ThemeId) {
|
||||
this.themeId = themeId;
|
||||
this.build();
|
||||
this.bindEvents();
|
||||
requestAnimationFrame((t) => this.loop(t));
|
||||
}
|
||||
|
||||
// ---------- setup ----------
|
||||
|
||||
private build(): void {
|
||||
this.tokens = tokenize(CODE_SAMPLE);
|
||||
|
||||
this.lineCount = 1;
|
||||
this.maxCols = 0;
|
||||
let col = 0;
|
||||
for (const t of this.tokens) {
|
||||
if (t.type === 'newline') { this.lineCount++; col = 0; }
|
||||
else { col += t.text.length; this.maxCols = Math.max(this.maxCols, col); }
|
||||
}
|
||||
|
||||
const counts: Partial<Record<CategoryId, number>> = {};
|
||||
for (const t of this.tokens) {
|
||||
if (t.type === 'whitespace' || t.type === 'newline' || t.type === 'identifier') continue;
|
||||
counts[t.type] = (counts[t.type] ?? 0) + 1;
|
||||
}
|
||||
const maxCount = Math.max(1, ...Object.values(counts));
|
||||
|
||||
const theme = THEMES[this.themeId];
|
||||
const categories = {} as CategoryStateMap;
|
||||
for (const def of CATEGORY_META) {
|
||||
const count = counts[def.id] ?? 0;
|
||||
const unset = def.id === 'background' ? UNSET_BG_RGB : UNSET_FG_RGB;
|
||||
categories[def.id] = {
|
||||
...def,
|
||||
actualHex: theme.colors[def.id],
|
||||
weight: def.id === 'background' ? maxCount : Math.max(count, 3),
|
||||
count,
|
||||
assignedHex: null,
|
||||
currentRgb: { ...unset },
|
||||
fromRgb: { ...unset },
|
||||
toRgb: { ...unset },
|
||||
transitionStart: 0,
|
||||
pulsePhase: Math.random() * Math.PI * 2,
|
||||
};
|
||||
}
|
||||
this.categories = categories;
|
||||
|
||||
this.setupCanvasSize();
|
||||
this.renderPanel();
|
||||
this.updateProgress();
|
||||
}
|
||||
|
||||
private setupCanvasSize(): void {
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
this.ctx.font = `${FONT_SIZE}px ${FONT_STACK}`;
|
||||
this.charWidth = this.ctx.measureText('M').width;
|
||||
this.width = GUTTER + PAD * 2 + this.maxCols * this.charWidth;
|
||||
this.height = PAD * 2 + this.lineCount * LINE_HEIGHT;
|
||||
this.canvas.style.width = `${this.width}px`;
|
||||
this.canvas.style.height = `${this.height}px`;
|
||||
this.canvas.width = this.width * dpr;
|
||||
this.canvas.height = this.height * dpr;
|
||||
this.ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
}
|
||||
|
||||
private renderPanel(): void {
|
||||
renderCategoryPanel(this.panel, this.categories, (id, card) => {
|
||||
this.setActiveCategory(id);
|
||||
this.openPicker(id, card.getBoundingClientRect());
|
||||
});
|
||||
}
|
||||
|
||||
// ---------- theme switching ----------
|
||||
|
||||
setTheme(themeId: ThemeId): void {
|
||||
this.themeId = themeId;
|
||||
const theme = THEMES[themeId];
|
||||
for (const def of CATEGORY_META) {
|
||||
const cat = this.categories[def.id];
|
||||
cat.actualHex = theme.colors[def.id];
|
||||
cat.assignedHex = null;
|
||||
const unset = def.id === 'background' ? UNSET_BG_RGB : UNSET_FG_RGB;
|
||||
cat.currentRgb = { ...unset };
|
||||
cat.fromRgb = { ...unset };
|
||||
cat.toRgb = { ...unset };
|
||||
cat.transitionStart = 0;
|
||||
}
|
||||
this.compareMode = 'yours';
|
||||
this.activeCategory = null;
|
||||
this.popover.classList.add('hidden');
|
||||
this.resultModal.classList.add('hidden');
|
||||
this.renderPanel();
|
||||
this.updateProgress();
|
||||
this.fx.clear();
|
||||
}
|
||||
|
||||
// ---------- events ----------
|
||||
|
||||
private bindEvents(): void {
|
||||
this.canvas.addEventListener('click', (e) => this.onCanvasClick(e));
|
||||
this.canvas.addEventListener('mousemove', (e) => this.onCanvasHover(e));
|
||||
|
||||
this.popoverColor.addEventListener('input', () => this.onPickerLiveInput(this.popoverColor.value));
|
||||
this.popoverHex.addEventListener('input', () => {
|
||||
let v = this.popoverHex.value.trim();
|
||||
if (!v.startsWith('#')) v = `#${v}`;
|
||||
if (isValidHex(v)) {
|
||||
this.popoverColor.value = v.length === 4
|
||||
? `#${v[1]}${v[1]}${v[2]}${v[2]}${v[3]}${v[3]}`
|
||||
: v;
|
||||
this.onPickerLiveInput(this.popoverColor.value);
|
||||
}
|
||||
});
|
||||
requireEl<HTMLButtonElement>('popover-apply').addEventListener('click', () => this.confirmPicker());
|
||||
requireEl<HTMLButtonElement>('popover-cancel').addEventListener('click', () => this.closePicker(true));
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape' && !this.popover.classList.contains('hidden')) this.closePicker(true);
|
||||
});
|
||||
document.addEventListener('click', (e) => {
|
||||
if (this.popover.classList.contains('hidden')) return;
|
||||
const target = e.target as HTMLElement;
|
||||
if (this.popover.contains(target) || target === this.canvas || target.closest('.cat-card')) return;
|
||||
this.closePicker(true);
|
||||
});
|
||||
|
||||
this.revealBtn.addEventListener('click', () => this.reveal());
|
||||
this.resetBtn.addEventListener('click', () => this.resetColors());
|
||||
|
||||
this.compareToggle.addEventListener('click', (e) => {
|
||||
const btn = (e.target as HTMLElement).closest<HTMLElement>('.toggle-btn');
|
||||
if (!btn?.dataset.mode) return;
|
||||
this.setCompareMode(btn.dataset.mode as CompareMode);
|
||||
});
|
||||
this.closeResultBtn.addEventListener('click', () => this.resultModal.classList.add('hidden'));
|
||||
this.playAgainBtn.addEventListener('click', () => {
|
||||
this.resultModal.classList.add('hidden');
|
||||
this.resetColors();
|
||||
});
|
||||
}
|
||||
|
||||
private onCanvasHover(e: MouseEvent): void {
|
||||
const { x, y } = this.toCanvasCoords(e);
|
||||
const token = this.tokenAt(x, y);
|
||||
this.canvas.style.cursor = token ? 'pointer' : 'default';
|
||||
}
|
||||
|
||||
private onCanvasClick(e: MouseEvent): void {
|
||||
const { x, y } = this.toCanvasCoords(e);
|
||||
const token = this.tokenAt(x, y);
|
||||
this.fx.spawnRipple(x, y, token ? rgbToHex(this.categories[token.type as CategoryId].currentRgb) : '#a89984');
|
||||
if (!token) return;
|
||||
sound.playOpen();
|
||||
const id = token.type as CategoryId;
|
||||
this.setActiveCategory(id);
|
||||
this.openPicker(id, getCardRect(this.panel, id));
|
||||
}
|
||||
|
||||
private toCanvasCoords(e: MouseEvent): { x: number; y: number } {
|
||||
const rect = this.canvas.getBoundingClientRect();
|
||||
const scaleX = this.width / rect.width;
|
||||
const scaleY = this.height / rect.height;
|
||||
return { x: (e.clientX - rect.left) * scaleX, y: (e.clientY - rect.top) * scaleY };
|
||||
}
|
||||
|
||||
private tokenAt(px: number, py: number): Token | null {
|
||||
const col = Math.floor((px - GUTTER - PAD) / this.charWidth);
|
||||
const line = Math.floor((py - PAD) / LINE_HEIGHT);
|
||||
if (line < 0 || col < 0) return null;
|
||||
for (const t of this.tokens) {
|
||||
if (t.type === 'whitespace' || t.type === 'newline') continue;
|
||||
if (t.line === line && col >= t.col && col < t.col + t.text.length) return t;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ---------- category / picker ----------
|
||||
|
||||
private setActiveCategory(id: CategoryId): void {
|
||||
this.activeCategory = id;
|
||||
setActiveCard(this.panel, id);
|
||||
}
|
||||
|
||||
private openPicker(id: CategoryId, anchorRect: DOMRect): void {
|
||||
const cat = this.categories[id];
|
||||
this.pickerOrigin = { ...cat.currentRgb };
|
||||
const startHex = cat.assignedHex || rgbToHex(cat.currentRgb);
|
||||
this.popoverLabel.textContent = `${cat.icon} ${cat.label}`;
|
||||
this.popoverColor.value = startHex;
|
||||
this.popoverHex.value = startHex;
|
||||
this.popover.classList.remove('hidden');
|
||||
|
||||
const pw = 220;
|
||||
const ph = 160;
|
||||
let left = anchorRect.left;
|
||||
let top = anchorRect.bottom + 10;
|
||||
if (left + pw > window.innerWidth - 10) left = window.innerWidth - pw - 10;
|
||||
if (top + ph > window.innerHeight - 10) top = anchorRect.top - ph - 10;
|
||||
this.popover.style.left = `${Math.max(10, left)}px`;
|
||||
this.popover.style.top = `${Math.max(10, top)}px`;
|
||||
this.popoverHex.focus();
|
||||
this.popoverHex.select();
|
||||
}
|
||||
|
||||
private onPickerLiveInput(hex: string): void {
|
||||
if (!this.activeCategory) return;
|
||||
this.popoverHex.value = hex;
|
||||
const cat = this.categories[this.activeCategory];
|
||||
cat.currentRgb = hexToRgb(hex);
|
||||
cat.transitionStart = 0;
|
||||
}
|
||||
|
||||
private confirmPicker(): void {
|
||||
if (!this.activeCategory) return;
|
||||
this.applyColor(this.activeCategory, this.popoverColor.value);
|
||||
this.closePicker(false);
|
||||
}
|
||||
|
||||
private closePicker(cancelled: boolean): void {
|
||||
if (cancelled && this.activeCategory) {
|
||||
const cat = this.categories[this.activeCategory];
|
||||
cat.currentRgb = cat.assignedHex ? hexToRgb(cat.assignedHex) : this.pickerOrigin ?? cat.currentRgb;
|
||||
}
|
||||
this.popover.classList.add('hidden');
|
||||
setActiveCard(this.panel, null);
|
||||
this.activeCategory = null;
|
||||
}
|
||||
|
||||
private applyColor(id: CategoryId, hex: string): void {
|
||||
const cat = this.categories[id];
|
||||
cat.assignedHex = hex;
|
||||
cat.fromRgb = { ...cat.currentRgb };
|
||||
cat.toRgb = hexToRgb(hex);
|
||||
cat.transitionStart = this.lastTime;
|
||||
|
||||
markCategoryAssigned(this.panel, id, hex);
|
||||
|
||||
for (const c of this.tokenCentersFor(id, 10)) this.fx.spawnBurst(c.x, c.y, hex, 10);
|
||||
sound.playApply();
|
||||
this.updateProgress();
|
||||
}
|
||||
|
||||
private tokenCentersFor(id: CategoryId, max: number): { x: number; y: number }[] {
|
||||
const matches = this.tokens.filter((t) => t.type === id);
|
||||
const sample: { x: number; y: number }[] = [];
|
||||
const step = Math.max(1, Math.floor(matches.length / max));
|
||||
for (let i = 0; i < matches.length && sample.length < max; i += step) {
|
||||
const t = matches[i];
|
||||
sample.push({
|
||||
x: GUTTER + PAD + (t.col + t.text.length / 2) * this.charWidth,
|
||||
y: PAD + t.line * LINE_HEIGHT + LINE_HEIGHT / 2,
|
||||
});
|
||||
}
|
||||
return sample;
|
||||
}
|
||||
|
||||
private updateProgress(): void {
|
||||
const total = CATEGORY_META.length;
|
||||
const done = CATEGORY_META.filter((d) => this.categories[d.id].assignedHex).length;
|
||||
this.progressFill.style.width = `${(done / total) * 100}%`;
|
||||
this.progressLabel.textContent = `${done} / ${total} TOKEN KINDS THEMED`;
|
||||
this.revealBtn.disabled = done < total;
|
||||
}
|
||||
|
||||
private resetColors(): void {
|
||||
for (const def of CATEGORY_META) {
|
||||
const cat = this.categories[def.id];
|
||||
cat.assignedHex = null;
|
||||
cat.fromRgb = { ...cat.currentRgb };
|
||||
cat.toRgb = { ...(def.id === 'background' ? UNSET_BG_RGB : UNSET_FG_RGB) };
|
||||
cat.transitionStart = this.lastTime;
|
||||
}
|
||||
this.compareMode = 'yours';
|
||||
this.renderPanel();
|
||||
this.updateProgress();
|
||||
this.fx.clear();
|
||||
}
|
||||
|
||||
private setCompareMode(mode: CompareMode): void {
|
||||
if (mode === this.compareMode) return;
|
||||
this.compareMode = mode;
|
||||
for (const btn of Array.from(this.compareToggle.children)) {
|
||||
btn.classList.toggle('active', (btn as HTMLElement).dataset.mode === mode);
|
||||
}
|
||||
for (const def of CATEGORY_META) {
|
||||
const cat = this.categories[def.id];
|
||||
const target = mode === 'original' ? cat.actualHex : cat.assignedHex;
|
||||
if (!target) continue;
|
||||
cat.fromRgb = { ...cat.currentRgb };
|
||||
cat.toRgb = hexToRgb(target);
|
||||
cat.transitionStart = this.lastTime;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- scoring / reveal ----------
|
||||
|
||||
private reveal(): void {
|
||||
const { overall, rows } = computeMatchResult(this.categories);
|
||||
|
||||
renderBreakdown(this.breakdownEl, rows);
|
||||
renderScoreMeter(this.scoreMeter, this.scoreNumber, overall);
|
||||
this.scoreVerdict.textContent = verdictFor(overall);
|
||||
|
||||
this.resultModal.classList.remove('hidden');
|
||||
sound.playReveal(overall);
|
||||
if (overall >= 75) this.fx.spawnConfetti(160, this.width);
|
||||
}
|
||||
|
||||
// ---------- render loop ----------
|
||||
|
||||
private loop(t: number): void {
|
||||
const dt = t - this.lastTime;
|
||||
this.lastTime = t;
|
||||
this.tickTransitions(t);
|
||||
this.fx.update(dt);
|
||||
this.draw(t);
|
||||
requestAnimationFrame((next) => this.loop(next));
|
||||
}
|
||||
|
||||
private tickTransitions(now: number): void {
|
||||
for (const def of CATEGORY_META) {
|
||||
const cat = this.categories[def.id];
|
||||
if (!cat.transitionStart) continue;
|
||||
const p = Math.min(1, (now - cat.transitionStart) / TRANSITION_MS);
|
||||
cat.currentRgb = lerpRgb(cat.fromRgb, cat.toRgb, easeOutCubic(p));
|
||||
if (p >= 1) cat.transitionStart = 0;
|
||||
}
|
||||
}
|
||||
|
||||
private draw(now: number): void {
|
||||
drawFrame({
|
||||
ctx: this.ctx,
|
||||
width: this.width,
|
||||
height: this.height,
|
||||
charWidth: this.charWidth,
|
||||
lineCount: this.lineCount,
|
||||
tokens: this.tokens,
|
||||
categories: this.categories,
|
||||
activeCategory: this.activeCategory,
|
||||
particles: this.fx,
|
||||
}, now);
|
||||
}
|
||||
}
|
||||
17
frontend/src/game/layoutConstants.ts
Normal file
17
frontend/src/game/layoutConstants.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
// Layout/animation constants shared by the game renderer, the game
|
||||
// controller, and the pre-game theme preview.
|
||||
|
||||
import type { RGB } from '../types';
|
||||
|
||||
export const FONT_STACK = "'JetBrains Mono','Fira Code',ui-monospace,SFMono-Regular,Consolas,monospace";
|
||||
|
||||
export const FONT_SIZE = 17;
|
||||
export const LINE_HEIGHT = 26;
|
||||
export const GUTTER = 46;
|
||||
export const PAD = 24;
|
||||
|
||||
export const UNSET_BG_RGB: RGB = { r: 48, g: 44, b: 42 };
|
||||
export const UNSET_FG_RGB: RGB = { r: 168, g: 153, b: 132 };
|
||||
|
||||
export const TRANSITION_MS = 550;
|
||||
export const METER_BLOCKS = 20;
|
||||
55
frontend/src/game/panelView.ts
Normal file
55
frontend/src/game/panelView.ts
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
import { CATEGORY_META } from '../data/themes';
|
||||
import type { CategoryId, CategoryStateMap } from '../types';
|
||||
|
||||
function cardFor(panel: HTMLElement, id: CategoryId): HTMLElement {
|
||||
const card = panel.querySelector<HTMLElement>(`[data-id="${id}"]`);
|
||||
if (!card) throw new Error(`Missing category card for "${id}"`);
|
||||
return card;
|
||||
}
|
||||
|
||||
/** (Re)builds the category cards grid from scratch, wiring each card's
|
||||
* click straight back to the caller so this module stays DOM-only. */
|
||||
export function renderCategoryPanel(
|
||||
panel: HTMLElement,
|
||||
categories: CategoryStateMap,
|
||||
onSelect: (id: CategoryId, card: HTMLElement) => void,
|
||||
): void {
|
||||
panel.innerHTML = '';
|
||||
for (const def of CATEGORY_META) {
|
||||
const count = categories[def.id].count;
|
||||
const card = document.createElement('button');
|
||||
card.type = 'button';
|
||||
card.className = 'cat-card';
|
||||
card.dataset.id = def.id;
|
||||
card.innerHTML = `
|
||||
<span class="cat-swatch" data-swatch></span>
|
||||
<span class="cat-info">
|
||||
<span class="cat-label">${def.icon} ${def.label}</span>
|
||||
<span class="cat-count">${count || '1'} token${count === 1 ? '' : 's'}</span>
|
||||
</span>`;
|
||||
card.addEventListener('click', () => onSelect(def.id, card));
|
||||
panel.appendChild(card);
|
||||
}
|
||||
}
|
||||
|
||||
export function setActiveCard(panel: HTMLElement, id: CategoryId | null): void {
|
||||
for (const card of Array.from(panel.children)) {
|
||||
card.classList.toggle('active', (card as HTMLElement).dataset.id === id);
|
||||
}
|
||||
}
|
||||
|
||||
/** Paints a card's swatch, bounces it, and marks the card as done. */
|
||||
export function markCategoryAssigned(panel: HTMLElement, id: CategoryId, hex: string): void {
|
||||
const card = cardFor(panel, id);
|
||||
const swatch = card.querySelector<HTMLElement>('[data-swatch]');
|
||||
if (!swatch) return;
|
||||
swatch.style.background = hex;
|
||||
swatch.classList.remove('pop');
|
||||
void swatch.offsetWidth;
|
||||
swatch.classList.add('pop');
|
||||
card.classList.add('assigned');
|
||||
}
|
||||
|
||||
export function getCardRect(panel: HTMLElement, id: CategoryId): DOMRect {
|
||||
return cardFor(panel, id).getBoundingClientRect();
|
||||
}
|
||||
66
frontend/src/game/renderer.ts
Normal file
66
frontend/src/game/renderer.ts
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
import { rgbToHex } from '../engine/colorUtils';
|
||||
import type { ParticleSystem } from '../engine/particles';
|
||||
import type { CategoryId, CategoryStateMap, Token } from '../types';
|
||||
import { FONT_SIZE, FONT_STACK, GUTTER, LINE_HEIGHT, PAD } from './layoutConstants';
|
||||
|
||||
/** Everything one frame of the code canvas needs to draw itself. Kept
|
||||
* separate from `ThemeGuessGame` so rendering has no DOM/event concerns. */
|
||||
export interface RenderState {
|
||||
ctx: CanvasRenderingContext2D;
|
||||
width: number;
|
||||
height: number;
|
||||
charWidth: number;
|
||||
lineCount: number;
|
||||
tokens: Token[];
|
||||
categories: CategoryStateMap;
|
||||
activeCategory: CategoryId | null;
|
||||
particles: ParticleSystem;
|
||||
}
|
||||
|
||||
export function drawFrame(state: RenderState, now: number): void {
|
||||
const { ctx } = state;
|
||||
ctx.clearRect(0, 0, state.width, state.height);
|
||||
|
||||
const bg = state.categories.background;
|
||||
ctx.fillStyle = rgbToHex(bg.currentRgb);
|
||||
ctx.globalAlpha = bg.assignedHex ? 1 : 0.85 + 0.1 * Math.sin(now / 500 + bg.pulsePhase);
|
||||
ctx.fillRect(0, 0, state.width, state.height);
|
||||
ctx.globalAlpha = 1;
|
||||
|
||||
ctx.fillStyle = 'rgba(0,0,0,.16)';
|
||||
ctx.fillRect(0, 0, GUTTER, state.height);
|
||||
|
||||
if (state.activeCategory) {
|
||||
const glowAlpha = 0.16 + 0.12 * Math.sin(now / 220);
|
||||
ctx.fillStyle = `rgba(255,255,255,${glowAlpha})`;
|
||||
for (const t of state.tokens) {
|
||||
if (t.type !== state.activeCategory) continue;
|
||||
const x = GUTTER + PAD + t.col * state.charWidth;
|
||||
const yTop = PAD + t.line * LINE_HEIGHT;
|
||||
ctx.fillRect(x - 3, yTop + 1, t.text.length * state.charWidth + 6, LINE_HEIGHT - 2);
|
||||
}
|
||||
}
|
||||
|
||||
ctx.fillStyle = '#665c54';
|
||||
ctx.font = `${FONT_SIZE - 3}px ${FONT_STACK}`;
|
||||
ctx.textBaseline = 'alphabetic';
|
||||
for (let l = 0; l < state.lineCount; l++) {
|
||||
ctx.fillText(String(l + 1).padStart(2, ' '), 8, PAD + l * LINE_HEIGHT + FONT_SIZE - 4);
|
||||
}
|
||||
|
||||
for (const t of state.tokens) {
|
||||
if (t.type === 'whitespace' || t.type === 'newline') continue;
|
||||
const cat = state.categories[t.type as CategoryId];
|
||||
const x = GUTTER + PAD + t.col * state.charWidth;
|
||||
const y = PAD + t.line * LINE_HEIGHT + FONT_SIZE - 4;
|
||||
ctx.font = t.type === 'comment'
|
||||
? `italic ${FONT_SIZE}px ${FONT_STACK}`
|
||||
: `${FONT_SIZE}px ${FONT_STACK}`;
|
||||
ctx.fillStyle = rgbToHex(cat.currentRgb);
|
||||
ctx.globalAlpha = cat.assignedHex ? 1 : 0.5 + 0.25 * Math.sin(now / 450 + cat.pulsePhase);
|
||||
ctx.fillText(t.text, x, y);
|
||||
}
|
||||
ctx.globalAlpha = 1;
|
||||
|
||||
state.particles.draw(ctx);
|
||||
}
|
||||
73
frontend/src/game/resultView.ts
Normal file
73
frontend/src/game/resultView.ts
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
import { easeOutCubic } from '../engine/colorUtils';
|
||||
import { METER_BLOCKS } from './layoutConstants';
|
||||
import type { MatchResultRow } from '../types';
|
||||
|
||||
export function barColor(sim: number): string {
|
||||
if (sim < 40) return '#ff5555';
|
||||
if (sim < 65) return '#ffa657';
|
||||
if (sim < 82) return '#ffd23f';
|
||||
return '#5fd75f';
|
||||
}
|
||||
|
||||
export function verdictFor(score: number): string {
|
||||
if (score >= 92) return '🏆 PIXEL PERFECT!';
|
||||
if (score >= 80) return '🔥 SO CLOSE!';
|
||||
if (score >= 60) return '🌤️ GETTING WARM';
|
||||
if (score >= 40) return '🎭 BOLD REMIX';
|
||||
return '😅 NEW GAME +';
|
||||
}
|
||||
|
||||
/** Renders one row per category comparing the player's guess against the
|
||||
* real color, each bar animating in with a staggered fade. */
|
||||
export function renderBreakdown(container: HTMLElement, rows: MatchResultRow[]): void {
|
||||
container.innerHTML = '';
|
||||
rows.forEach((row, i) => {
|
||||
const el = document.createElement('div');
|
||||
el.className = 'breakdown-row';
|
||||
el.style.animationDelay = `${i * 60}ms`;
|
||||
el.innerHTML = `
|
||||
<div class="swatch-pair">
|
||||
<span style="background:${row.guess}"></span>
|
||||
<span style="background:${row.actualHex}"></span>
|
||||
</div>
|
||||
<div class="breakdown-info">
|
||||
<div class="breakdown-label">${row.icon} ${row.label}</div>
|
||||
<div class="breakdown-bar"><div class="breakdown-bar-fill"></div></div>
|
||||
</div>
|
||||
<div class="breakdown-pct">${Math.round(row.sim)}%</div>`;
|
||||
container.appendChild(el);
|
||||
requestAnimationFrame(() => {
|
||||
const fill = el.querySelector<HTMLElement>('.breakdown-bar-fill');
|
||||
if (!fill) return;
|
||||
fill.style.width = `${row.sim}%`;
|
||||
fill.style.background = barColor(row.sim);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/** Builds the pixel-block score meter and animates it, and the big
|
||||
* percentage readout, up to the final overall score together. */
|
||||
export function renderScoreMeter(meterEl: HTMLElement, numberEl: HTMLElement, overall: number): void {
|
||||
meterEl.innerHTML = '';
|
||||
const litColor = barColor(overall);
|
||||
for (let i = 0; i < METER_BLOCKS; i++) {
|
||||
const b = document.createElement('span');
|
||||
b.className = 'meter-block';
|
||||
b.style.setProperty('--lit-color', litColor);
|
||||
meterEl.appendChild(b);
|
||||
}
|
||||
const blocks = meterEl.children;
|
||||
const targetLit = Math.round((overall / 100) * METER_BLOCKS);
|
||||
|
||||
const start = performance.now();
|
||||
const duration = 1100;
|
||||
const animate = (t: number) => {
|
||||
const p = Math.min(1, (t - start) / duration);
|
||||
const eased = easeOutCubic(p);
|
||||
numberEl.textContent = `${Math.round(overall * eased)}%`;
|
||||
const lit = Math.round(targetLit * eased);
|
||||
for (let i = 0; i < blocks.length; i++) blocks[i].classList.toggle('lit', i < lit);
|
||||
if (p < 1) requestAnimationFrame(animate);
|
||||
};
|
||||
requestAnimationFrame(animate);
|
||||
}
|
||||
21
frontend/src/game/scoring.ts
Normal file
21
frontend/src/game/scoring.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
import { similarityFromHex } from '../engine/colorUtils';
|
||||
import { CATEGORY_META } from '../data/themes';
|
||||
import type { CategoryStateMap, MatchResult } from '../types';
|
||||
|
||||
/** Weighted-by-frequency average of how close each guessed color landed
|
||||
* to the theme's real color, in perceptual (CIELAB) distance. */
|
||||
export function computeMatchResult(categories: CategoryStateMap): MatchResult {
|
||||
let totalWeight = 0;
|
||||
let weightedSum = 0;
|
||||
const rows: MatchResult['rows'] = [];
|
||||
|
||||
for (const def of CATEGORY_META) {
|
||||
const cat = categories[def.id];
|
||||
const sim = similarityFromHex(cat.assignedHex, cat.actualHex);
|
||||
totalWeight += cat.weight;
|
||||
weightedSum += sim * cat.weight;
|
||||
rows.push({ ...def, guess: cat.assignedHex, actualHex: cat.actualHex, sim });
|
||||
}
|
||||
|
||||
return { overall: Math.round(weightedSum / totalWeight), rows };
|
||||
}
|
||||
56
frontend/src/main.ts
Normal file
56
frontend/src/main.ts
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
import './style.css';
|
||||
import { ThemeGuessGame } from './game/ThemeGuessGame';
|
||||
import { THEMES } from './data/themes';
|
||||
import { sound } from './engine/sound';
|
||||
import { createThemeGrid } from './ui/themeGrid';
|
||||
import { runThemePreview } from './ui/previewFlow';
|
||||
import type { ThemeId } from './types';
|
||||
|
||||
function requireEl<T extends HTMLElement>(id: string): T {
|
||||
const el = document.getElementById(id);
|
||||
if (!el) throw new Error(`Missing required element #${id}`);
|
||||
return el as T;
|
||||
}
|
||||
|
||||
const introModal = requireEl<HTMLElement>('intro-modal');
|
||||
const themeGridEl = requireEl<HTMLElement>('theme-grid');
|
||||
const startBtn = requireEl<HTMLButtonElement>('start-btn');
|
||||
const helpBtn = requireEl<HTMLButtonElement>('help-btn');
|
||||
const muteBtn = requireEl<HTMLButtonElement>('mute-btn');
|
||||
const themeNameBadge = requireEl<HTMLElement>('theme-name-badge');
|
||||
const changeThemeBtn = requireEl<HTMLButtonElement>('change-theme-btn');
|
||||
const resultModal = requireEl<HTMLElement>('result-modal');
|
||||
|
||||
const previewElements = {
|
||||
overlay: requireEl<HTMLElement>('preview-overlay'),
|
||||
countdown: requireEl<HTMLElement>('preview-countdown'),
|
||||
caption: requireEl<HTMLElement>('preview-caption'),
|
||||
canvas: requireEl<HTMLCanvasElement>('preview-canvas'),
|
||||
};
|
||||
|
||||
let game: ThemeGuessGame | null = null;
|
||||
|
||||
const themeGrid = createThemeGrid(themeGridEl, 'gruvbox', () => sound.playOpen());
|
||||
|
||||
function startWithSelectedTheme(): void {
|
||||
const themeId: ThemeId = themeGrid.selected;
|
||||
introModal.classList.add('hidden');
|
||||
runThemePreview(previewElements, themeId, () => {
|
||||
if (game) game.setTheme(themeId);
|
||||
else game = new ThemeGuessGame(themeId);
|
||||
themeNameBadge.textContent = THEMES[themeId].name;
|
||||
sound.playApply();
|
||||
}, () => sound.playOpen());
|
||||
}
|
||||
|
||||
startBtn.addEventListener('click', startWithSelectedTheme);
|
||||
helpBtn.addEventListener('click', () => introModal.classList.remove('hidden'));
|
||||
changeThemeBtn.addEventListener('click', () => {
|
||||
resultModal.classList.add('hidden');
|
||||
introModal.classList.remove('hidden');
|
||||
});
|
||||
|
||||
muteBtn.addEventListener('click', () => {
|
||||
const muted = sound.toggleMute();
|
||||
muteBtn.textContent = muted ? '🔇' : '🔊';
|
||||
});
|
||||
|
|
@ -1,22 +1,21 @@
|
|||
import { tokenize } from './tokenizer.js';
|
||||
import { THEMES, PREVIEW_SAMPLE } from './theme.js';
|
||||
import { tokenize } from '../engine/tokenizer';
|
||||
import { THEMES, PREVIEW_SAMPLE } from '../data/themes';
|
||||
import type { ThemeId } from '../types';
|
||||
import { FONT_STACK } from '../game/layoutConstants';
|
||||
|
||||
const FONT_SIZE = 16;
|
||||
const LINE_HEIGHT = 24;
|
||||
const PAD = 20;
|
||||
|
||||
function fontStack() {
|
||||
return "'JetBrains Mono','Fira Code',ui-monospace,SFMono-Regular,Consolas,monospace";
|
||||
}
|
||||
|
||||
// Draws the (different, intentionally incomplete) preview snippet fully
|
||||
// colored with a theme's *real* colors — no guessing, just a flash of
|
||||
// what the target theme actually looks like.
|
||||
export function renderThemePreview(canvas, themeId) {
|
||||
export function renderThemePreview(canvas: HTMLCanvasElement, themeId: ThemeId): void {
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
const tokens = tokenize(PREVIEW_SAMPLE);
|
||||
|
||||
ctx.font = `${FONT_SIZE}px ${fontStack()}`;
|
||||
ctx.font = `${FONT_SIZE}px ${FONT_STACK}`;
|
||||
const charWidth = ctx.measureText('M').width;
|
||||
|
||||
let lineCount = 1;
|
||||
|
|
@ -46,9 +45,9 @@ export function renderThemePreview(canvas, themeId) {
|
|||
const x = PAD + t.col * charWidth;
|
||||
const y = PAD + t.line * LINE_HEIGHT + FONT_SIZE - 3;
|
||||
ctx.font = t.type === 'comment'
|
||||
? `italic ${FONT_SIZE}px ${fontStack()}`
|
||||
: `${FONT_SIZE}px ${fontStack()}`;
|
||||
ctx.fillStyle = theme.colors[t.type] || theme.colors.punctuation;
|
||||
? `italic ${FONT_SIZE}px ${FONT_STACK}`
|
||||
: `${FONT_SIZE}px ${FONT_STACK}`;
|
||||
ctx.fillStyle = theme.colors[t.type as keyof typeof theme.colors] ?? theme.colors.punctuation;
|
||||
ctx.fillText(t.text, x, y);
|
||||
}
|
||||
}
|
||||
99
frontend/src/types.ts
Normal file
99
frontend/src/types.ts
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
// Shared domain types for the tokenizer, theme data, and game engine.
|
||||
|
||||
/** Every lexical bucket the tokenizer can assign, including the two
|
||||
* layout-only kinds ('whitespace', 'newline') and the transient
|
||||
* pre-classification kind ('identifier') that never survives past
|
||||
* `tokenize()`. */
|
||||
export type TokenType =
|
||||
| 'keyword'
|
||||
| 'string'
|
||||
| 'number'
|
||||
| 'constant'
|
||||
| 'function'
|
||||
| 'variable'
|
||||
| 'property'
|
||||
| 'type'
|
||||
| 'operator'
|
||||
| 'punctuation'
|
||||
| 'comment'
|
||||
| 'whitespace'
|
||||
| 'newline'
|
||||
| 'identifier';
|
||||
|
||||
export interface Token {
|
||||
type: TokenType;
|
||||
text: string;
|
||||
/** 0-indexed source line, assigned during tokenization. */
|
||||
line: number;
|
||||
/** 0-indexed character column within its line. */
|
||||
col: number;
|
||||
}
|
||||
|
||||
/** The token kinds the player can actually paint a color onto. Mirrors
|
||||
* `TokenType` minus the layout-only and transient members, plus
|
||||
* 'background', which isn't a token at all but is themed the same way. */
|
||||
export type CategoryId =
|
||||
| 'background'
|
||||
| 'keyword'
|
||||
| 'string'
|
||||
| 'number'
|
||||
| 'constant'
|
||||
| 'function'
|
||||
| 'variable'
|
||||
| 'property'
|
||||
| 'type'
|
||||
| 'operator'
|
||||
| 'punctuation'
|
||||
| 'comment';
|
||||
|
||||
export interface CategoryMeta {
|
||||
id: CategoryId;
|
||||
label: string;
|
||||
icon: string;
|
||||
}
|
||||
|
||||
export type ThemeColors = Record<CategoryId, string>;
|
||||
|
||||
export interface ThemeDefinition {
|
||||
name: string;
|
||||
colors: ThemeColors;
|
||||
}
|
||||
|
||||
export type ThemeId = string;
|
||||
export type Themes = Record<ThemeId, ThemeDefinition>;
|
||||
|
||||
export interface RGB {
|
||||
r: number;
|
||||
g: number;
|
||||
b: number;
|
||||
}
|
||||
|
||||
/** Runtime state for one paintable category: its real target color, the
|
||||
* player's guess, and the animated color currently on screen. */
|
||||
export interface CategoryState extends CategoryMeta {
|
||||
actualHex: string;
|
||||
/** Scoring weight, derived from how often this kind appears in the code. */
|
||||
weight: number;
|
||||
count: number;
|
||||
assignedHex: string | null;
|
||||
currentRgb: RGB;
|
||||
fromRgb: RGB;
|
||||
toRgb: RGB;
|
||||
/** `performance.now()` timestamp a color transition started, or 0 when idle. */
|
||||
transitionStart: number;
|
||||
/** Per-category phase offset so "unset" pulse animations don't sync up. */
|
||||
pulsePhase: number;
|
||||
}
|
||||
|
||||
export type CategoryStateMap = Record<CategoryId, CategoryState>;
|
||||
|
||||
export interface MatchResultRow extends CategoryMeta {
|
||||
guess: string | null;
|
||||
actualHex: string;
|
||||
sim: number;
|
||||
}
|
||||
|
||||
export interface MatchResult {
|
||||
overall: number;
|
||||
rows: MatchResultRow[];
|
||||
}
|
||||
49
frontend/src/ui/previewFlow.ts
Normal file
49
frontend/src/ui/previewFlow.ts
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
import { renderThemePreview } from '../preview/themePreview';
|
||||
import { THEMES } from '../data/themes';
|
||||
import type { ThemeId } from '../types';
|
||||
|
||||
export interface PreviewFlowElements {
|
||||
overlay: HTMLElement;
|
||||
countdown: HTMLElement;
|
||||
caption: HTMLElement;
|
||||
canvas: HTMLCanvasElement;
|
||||
}
|
||||
|
||||
const COUNTDOWN_SECONDS = 5;
|
||||
|
||||
function bumpCountdown(el: HTMLElement, text: string): void {
|
||||
el.textContent = text;
|
||||
el.classList.remove('tick');
|
||||
void el.offsetWidth;
|
||||
el.classList.add('tick');
|
||||
}
|
||||
|
||||
/** Flashes a different, real-colored snippet of the chosen theme for a
|
||||
* few seconds before handing control to `onDone`. `onTick` fires once per
|
||||
* second (including the first frame) so the caller can layer in sound. */
|
||||
export function runThemePreview(
|
||||
elements: PreviewFlowElements,
|
||||
themeId: ThemeId,
|
||||
onDone: () => void,
|
||||
onTick?: () => void,
|
||||
): void {
|
||||
renderThemePreview(elements.canvas, themeId);
|
||||
elements.caption.textContent = `LOADING: ${THEMES[themeId].name.toUpperCase()}`;
|
||||
elements.overlay.classList.remove('hidden');
|
||||
|
||||
let remaining = COUNTDOWN_SECONDS;
|
||||
bumpCountdown(elements.countdown, String(remaining));
|
||||
onTick?.();
|
||||
|
||||
const timer = window.setInterval(() => {
|
||||
remaining -= 1;
|
||||
if (remaining <= 0) {
|
||||
window.clearInterval(timer);
|
||||
elements.overlay.classList.add('hidden');
|
||||
onDone();
|
||||
return;
|
||||
}
|
||||
bumpCountdown(elements.countdown, String(remaining));
|
||||
onTick?.();
|
||||
}, 1000);
|
||||
}
|
||||
50
frontend/src/ui/themeGrid.ts
Normal file
50
frontend/src/ui/themeGrid.ts
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
import { THEMES } from '../data/themes';
|
||||
import type { ThemeId } from '../types';
|
||||
|
||||
const SWATCH_KEYS = ['keyword', 'string', 'function', 'type'] as const;
|
||||
|
||||
export interface ThemeGrid {
|
||||
readonly selected: ThemeId;
|
||||
setSelected(id: ThemeId): void;
|
||||
}
|
||||
|
||||
/** Renders the selectable theme cards into `container` and keeps them in
|
||||
* sync with the current selection. `onSelect` fires on every click,
|
||||
* including re-clicking the already-selected card. */
|
||||
export function createThemeGrid(
|
||||
container: HTMLElement,
|
||||
initialId: ThemeId,
|
||||
onSelect: (id: ThemeId) => void,
|
||||
): ThemeGrid {
|
||||
let selected = initialId;
|
||||
|
||||
function render(): void {
|
||||
container.innerHTML = '';
|
||||
for (const id in THEMES) {
|
||||
const theme = THEMES[id];
|
||||
const card = document.createElement('button');
|
||||
card.type = 'button';
|
||||
card.className = `theme-card${id === selected ? ' selected' : ''}`;
|
||||
card.dataset.id = id;
|
||||
card.style.setProperty('--card-bg', theme.colors.background);
|
||||
card.innerHTML = `
|
||||
<span class="theme-swatches">
|
||||
${SWATCH_KEYS.map((k) => `<i style="background:${theme.colors[k]}"></i>`).join('')}
|
||||
</span>
|
||||
<span class="theme-name">${theme.name}</span>`;
|
||||
card.addEventListener('click', () => {
|
||||
selected = id;
|
||||
render();
|
||||
onSelect(id);
|
||||
});
|
||||
container.appendChild(card);
|
||||
}
|
||||
}
|
||||
|
||||
render();
|
||||
|
||||
return {
|
||||
get selected() { return selected; },
|
||||
setSelected(id: ThemeId) { selected = id; render(); },
|
||||
};
|
||||
}
|
||||
21
frontend/tsconfig.json
Normal file
21
frontend/tsconfig.json
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"module": "ESNext",
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"skipLibCheck": true,
|
||||
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
7
frontend/vite.config.ts
Normal file
7
frontend/vite.config.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import { defineConfig } from 'vite';
|
||||
|
||||
export default defineConfig({
|
||||
server: {
|
||||
port: 5173,
|
||||
},
|
||||
});
|
||||
510
src/game.js
510
src/game.js
|
|
@ -1,510 +0,0 @@
|
|||
import { tokenize } from './tokenizer.js';
|
||||
import { CATEGORY_META, THEMES, CODE_SAMPLE } from './theme.js';
|
||||
import { hexToRgb, rgbToHex, lerpRgb, easeOutCubic, isValidHex, similarityFromHex } from './colorUtils.js';
|
||||
import { ParticleSystem } from './particles.js';
|
||||
import { sound } from './sound.js';
|
||||
|
||||
const FONT_SIZE = 17;
|
||||
const LINE_HEIGHT = 26;
|
||||
const GUTTER = 46;
|
||||
const PAD = 24;
|
||||
const UNSET_BG_RGB = { r: 48, g: 44, b: 42 };
|
||||
const UNSET_FG_RGB = { r: 168, g: 153, b: 132 };
|
||||
const TRANSITION_MS = 550;
|
||||
const METER_BLOCKS = 20;
|
||||
|
||||
function barColor(sim) {
|
||||
if (sim < 40) return '#ff5555';
|
||||
if (sim < 65) return '#ffa657';
|
||||
if (sim < 82) return '#ffd23f';
|
||||
return '#5fd75f';
|
||||
}
|
||||
|
||||
function verdictFor(score) {
|
||||
if (score >= 92) return '🏆 PIXEL PERFECT!';
|
||||
if (score >= 80) return '🔥 SO CLOSE!';
|
||||
if (score >= 60) return '🌤️ GETTING WARM';
|
||||
if (score >= 40) return '🎭 BOLD REMIX';
|
||||
return '😅 NEW GAME +';
|
||||
}
|
||||
|
||||
export class ThemeGuessGame {
|
||||
constructor(themeId) {
|
||||
this.canvas = document.getElementById('code-canvas');
|
||||
this.ctx = this.canvas.getContext('2d');
|
||||
this.panel = document.getElementById('category-panel');
|
||||
this.popover = document.getElementById('picker-popover');
|
||||
this.popoverLabel = document.getElementById('popover-label');
|
||||
this.popoverColor = document.getElementById('popover-color');
|
||||
this.popoverHex = document.getElementById('popover-hex');
|
||||
this.progressFill = document.getElementById('progress-fill');
|
||||
this.progressLabel = document.getElementById('progress-label');
|
||||
this.revealBtn = document.getElementById('reveal-btn');
|
||||
this.resetBtn = document.getElementById('reset-btn');
|
||||
this.resultModal = document.getElementById('result-modal');
|
||||
this.scoreNumber = document.getElementById('score-number');
|
||||
this.scoreMeter = document.getElementById('score-meter');
|
||||
this.scoreVerdict = document.getElementById('score-verdict');
|
||||
this.breakdownEl = document.getElementById('score-breakdown');
|
||||
this.compareToggle = document.getElementById('compare-toggle');
|
||||
this.closeResultBtn = document.getElementById('close-result-btn');
|
||||
this.playAgainBtn = document.getElementById('play-again-btn');
|
||||
|
||||
this.themeId = themeId;
|
||||
this.fx = new ParticleSystem();
|
||||
this.activeCategory = null;
|
||||
this.compareMode = 'yours';
|
||||
this.lastTime = performance.now();
|
||||
|
||||
this.build();
|
||||
this.bindEvents();
|
||||
requestAnimationFrame(this.loop.bind(this));
|
||||
}
|
||||
|
||||
// ---------- setup ----------
|
||||
|
||||
build() {
|
||||
this.tokens = tokenize(CODE_SAMPLE);
|
||||
|
||||
this.lineCount = 1;
|
||||
this.maxCols = 0;
|
||||
let col = 0;
|
||||
for (const t of this.tokens) {
|
||||
if (t.type === 'newline') { this.lineCount++; col = 0; }
|
||||
else { col += t.text.length; this.maxCols = Math.max(this.maxCols, col); }
|
||||
}
|
||||
|
||||
const counts = {};
|
||||
for (const t of this.tokens) {
|
||||
if (t.type === 'whitespace' || t.type === 'newline') continue;
|
||||
counts[t.type] = (counts[t.type] || 0) + 1;
|
||||
}
|
||||
const maxCount = Math.max(1, ...Object.values(counts));
|
||||
|
||||
const theme = THEMES[this.themeId];
|
||||
this.categories = {};
|
||||
for (const def of CATEGORY_META) {
|
||||
const count = counts[def.id] || 0;
|
||||
const unset = def.id === 'background' ? UNSET_BG_RGB : UNSET_FG_RGB;
|
||||
this.categories[def.id] = {
|
||||
...def,
|
||||
actualHex: theme.colors[def.id],
|
||||
weight: def.id === 'background' ? maxCount : Math.max(count, 3),
|
||||
count,
|
||||
assignedHex: null,
|
||||
currentRgb: { ...unset },
|
||||
fromRgb: { ...unset },
|
||||
toRgb: { ...unset },
|
||||
transitionStart: 0,
|
||||
pulsePhase: Math.random() * Math.PI * 2,
|
||||
};
|
||||
}
|
||||
|
||||
this.setupCanvasSize();
|
||||
this.renderPanel();
|
||||
this.updateProgress();
|
||||
}
|
||||
|
||||
setupCanvasSize() {
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
this.ctx.font = `${FONT_SIZE}px ${this.fontStack()}`;
|
||||
this.charWidth = this.ctx.measureText('M').width;
|
||||
this.width = GUTTER + PAD * 2 + this.maxCols * this.charWidth;
|
||||
this.height = PAD * 2 + this.lineCount * LINE_HEIGHT;
|
||||
this.canvas.style.width = `${this.width}px`;
|
||||
this.canvas.style.height = `${this.height}px`;
|
||||
this.canvas.width = this.width * dpr;
|
||||
this.canvas.height = this.height * dpr;
|
||||
this.ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
}
|
||||
|
||||
fontStack() {
|
||||
return "'JetBrains Mono','Fira Code',ui-monospace,SFMono-Regular,Consolas,monospace";
|
||||
}
|
||||
|
||||
renderPanel() {
|
||||
this.panel.innerHTML = '';
|
||||
for (const def of CATEGORY_META) {
|
||||
const card = document.createElement('button');
|
||||
card.type = 'button';
|
||||
card.className = 'cat-card';
|
||||
card.dataset.id = def.id;
|
||||
card.innerHTML = `
|
||||
<span class="cat-swatch" data-swatch></span>
|
||||
<span class="cat-info">
|
||||
<span class="cat-label">${def.icon} ${def.label}</span>
|
||||
<span class="cat-count">${this.categories[def.id].count || '1'} token${this.categories[def.id].count === 1 ? '' : 's'}</span>
|
||||
</span>`;
|
||||
card.addEventListener('click', () => {
|
||||
this.setActiveCategory(def.id);
|
||||
this.openPicker(def.id, card.getBoundingClientRect());
|
||||
});
|
||||
this.panel.appendChild(card);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- theme switching ----------
|
||||
|
||||
setTheme(themeId) {
|
||||
this.themeId = themeId;
|
||||
const theme = THEMES[themeId];
|
||||
for (const def of CATEGORY_META) {
|
||||
const cat = this.categories[def.id];
|
||||
cat.actualHex = theme.colors[def.id];
|
||||
cat.assignedHex = null;
|
||||
const unset = def.id === 'background' ? UNSET_BG_RGB : UNSET_FG_RGB;
|
||||
cat.currentRgb = { ...unset };
|
||||
cat.fromRgb = { ...unset };
|
||||
cat.toRgb = { ...unset };
|
||||
cat.transitionStart = 0;
|
||||
}
|
||||
this.compareMode = 'yours';
|
||||
this.activeCategory = null;
|
||||
this.popover.classList.add('hidden');
|
||||
this.resultModal.classList.add('hidden');
|
||||
this.renderPanel();
|
||||
this.updateProgress();
|
||||
this.fx.clear();
|
||||
}
|
||||
|
||||
// ---------- events ----------
|
||||
|
||||
bindEvents() {
|
||||
this.canvas.addEventListener('click', (e) => this.onCanvasClick(e));
|
||||
this.canvas.addEventListener('mousemove', (e) => this.onCanvasHover(e));
|
||||
|
||||
this.popoverColor.addEventListener('input', () => this.onPickerLiveInput(this.popoverColor.value));
|
||||
this.popoverHex.addEventListener('input', () => {
|
||||
let v = this.popoverHex.value.trim();
|
||||
if (!v.startsWith('#')) v = `#${v}`;
|
||||
if (isValidHex(v)) {
|
||||
this.popoverColor.value = v.length === 4
|
||||
? `#${v[1]}${v[1]}${v[2]}${v[2]}${v[3]}${v[3]}`
|
||||
: v;
|
||||
this.onPickerLiveInput(this.popoverColor.value);
|
||||
}
|
||||
});
|
||||
document.getElementById('popover-apply').addEventListener('click', () => this.confirmPicker());
|
||||
document.getElementById('popover-cancel').addEventListener('click', () => this.closePicker(true));
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape' && !this.popover.classList.contains('hidden')) this.closePicker(true);
|
||||
});
|
||||
document.addEventListener('click', (e) => {
|
||||
if (this.popover.classList.contains('hidden')) return;
|
||||
if (this.popover.contains(e.target) || e.target === this.canvas || e.target.closest('.cat-card')) return;
|
||||
this.closePicker(true);
|
||||
});
|
||||
|
||||
this.revealBtn.addEventListener('click', () => this.reveal());
|
||||
this.resetBtn.addEventListener('click', () => this.resetColors());
|
||||
|
||||
this.compareToggle.addEventListener('click', (e) => {
|
||||
const btn = e.target.closest('.toggle-btn');
|
||||
if (!btn) return;
|
||||
this.setCompareMode(btn.dataset.mode);
|
||||
});
|
||||
this.closeResultBtn.addEventListener('click', () => this.resultModal.classList.add('hidden'));
|
||||
this.playAgainBtn.addEventListener('click', () => {
|
||||
this.resultModal.classList.add('hidden');
|
||||
this.resetColors();
|
||||
});
|
||||
}
|
||||
|
||||
onCanvasHover(e) {
|
||||
const { x, y } = this.toCanvasCoords(e);
|
||||
const token = this.tokenAt(x, y);
|
||||
this.canvas.style.cursor = token ? 'pointer' : 'default';
|
||||
}
|
||||
|
||||
onCanvasClick(e) {
|
||||
const { x, y } = this.toCanvasCoords(e);
|
||||
const token = this.tokenAt(x, y);
|
||||
this.fx.spawnRipple(x, y, token ? rgbToHex(this.categories[token.type].currentRgb) : '#a89984');
|
||||
if (!token) return;
|
||||
sound.playOpen();
|
||||
this.setActiveCategory(token.type);
|
||||
const card = this.panel.querySelector(`[data-id="${token.type}"]`);
|
||||
this.openPicker(token.type, card.getBoundingClientRect());
|
||||
}
|
||||
|
||||
toCanvasCoords(e) {
|
||||
const rect = this.canvas.getBoundingClientRect();
|
||||
const scaleX = this.width / rect.width;
|
||||
const scaleY = this.height / rect.height;
|
||||
return { x: (e.clientX - rect.left) * scaleX, y: (e.clientY - rect.top) * scaleY };
|
||||
}
|
||||
|
||||
tokenAt(px, py) {
|
||||
const col = Math.floor((px - GUTTER - PAD) / this.charWidth);
|
||||
const line = Math.floor((py - PAD) / LINE_HEIGHT);
|
||||
if (line < 0 || col < 0) return null;
|
||||
for (const t of this.tokens) {
|
||||
if (t.type === 'whitespace' || t.type === 'newline') continue;
|
||||
if (t.line === line && col >= t.col && col < t.col + t.text.length) return t;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ---------- category / picker ----------
|
||||
|
||||
setActiveCategory(id) {
|
||||
this.activeCategory = id;
|
||||
for (const card of this.panel.children) card.classList.toggle('active', card.dataset.id === id);
|
||||
}
|
||||
|
||||
openPicker(id, anchorRect) {
|
||||
const cat = this.categories[id];
|
||||
this.pickerOrigin = { ...cat.currentRgb };
|
||||
const startHex = cat.assignedHex || rgbToHex(cat.currentRgb);
|
||||
this.popoverLabel.textContent = `${cat.icon} ${cat.label}`;
|
||||
this.popoverColor.value = startHex;
|
||||
this.popoverHex.value = startHex;
|
||||
this.popover.classList.remove('hidden');
|
||||
|
||||
const pw = 220;
|
||||
const ph = 160;
|
||||
let left = anchorRect.left;
|
||||
let top = anchorRect.bottom + 10;
|
||||
if (left + pw > window.innerWidth - 10) left = window.innerWidth - pw - 10;
|
||||
if (top + ph > window.innerHeight - 10) top = anchorRect.top - ph - 10;
|
||||
this.popover.style.left = `${Math.max(10, left)}px`;
|
||||
this.popover.style.top = `${Math.max(10, top)}px`;
|
||||
this.popoverHex.focus();
|
||||
this.popoverHex.select();
|
||||
}
|
||||
|
||||
onPickerLiveInput(hex) {
|
||||
if (!this.activeCategory) return;
|
||||
this.popoverHex.value = hex;
|
||||
const cat = this.categories[this.activeCategory];
|
||||
cat.currentRgb = hexToRgb(hex);
|
||||
cat.transitionStart = 0;
|
||||
}
|
||||
|
||||
confirmPicker() {
|
||||
if (!this.activeCategory) return;
|
||||
const hex = this.popoverColor.value;
|
||||
this.applyColor(this.activeCategory, hex);
|
||||
this.closePicker(false);
|
||||
}
|
||||
|
||||
closePicker(cancelled) {
|
||||
if (cancelled && this.activeCategory) {
|
||||
const cat = this.categories[this.activeCategory];
|
||||
cat.currentRgb = cat.assignedHex ? hexToRgb(cat.assignedHex) : this.pickerOrigin;
|
||||
}
|
||||
this.popover.classList.add('hidden');
|
||||
for (const card of this.panel.children) card.classList.remove('active');
|
||||
this.activeCategory = null;
|
||||
}
|
||||
|
||||
applyColor(id, hex) {
|
||||
const cat = this.categories[id];
|
||||
cat.assignedHex = hex;
|
||||
cat.fromRgb = { ...cat.currentRgb };
|
||||
cat.toRgb = hexToRgb(hex);
|
||||
cat.transitionStart = this.lastTime;
|
||||
|
||||
const card = this.panel.querySelector(`[data-id="${id}"]`);
|
||||
const swatch = card.querySelector('[data-swatch]');
|
||||
swatch.style.background = hex;
|
||||
swatch.classList.remove('pop');
|
||||
void swatch.offsetWidth;
|
||||
swatch.classList.add('pop');
|
||||
card.classList.add('assigned');
|
||||
|
||||
const centers = this.tokenCentersFor(id, 10);
|
||||
for (const c of centers) this.fx.spawnBurst(c.x, c.y, hex, 10);
|
||||
sound.playApply();
|
||||
this.updateProgress();
|
||||
}
|
||||
|
||||
tokenCentersFor(id, max) {
|
||||
const matches = this.tokens.filter((t) => t.type === id);
|
||||
const sample = [];
|
||||
const step = Math.max(1, Math.floor(matches.length / max));
|
||||
for (let i = 0; i < matches.length && sample.length < max; i += step) {
|
||||
const t = matches[i];
|
||||
sample.push({
|
||||
x: GUTTER + PAD + (t.col + t.text.length / 2) * this.charWidth,
|
||||
y: PAD + t.line * LINE_HEIGHT + LINE_HEIGHT / 2,
|
||||
});
|
||||
}
|
||||
return sample;
|
||||
}
|
||||
|
||||
updateProgress() {
|
||||
const total = CATEGORY_META.length;
|
||||
const done = CATEGORY_META.filter((d) => this.categories[d.id].assignedHex).length;
|
||||
this.progressFill.style.width = `${(done / total) * 100}%`;
|
||||
this.progressLabel.textContent = `${done} / ${total} TOKEN KINDS THEMED`;
|
||||
this.revealBtn.disabled = done < total;
|
||||
}
|
||||
|
||||
resetColors() {
|
||||
for (const def of CATEGORY_META) {
|
||||
const cat = this.categories[def.id];
|
||||
cat.assignedHex = null;
|
||||
cat.fromRgb = { ...cat.currentRgb };
|
||||
cat.toRgb = { ...(def.id === 'background' ? UNSET_BG_RGB : UNSET_FG_RGB) };
|
||||
cat.transitionStart = this.lastTime;
|
||||
}
|
||||
this.compareMode = 'yours';
|
||||
this.renderPanel();
|
||||
this.updateProgress();
|
||||
this.fx.clear();
|
||||
}
|
||||
|
||||
setCompareMode(mode) {
|
||||
if (mode === this.compareMode) return;
|
||||
this.compareMode = mode;
|
||||
for (const btn of this.compareToggle.children) btn.classList.toggle('active', btn.dataset.mode === mode);
|
||||
for (const def of CATEGORY_META) {
|
||||
const cat = this.categories[def.id];
|
||||
const target = mode === 'original' ? cat.actualHex : cat.assignedHex;
|
||||
cat.fromRgb = { ...cat.currentRgb };
|
||||
cat.toRgb = hexToRgb(target);
|
||||
cat.transitionStart = this.lastTime;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- scoring / reveal ----------
|
||||
|
||||
reveal() {
|
||||
let totalWeight = 0;
|
||||
let weightedSum = 0;
|
||||
const rows = [];
|
||||
for (const def of CATEGORY_META) {
|
||||
const cat = this.categories[def.id];
|
||||
const sim = similarityFromHex(cat.assignedHex, cat.actualHex);
|
||||
totalWeight += cat.weight;
|
||||
weightedSum += sim * cat.weight;
|
||||
rows.push({ ...def, guess: cat.assignedHex, actualHex: cat.actualHex, sim });
|
||||
}
|
||||
const overall = Math.round(weightedSum / totalWeight);
|
||||
|
||||
this.renderResult(overall, rows);
|
||||
this.resultModal.classList.remove('hidden');
|
||||
sound.playReveal(overall);
|
||||
if (overall >= 75) this.fx.spawnConfetti(160, this.width);
|
||||
}
|
||||
|
||||
renderResult(overall, rows) {
|
||||
this.breakdownEl.innerHTML = '';
|
||||
rows.forEach((row, i) => {
|
||||
const el = document.createElement('div');
|
||||
el.className = 'breakdown-row';
|
||||
el.style.animationDelay = `${i * 60}ms`;
|
||||
el.innerHTML = `
|
||||
<div class="swatch-pair">
|
||||
<span style="background:${row.guess}"></span>
|
||||
<span style="background:${row.actualHex}"></span>
|
||||
</div>
|
||||
<div class="breakdown-info">
|
||||
<div class="breakdown-label">${row.icon} ${row.label}</div>
|
||||
<div class="breakdown-bar"><div class="breakdown-bar-fill"></div></div>
|
||||
</div>
|
||||
<div class="breakdown-pct">${Math.round(row.sim)}%</div>`;
|
||||
this.breakdownEl.appendChild(el);
|
||||
requestAnimationFrame(() => {
|
||||
el.querySelector('.breakdown-bar-fill').style.width = `${row.sim}%`;
|
||||
el.querySelector('.breakdown-bar-fill').style.background = barColor(row.sim);
|
||||
});
|
||||
});
|
||||
|
||||
this.scoreVerdict.textContent = verdictFor(overall);
|
||||
|
||||
this.scoreMeter.innerHTML = '';
|
||||
const litColor = barColor(overall);
|
||||
for (let i = 0; i < METER_BLOCKS; i++) {
|
||||
const b = document.createElement('span');
|
||||
b.className = 'meter-block';
|
||||
b.style.setProperty('--lit-color', litColor);
|
||||
this.scoreMeter.appendChild(b);
|
||||
}
|
||||
const blocks = this.scoreMeter.children;
|
||||
const targetLit = Math.round((overall / 100) * METER_BLOCKS);
|
||||
|
||||
const start = performance.now();
|
||||
const duration = 1100;
|
||||
const animate = (t) => {
|
||||
const p = Math.min(1, (t - start) / duration);
|
||||
const eased = easeOutCubic(p);
|
||||
const val = Math.round(overall * eased);
|
||||
this.scoreNumber.textContent = `${val}%`;
|
||||
const lit = Math.round(targetLit * eased);
|
||||
for (let i = 0; i < blocks.length; i++) blocks[i].classList.toggle('lit', i < lit);
|
||||
if (p < 1) requestAnimationFrame(animate);
|
||||
};
|
||||
requestAnimationFrame(animate);
|
||||
}
|
||||
|
||||
// ---------- render loop ----------
|
||||
|
||||
loop(t) {
|
||||
const dt = t - this.lastTime;
|
||||
this.lastTime = t;
|
||||
this.tickTransitions(t);
|
||||
this.fx.update(dt);
|
||||
this.draw(t);
|
||||
requestAnimationFrame(this.loop.bind(this));
|
||||
}
|
||||
|
||||
tickTransitions(now) {
|
||||
for (const def of CATEGORY_META) {
|
||||
const cat = this.categories[def.id];
|
||||
if (!cat.transitionStart) continue;
|
||||
const p = Math.min(1, (now - cat.transitionStart) / TRANSITION_MS);
|
||||
cat.currentRgb = lerpRgb(cat.fromRgb, cat.toRgb, easeOutCubic(p));
|
||||
if (p >= 1) cat.transitionStart = 0;
|
||||
}
|
||||
}
|
||||
|
||||
draw(now) {
|
||||
const ctx = this.ctx;
|
||||
ctx.clearRect(0, 0, this.width, this.height);
|
||||
|
||||
const bg = this.categories.background;
|
||||
ctx.fillStyle = rgbToHex(bg.currentRgb);
|
||||
ctx.globalAlpha = bg.assignedHex ? 1 : 0.85 + 0.1 * Math.sin(now / 500 + bg.pulsePhase);
|
||||
ctx.fillRect(0, 0, this.width, this.height);
|
||||
ctx.globalAlpha = 1;
|
||||
|
||||
ctx.fillStyle = 'rgba(0,0,0,.16)';
|
||||
ctx.fillRect(0, 0, GUTTER, this.height);
|
||||
|
||||
if (this.activeCategory) {
|
||||
const glowAlpha = 0.16 + 0.12 * Math.sin(now / 220);
|
||||
ctx.fillStyle = `rgba(255,255,255,${glowAlpha})`;
|
||||
for (const t of this.tokens) {
|
||||
if (t.type !== this.activeCategory) continue;
|
||||
const x = GUTTER + PAD + t.col * this.charWidth;
|
||||
const yTop = PAD + t.line * LINE_HEIGHT;
|
||||
ctx.fillRect(x - 3, yTop + 1, t.text.length * this.charWidth + 6, LINE_HEIGHT - 2);
|
||||
}
|
||||
}
|
||||
|
||||
ctx.fillStyle = '#665c54';
|
||||
ctx.font = `${FONT_SIZE - 3}px ${this.fontStack()}`;
|
||||
ctx.textBaseline = 'alphabetic';
|
||||
for (let l = 0; l < this.lineCount; l++) {
|
||||
ctx.fillText(String(l + 1).padStart(2, ' '), 8, PAD + l * LINE_HEIGHT + FONT_SIZE - 4);
|
||||
}
|
||||
|
||||
for (const t of this.tokens) {
|
||||
if (t.type === 'whitespace' || t.type === 'newline') continue;
|
||||
const cat = this.categories[t.type];
|
||||
const x = GUTTER + PAD + t.col * this.charWidth;
|
||||
const y = PAD + t.line * LINE_HEIGHT + FONT_SIZE - 4;
|
||||
ctx.font = t.type === 'comment'
|
||||
? `italic ${FONT_SIZE}px ${this.fontStack()}`
|
||||
: `${FONT_SIZE}px ${this.fontStack()}`;
|
||||
ctx.fillStyle = rgbToHex(cat.currentRgb);
|
||||
ctx.globalAlpha = cat.assignedHex ? 1 : 0.5 + 0.25 * Math.sin(now / 450 + cat.pulsePhase);
|
||||
ctx.fillText(t.text, x, y);
|
||||
}
|
||||
ctx.globalAlpha = 1;
|
||||
|
||||
this.fx.draw(ctx);
|
||||
}
|
||||
}
|
||||
102
src/main.js
102
src/main.js
|
|
@ -1,102 +0,0 @@
|
|||
import { ThemeGuessGame } from './game.js';
|
||||
import { THEMES } from './theme.js';
|
||||
import { sound } from './sound.js';
|
||||
import { renderThemePreview } from './preview.js';
|
||||
|
||||
const introModal = document.getElementById('intro-modal');
|
||||
const themeGrid = document.getElementById('theme-grid');
|
||||
const startBtn = document.getElementById('start-btn');
|
||||
const helpBtn = document.getElementById('help-btn');
|
||||
const muteBtn = document.getElementById('mute-btn');
|
||||
const themeNameBadge = document.getElementById('theme-name-badge');
|
||||
const changeThemeBtn = document.getElementById('change-theme-btn');
|
||||
const resultModal = document.getElementById('result-modal');
|
||||
|
||||
const previewOverlay = document.getElementById('preview-overlay');
|
||||
const previewCountdown = document.getElementById('preview-countdown');
|
||||
const previewCaption = document.getElementById('preview-caption');
|
||||
const previewCanvas = document.getElementById('preview-canvas');
|
||||
|
||||
const SWATCH_KEYS = ['keyword', 'string', 'function', 'type'];
|
||||
const COUNTDOWN_SECONDS = 5;
|
||||
|
||||
let selectedTheme = 'gruvbox';
|
||||
let game = null;
|
||||
|
||||
function renderThemeGrid() {
|
||||
themeGrid.innerHTML = '';
|
||||
for (const id in THEMES) {
|
||||
const theme = THEMES[id];
|
||||
const card = document.createElement('button');
|
||||
card.type = 'button';
|
||||
card.className = `theme-card${id === selectedTheme ? ' selected' : ''}`;
|
||||
card.dataset.id = id;
|
||||
card.style.setProperty('--card-bg', theme.colors.background);
|
||||
card.innerHTML = `
|
||||
<span class="theme-swatches">
|
||||
${SWATCH_KEYS.map((k) => `<i style="background:${theme.colors[k]}"></i>`).join('')}
|
||||
</span>
|
||||
<span class="theme-name">${theme.name}</span>`;
|
||||
card.addEventListener('click', () => {
|
||||
selectedTheme = id;
|
||||
renderThemeGrid();
|
||||
sound.playOpen();
|
||||
});
|
||||
themeGrid.appendChild(card);
|
||||
}
|
||||
}
|
||||
|
||||
function bumpCountdown(text) {
|
||||
previewCountdown.textContent = text;
|
||||
previewCountdown.classList.remove('tick');
|
||||
void previewCountdown.offsetWidth;
|
||||
previewCountdown.classList.add('tick');
|
||||
}
|
||||
|
||||
// Flashes a *different* snippet, fully colored with the real theme (and
|
||||
// deliberately missing a few token kinds so it isn't a complete answer
|
||||
// key), for a few seconds before the round actually starts.
|
||||
function runThemePreview(themeId, onDone) {
|
||||
renderThemePreview(previewCanvas, themeId);
|
||||
previewCaption.textContent = `LOADING: ${THEMES[themeId].name.toUpperCase()}`;
|
||||
previewOverlay.classList.remove('hidden');
|
||||
|
||||
let remaining = COUNTDOWN_SECONDS;
|
||||
bumpCountdown(remaining);
|
||||
sound.playOpen();
|
||||
|
||||
const timer = setInterval(() => {
|
||||
remaining -= 1;
|
||||
if (remaining <= 0) {
|
||||
clearInterval(timer);
|
||||
previewOverlay.classList.add('hidden');
|
||||
onDone();
|
||||
return;
|
||||
}
|
||||
bumpCountdown(remaining);
|
||||
sound.playOpen();
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
function startWithSelectedTheme() {
|
||||
introModal.classList.add('hidden');
|
||||
runThemePreview(selectedTheme, () => {
|
||||
if (game) game.setTheme(selectedTheme);
|
||||
else game = new ThemeGuessGame(selectedTheme);
|
||||
themeNameBadge.textContent = THEMES[selectedTheme].name;
|
||||
sound.playApply();
|
||||
});
|
||||
}
|
||||
|
||||
renderThemeGrid();
|
||||
startBtn.addEventListener('click', startWithSelectedTheme);
|
||||
helpBtn.addEventListener('click', () => introModal.classList.remove('hidden'));
|
||||
changeThemeBtn.addEventListener('click', () => {
|
||||
resultModal.classList.add('hidden');
|
||||
introModal.classList.remove('hidden');
|
||||
});
|
||||
|
||||
muteBtn.addEventListener('click', () => {
|
||||
const muted = sound.toggleMute();
|
||||
muteBtn.textContent = muted ? '🔇' : '🔊';
|
||||
});
|
||||
Loading…
Reference in a new issue