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:
Gabriel Franco 2026-09-10 10:37:44 -03:00
parent 467447b51a
commit 6f9a298aef
25 changed files with 2153 additions and 695 deletions

15
.gitignore vendored Normal file
View file

@ -0,0 +1,15 @@
# Dependencies
**/node_modules/
# Build output
**/dist/
**/.vite/
# Env files
.env
.env.*
!.env.example
# OS/editor cruft
.DS_Store
*.local

View file

@ -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

File diff suppressed because it is too large Load diff

17
frontend/package.json Normal file
View 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"
}
}

View file

@ -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

View file

@ -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));

View file

@ -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;

View file

@ -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;
}

View file

@ -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,15 +95,8 @@ function expandNewlines(raw) {
return tokens;
}
function classify(tokens) {
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;
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;
@ -105,21 +107,33 @@ function classify(tokens) {
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 (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';
}
t.line = line;
t.col = col;
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') {
result.push({ ...t, line, col });
line++;
col = 0;
continue;
}
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)));
}

View 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);
}
}

View 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;

View 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();
}

View 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);
}

View 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);
}

View 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
View 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 ? '🔇' : '🔊';
});

View file

@ -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
View 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[];
}

View 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);
}

View 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
View 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
View file

@ -0,0 +1,7 @@
import { defineConfig } from 'vite';
export default defineConfig({
server: {
port: 5173,
},
});

View file

@ -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);
}
}

View file

@ -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 ? '🔇' : '🔊';
});