feat(frontend): expand code snippet catalog for random selection

This commit is contained in:
Gabriel Franco 2026-09-10 11:45:28 -03:00
parent 629f52caa3
commit d51113469f
3 changed files with 281 additions and 38 deletions

View file

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

View file

@ -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 // A short, different snippet used for the pre-game theme reveal. It's
// deliberately missing some token kinds the real game asks about (no // deliberately missing some token kinds the real game asks about (no
// numeric literals, no true/false/null/undefined/this) so the preview // numeric literals, no true/false/null/undefined/this) so the preview

View file

@ -1,5 +1,6 @@
import { tokenize } from '../engine/tokenizer'; 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 { easeOutCubic, hexToRgb, isValidHex, lerpRgb, rgbToHex } from '../engine/colorUtils';
import { ParticleSystem } from '../engine/particles'; import { ParticleSystem } from '../engine/particles';
import { sound } from '../engine/sound'; import { sound } from '../engine/sound';
@ -44,6 +45,7 @@ export class ThemeGuessGame {
private readonly fx = new ParticleSystem(); private readonly fx = new ParticleSystem();
private themeId: ThemeId; private themeId: ThemeId;
private snippetIndex: number;
private activeCategory: CategoryId | null = null; private activeCategory: CategoryId | null = null;
private compareMode: CompareMode = 'yours'; private compareMode: CompareMode = 'yours';
private lastTime = performance.now(); private lastTime = performance.now();
@ -57,8 +59,9 @@ export class ThemeGuessGame {
private height = 0; private height = 0;
private categories!: CategoryStateMap; private categories!: CategoryStateMap;
constructor(themeId: ThemeId) { constructor(themeId: ThemeId, snippetIndex: number = pickRandomSnippetIndex()) {
this.themeId = themeId; this.themeId = themeId;
this.snippetIndex = snippetIndex;
this.build(); this.build();
this.bindEvents(); this.bindEvents();
requestAnimationFrame((t) => this.loop(t)); requestAnimationFrame((t) => this.loop(t));
@ -67,7 +70,7 @@ export class ThemeGuessGame {
// ---------- setup ---------- // ---------- setup ----------
private build(): void { private build(): void {
this.tokens = tokenize(CODE_SAMPLE); this.tokens = tokenize(SNIPPETS[this.snippetIndex]);
this.lineCount = 1; this.lineCount = 1;
this.maxCols = 0; this.maxCols = 0;
@ -153,6 +156,18 @@ export class ThemeGuessGame {
this.fx.clear(); 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 ---------- // ---------- events ----------
private bindEvents(): void { private bindEvents(): void {