From d51113469fd6fad2f0990a4cf4e5997f941d2cf0 Mon Sep 17 00:00:00 2001 From: Gabriel Franco Date: Thu, 10 Sep 2026 11:45:28 -0300 Subject: [PATCH] feat(frontend): expand code snippet catalog for random selection --- frontend/src/data/snippets.ts | 263 ++++++++++++++++++++++++++++ frontend/src/data/themes.ts | 35 ---- frontend/src/game/ThemeGuessGame.ts | 21 ++- 3 files changed, 281 insertions(+), 38 deletions(-) create mode 100644 frontend/src/data/snippets.ts diff --git a/frontend/src/data/snippets.ts b/frontend/src/data/snippets.ts new file mode 100644 index 0000000..4ad92c4 --- /dev/null +++ b/frontend/src/data/snippets.ts @@ -0,0 +1,263 @@ +// The pool of code samples the game tokenizes and asks the player to +// theme. One entry is picked per match (see `pickRandomSnippetIndex`) +// so repeat play doesn't always show the same file. +// +// INVARIANT: this array is append-only and indices are stable across +// releases. Multiplayer matches reference a snippet by its numeric +// index over the wire (so both players in a match render the exact +// same source), so an existing entry must never be removed, reordered, +// or have its meaning changed once shipped — only append new entries +// at the end. +// +// Each sample exercises every CategoryId the tokenizer/theme panel +// cares about (keyword, string, number, constant, function, variable, +// property, type, operator, punctuation, comment; 'background' isn't +// a token and is always themed regardless of source). + +export const SNIPPETS: string[] = [ + // 0: original Theme Guess demo file. + `// Theme Guess demo file +class ColorMixer { + constructor(name, hue) { + this.name = name; + this.hue = hue; + this.locked = false; + } + + blend(other, amount = 0.5) { + const mixed = this.hue * (1 - amount) + other.hue * amount; + return new ColorMixer(\`\${this.name}+\${other.name}\`, mixed); + } +} + +function buildPalette(seed) { + const colors = []; + for (let i = 0; i < 8; i++) { + const hue = (seed + i * 45) % 360; + colors.push(new ColorMixer(\`swatch-\${i}\`, hue)); + } + return colors; +} + +const palette = buildPalette(20); +const primary = palette[0]; +const accent = palette[palette.length - 1]; + +if (accent.hue > 180 && !primary.locked) { + primary.locked = true; + console.log(\`Locked \${primary.name} at hue \${primary.hue}\`); +} + +export default palette; +`, + // 1: inventory tracker. + `// Inventory tracking demo +class InventoryTracker { + constructor(label) { + this.label = label; + this.items = []; + this.total = 0; + } + + add(item, quantity = 1) { + const cost = item.price * quantity; + this.total += cost; + this.items.push(item.name); + return cost; + } + + isEmpty() { + return this.items.length === 0; + } +} + +function createItem(name, price) { + return { name, price }; +} + +const tracker = new InventoryTracker('Warehouse A'); +const apple = createItem('apple', 2.5); +const banana = createItem('banana', 1.25); + +tracker.add(apple, 10); +tracker.add(banana, 6); + +if (tracker.isEmpty() || tracker.total > 100) { + console.log(\`Alert for \${tracker.label}: total is \${tracker.total}\`); +} else { + console.log('Inventory nominal'); +} + +export default tracker; +`, + // 2: 2D vector math. + `// Vector math utilities +class Vector2 { + constructor(x, y) { + this.x = x; + this.y = y; + } + + add(other) { + return new Vector2(this.x + other.x, this.y + other.y); + } + + length() { + return Math.sqrt(this.x * this.x + this.y * this.y); + } + + normalize() { + const len = this.length(); + if (len === 0) { + return new Vector2(0, 0); + } + return new Vector2(this.x / len, this.y / len); + } +} + +function lerpVector(a, b, t) { + const clamped = Math.min(Math.max(t, 0), 1); + return new Vector2(a.x + (b.x - a.x) * clamped, a.y + (b.y - a.y) * clamped); +} + +const origin = new Vector2(0, 0); +const target = lerpVector(origin, new Vector2(10, 20), 0.75); +const isZero = target.length() === 0; + +if (!isZero && target.x !== undefined) { + console.log(\`Reached \${target.x}, \${target.y}\`); +} + +export { Vector2, lerpVector }; +`, + // 3: publish/subscribe event bus. + `// Simple publish/subscribe event bus +class EventBus { + constructor() { + this.listeners = {}; + } + + on(eventName, handler) { + if (!this.listeners[eventName]) { + this.listeners[eventName] = []; + } + this.listeners[eventName].push(handler); + return this; + } + + emit(eventName, payload) { + const handlers = this.listeners[eventName] || []; + for (const handler of handlers) { + handler(payload); + } + } +} + +function logHandler(payload) { + console.log(\`[log] \${payload.type}: \${payload.message}\`); +} + +const bus = new EventBus(); +bus.on('error', logHandler); + +const failureCount = 3; +let attempts = 0; + +while (attempts < failureCount) { + bus.emit('error', { type: 'retry', message: \`attempt \${attempts}\`, fatal: false }); + attempts++; +} + +if (attempts === failureCount && bus.listeners.error != null) { + console.log('Done retrying'); +} +`, + // 4: descriptive statistics helpers. + `// Basic descriptive statistics helpers +class StatsCalculator { + constructor(values) { + this.values = values; + } + + mean() { + const sum = this.values.reduce((acc, v) => acc + v, 0); + return sum / this.values.length; + } + + max() { + return Math.max(...this.values); + } +} + +function isNumericArray(list) { + return list.every((v) => typeof v === 'number'); +} + +const samples = [4, 8, 15, 16, 23, 42]; +const calc = new StatsCalculator(samples); +const average = calc.mean(); +const peak = calc.max(); + +let label = null; +if (isNumericArray(samples) && average > 0) { + label = \`avg=\${average.toFixed(2)}, peak=\${peak}\`; +} else { + label = 'invalid dataset'; +} + +console.log(label); +export { StatsCalculator, isNumericArray }; +`, + // 5: shape hierarchy with inheritance. + `/* Grid renderer with basic collision helpers */ +class Shape { + constructor(kind) { + this.kind = kind; + } + + describe() { + return \`\${this.kind} shape\`; + } +} + +class Rectangle extends Shape { + constructor(width, height) { + super('rectangle'); + this.width = width; + this.height = height; + } + + area() { + return this.width * this.height; + } + + contains(px, py) { + return px >= 0 && px <= this.width && py >= 0 && py <= this.height; + } +} + +function makeGrid(count) { + const shapes = []; + for (let i = 0; i < count; i++) { + shapes.push(new Rectangle(i + 1, i + 2)); + } + return shapes; +} + +const grid = makeGrid(5); +const first = grid[0]; +const hit = first.contains(1, 1); + +if (hit === true || first.area() === 0) { + console.log(first.describe()); +} else { + console.log('no hit'); +} +`, +]; + +/** Picks a uniformly random index into `SNIPPETS`, used as the default + * snippet selection for a solo match. */ +export function pickRandomSnippetIndex(): number { + return Math.floor(Math.random() * SNIPPETS.length); +} diff --git a/frontend/src/data/themes.ts b/frontend/src/data/themes.ts index ef1ae6b..f0e1a22 100644 --- a/frontend/src/data/themes.ts +++ b/frontend/src/data/themes.ts @@ -74,41 +74,6 @@ export const THEMES: Themes = { }, }; -export const CODE_SAMPLE = `// Theme Guess demo file -class ColorMixer { - constructor(name, hue) { - this.name = name; - this.hue = hue; - this.locked = false; - } - - blend(other, amount = 0.5) { - const mixed = this.hue * (1 - amount) + other.hue * amount; - return new ColorMixer(\`\${this.name}+\${other.name}\`, mixed); - } -} - -function buildPalette(seed) { - const colors = []; - for (let i = 0; i < 8; i++) { - const hue = (seed + i * 45) % 360; - colors.push(new ColorMixer(\`swatch-\${i}\`, hue)); - } - return colors; -} - -const palette = buildPalette(20); -const primary = palette[0]; -const accent = palette[palette.length - 1]; - -if (accent.hue > 180 && !primary.locked) { - primary.locked = true; - console.log(\`Locked \${primary.name} at hue \${primary.hue}\`); -} - -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 diff --git a/frontend/src/game/ThemeGuessGame.ts b/frontend/src/game/ThemeGuessGame.ts index 2e4a4aa..cfa5cd4 100644 --- a/frontend/src/game/ThemeGuessGame.ts +++ b/frontend/src/game/ThemeGuessGame.ts @@ -1,5 +1,6 @@ import { tokenize } from '../engine/tokenizer'; -import { CATEGORY_META, THEMES, CODE_SAMPLE } from '../data/themes'; +import { CATEGORY_META, THEMES } from '../data/themes'; +import { SNIPPETS, pickRandomSnippetIndex } from '../data/snippets'; import { easeOutCubic, hexToRgb, isValidHex, lerpRgb, rgbToHex } from '../engine/colorUtils'; import { ParticleSystem } from '../engine/particles'; import { sound } from '../engine/sound'; @@ -44,6 +45,7 @@ export class ThemeGuessGame { private readonly fx = new ParticleSystem(); private themeId: ThemeId; + private snippetIndex: number; private activeCategory: CategoryId | null = null; private compareMode: CompareMode = 'yours'; private lastTime = performance.now(); @@ -57,8 +59,9 @@ export class ThemeGuessGame { private height = 0; private categories!: CategoryStateMap; - constructor(themeId: ThemeId) { + constructor(themeId: ThemeId, snippetIndex: number = pickRandomSnippetIndex()) { this.themeId = themeId; + this.snippetIndex = snippetIndex; this.build(); this.bindEvents(); requestAnimationFrame((t) => this.loop(t)); @@ -67,7 +70,7 @@ export class ThemeGuessGame { // ---------- setup ---------- private build(): void { - this.tokens = tokenize(CODE_SAMPLE); + this.tokens = tokenize(SNIPPETS[this.snippetIndex]); this.lineCount = 1; this.maxCols = 0; @@ -153,6 +156,18 @@ export class ThemeGuessGame { this.fx.clear(); } + /** Forces a specific snippet (by stable `SNIPPETS` index) and rebuilds + * the round around it — used by multiplayer to keep both players on + * the same source file instead of each picking their own at random. */ + setSnippet(index: number): void { + this.snippetIndex = index; + this.build(); + this.activeCategory = null; + this.popover.classList.add('hidden'); + this.resultModal.classList.add('hidden'); + this.fx.clear(); + } + // ---------- events ---------- private bindEvents(): void {