diff --git a/nextjs-site/components/NpcTown.tsx b/nextjs-site/components/NpcTown.tsx new file mode 100644 index 0000000..e60bdb3 --- /dev/null +++ b/nextjs-site/components/NpcTown.tsx @@ -0,0 +1,759 @@ +// NPC Town — soft pixel top-down. Tiny self-attention in TFJS drives ambient NPC chatter. +// Project NPCs are themed (Harsh's actual projects). Ambient NPCs talk via attention-weighted word picks. +// Originally an HTML/CSS/JS prototype from the Claude Design handoff bundle; ported to a Next.js client component. + +import { useState, useEffect, useRef, CSSProperties, MouseEvent } from 'react'; + +// TFJS is loaded from CDN at runtime to keep it out of the main bundle. +declare global { + interface Window { + tf?: any; + } +} + +// ---------- TYPES ---------- +type Embedding = number[]; + +interface ProjectNPCData { + id: string; + name: string; + color: string; + x: number; + y: number; + traits: Record; + title: string; + body: string; + tags: string[]; +} + +interface AmbientNPC { + id: string; + name: string; + persona: string; + color: string; + x: number; + y: number; + vx: number; + vy: number; + embedding: Embedding; +} + +interface BubbleData { + id: string; + npcId: string; + text: string; + x: number; + y: number; + born: number; + ttl: number; +} + +interface TranscriptEntry { + id: string; + speaker: string; + listener: string; + text: string; + t: number; +} + +interface AttentionApi { + attentionScores: (embeddings: Embedding[]) => number[][]; + wordSoftmax: (query: Embedding, wordEmbs: Embedding[]) => number[]; + dispose: () => void; +} + +// ---------- DATA ---------- +// To add a new project NPC: append an object to PROJECT_NPCS below. +// - id: unique slug +// - name: short codename shown in-world +// - color: pixel-person body color (any CSS color) +// - x, y: spawn position in % (0-100). Keep clear of player center 50/50. +// - traits: free-form dict of personality dimensions, shown as bars in the modal. +// - title / body / tags: shown in the click-through modal. +const PROJECT_NPCS: ProjectNPCData[] = [ + { id: 'ocean', name: 'Dr. OCEAN', color: '#e8a5a5', x: 22, y: 32, traits: { vision: 1.0, medical: 1.0, classify: 0.9 }, + title: 'UBC-OCEAN', + body: 'I predict ovarian cancer subtypes from whole-slide histopathology. Vision Transformers + multiple-instance learning across gigapixel slides.', + tags: ['computer-vision', 'ViT', 'MIL', 'histopath'] }, + { id: 'mots', name: 'TRAK', color: '#a5c5e8', x: 72, y: 24, traits: { vision: 0.9, tracking: 1.0, segment: 1.0 }, + title: 'MOTS · BDD100K', + body: 'Multi-object tracking + segmentation for the CVPR workshop. MaskDINO backbone with ByteTrack/SORT variants on driving video.', + tags: ['MaskDINO', 'tracking', 'CVPR', 'BDD100K'] }, + { id: 'codex', name: 'CODEX', color: '#c5a5e8', x: 78, y: 70, traits: { language: 1.0, code: 1.0, generate: 0.9 }, + title: 'Graphic-Codex', + body: 'Pre-ChatGPT: text → 3D animation by generating CARLA scene code from natural language. An early NL → world programming experiment.', + tags: ['Codex', 'CARLA', 'code-gen'] }, + { id: 'tfjs', name: 'WEBCAM-1', color: '#a5e8c1', x: 18, y: 72, traits: { browser: 1.0, vision: 0.7, deploy: 1.0 }, + title: 'TFJS Webcam', + body: 'Live image classifier that trains in your browser on webcam frames. Same family of models powering this very simulation.', + tags: ['TensorFlow.js', 'browser-ML', 'live-train'] }, + { id: 'fractal', name: 'FORECAST-9', color: '#e8d4a5', x: 50, y: 18, traits: { forecast: 1.0, business: 1.0, scale: 0.9 }, + title: 'Sales Forecasting · Fractal', + body: 'Flagship forecasting product — held under 15% MAPE through COVID. Scaled across APAC after the model success.', + tags: ['forecasting', 'MAPE', 'Prophet', 'ARIMA'] }, + { id: 'ola', name: 'OPTI', color: '#e8c1a5', x: 50, y: 78, traits: { optimize: 1.0, logistics: 1.0, scale: 0.9 }, + title: 'Routing Optimization · Ola Electric', + body: 'Reduced transportation costs ~30% (≈$6M/yr) by re-locating fulfillment centers. Plus a unified ETL that cut complaint resolution 40%.', + tags: ['routing', 'MILP', 'ETL', 'logistics'] }, +]; + +// vocabulary for ambient chatter — each word has a small embedding (8d) +// dimensions: [data, model, train, deploy, vision, language, fast, weird] +const VOCAB: [string, Embedding][] = [ + ['dataset', [1.0, 0.2, 0.4, 0.0, 0.0, 0.0, 0.0, 0.0]], + ['pipeline', [0.9, 0.1, 0.3, 0.5, 0.0, 0.0, 0.2, 0.0]], + ['embeddings', [0.7, 0.8, 0.0, 0.0, 0.3, 0.5, 0.0, 0.1]], + ['features', [0.8, 0.5, 0.2, 0.0, 0.0, 0.0, 0.0, 0.0]], + ['tokens', [0.6, 0.4, 0.1, 0.0, 0.0, 1.0, 0.0, 0.0]], + ['batch', [0.5, 0.3, 0.9, 0.0, 0.0, 0.0, 0.4, 0.0]], + ['transformer', [0.0, 1.0, 0.4, 0.0, 0.0, 0.7, 0.0, 0.0]], + ['attention', [0.0, 1.0, 0.0, 0.0, 0.0, 0.6, 0.0, 0.2]], + ['softmax', [0.0, 0.9, 0.0, 0.0, 0.0, 0.3, 0.5, 0.0]], + ['gradients', [0.0, 0.7, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0]], + ['loss', [0.0, 0.6, 1.0, 0.0, 0.0, 0.0, 0.0, 0.1]], + ['weights', [0.0, 1.0, 0.7, 0.0, 0.0, 0.0, 0.0, 0.0]], + ['MAPE', [0.4, 0.6, 0.0, 0.3, 0.0, 0.0, 0.5, 0.0]], + ['epoch', [0.3, 0.2, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0]], + ['overfitting', [0.4, 0.6, 0.9, 0.0, 0.0, 0.0, 0.0, 0.3]], + ['lr-scheduler',[0.0, 0.4, 0.9, 0.0, 0.0, 0.0, 0.6, 0.0]], + ['lambda', [0.5, 0.0, 0.0, 1.0, 0.0, 0.0, 0.7, 0.0]], + ['serverless', [0.2, 0.0, 0.0, 1.0, 0.0, 0.0, 0.6, 0.0]], + ['docker', [0.3, 0.0, 0.0, 0.9, 0.0, 0.0, 0.4, 0.0]], + ['browser', [0.4, 0.5, 0.0, 1.0, 0.4, 0.0, 0.5, 0.0]], + ['vercel', [0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.7, 0.0]], + ['pixels', [0.5, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0]], + ['histopath', [0.7, 0.5, 0.0, 0.0, 1.0, 0.0, 0.0, 0.2]], + ['tracking', [0.4, 0.5, 0.3, 0.0, 1.0, 0.0, 0.6, 0.0]], + ['segmentation',[0.3, 0.6, 0.4, 0.0, 1.0, 0.0, 0.0, 0.0]], + ['coffee', [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.4, 1.0]], + ['existential', [0.0, 0.0, 0.0, 0.0, 0.0, 0.3, 0.0, 1.0]], + ['vibes', [0.0, 0.0, 0.0, 0.0, 0.0, 0.4, 0.3, 1.0]], + ['semicolon', [0.0, 0.0, 0.0, 0.4, 0.0, 0.5, 0.5, 0.9]], + ['lol', [0.0, 0.0, 0.0, 0.0, 0.0, 0.6, 0.6, 0.9]], +]; + +const PHRASE_TEMPLATES = [ + 'did you see the {x}?', + '{x} is rough today.', + "i'm debugging {x}.", + 'ship the {x} already.', + '{x} → {y}, finally.', + 'more {x}, less {y}.', + '{x}? {y}.', + 'the {x} converged.', + '{x} broke staging.', + 'another {x} kind of day.', +]; + +// ---------- TFJS ATTENTION ---------- +async function setupAttention(): Promise { + while (!window.tf) await new Promise(r => setTimeout(r, 100)); + const tf = window.tf; + + const Wq = tf.randomNormal([8, 8], 0, 0.5); + const Wk = tf.randomNormal([8, 8], 0, 0.5); + const Wv = tf.randomNormal([8, 8], 0, 0.5); + + function attentionScores(embeddings: Embedding[]): number[][] { + return tf.tidy(() => { + const X = tf.tensor2d(embeddings); + const Q = X.matMul(Wq); + const K = X.matMul(Wk); + const scores = Q.matMul(K.transpose()).div(Math.sqrt(8)); + return tf.softmax(scores).arraySync(); + }); + } + + function wordSoftmax(query: Embedding, wordEmbs: Embedding[]): number[] { + return tf.tidy(() => { + const q = tf.tensor1d(query).reshape([1, 8]).matMul(Wq); + const K = tf.tensor2d(wordEmbs).matMul(Wk); + const scores = q.matMul(K.transpose()).div(Math.sqrt(8)); + return tf.softmax(scores).reshape([wordEmbs.length]).arraySync(); + }); + } + + return { + attentionScores, + wordSoftmax, + dispose: () => { Wq.dispose(); Wk.dispose(); Wv.dispose(); }, + }; +} + +function sampleFromDist(dist: number[], temp = 0.8): number { + const adjusted = dist.map(p => Math.pow(p, 1 / temp)); + const sum = adjusted.reduce((a, b) => a + b, 0); + const norm = adjusted.map(p => p / sum); + let r = Math.random(); + for (let i = 0; i < norm.length; i++) { + r -= norm[i]; + if (r <= 0) return i; + } + return norm.length - 1; +} + +// ---------- AMBIENT NPCS ---------- +const PERSONAS: { key: string; bias: Embedding }[] = [ + { key: 'data-person', bias: [1.0, 0.3, 0.2, 0.1, 0.0, 0.0, 0.2, 0.1] }, + { key: 'model-person', bias: [0.2, 1.0, 0.6, 0.0, 0.1, 0.4, 0.1, 0.0] }, + { key: 'infra-person', bias: [0.3, 0.0, 0.1, 1.0, 0.0, 0.0, 0.7, 0.0] }, + { key: 'vision-person', bias: [0.4, 0.5, 0.2, 0.0, 1.0, 0.0, 0.1, 0.0] }, + { key: 'lang-person', bias: [0.2, 0.5, 0.1, 0.0, 0.0, 1.0, 0.0, 0.2] }, + { key: 'vibes-person', bias: [0.0, 0.0, 0.0, 0.0, 0.0, 0.2, 0.3, 1.0] }, +]; + +function makeAmbients(): AmbientNPC[] { + const names = ['alex', 'sam', 'jo', 'kai', 'rae', 'noor', 'sid', 'wren', 'el', 'max']; + const colors = ['#bcbcbc', '#a8a8a8', '#cfcfcf', '#9c9c9c', '#b5b5b5']; + const clusters = [{ x: 38, y: 42 }, { x: 62, y: 55 }, { x: 48, y: 68 }]; + return names.map((n, i) => { + const c = clusters[i % clusters.length]; + const persona = PERSONAS[i % PERSONAS.length]; + return { + id: 'amb-' + i, + name: n, + persona: persona.key, + color: colors[i % colors.length], + x: c.x + (Math.random() - 0.5) * 14, + y: c.y + (Math.random() - 0.5) * 10, + vx: 0, + vy: 0, + embedding: persona.bias.map(b => + Math.max(0.05, b * (0.7 + Math.random() * 0.5) + (Math.random() - 0.5) * 0.1) + ), + }; + }); +} + +// ---------- COMPONENT ---------- +export default function NpcTown() { + const [att, setAtt] = useState(null); + const [tfReady, setTfReady] = useState(false); + const [player, setPlayer] = useState({ x: 50, y: 50 }); + const [keys, setKeys] = useState>({}); + const [ambients, setAmbients] = useState(() => makeAmbients()); + const [bubbles, setBubbles] = useState([]); + const [nearProject, setNearProject] = useState(null); + const [openProject, setOpenProject] = useState(null); + const [attMatrix, setAttMatrix] = useState(null); + const [showAttn, setShowAttn] = useState(true); + const [spoken, setSpoken] = useState(0); + const [, setClicks] = useState(0); + const [showWasdHint, setShowWasdHint] = useState(false); + const [hasMoved, setHasMoved] = useState(false); + const [transcriptOpen, setTranscriptOpen] = useState(false); + const [transcript, setTranscript] = useState([]); + const ambientsRef = useRef(ambients); + useEffect(() => { ambientsRef.current = ambients; }, [ambients]); + + // load tfjs + useEffect(() => { + if (window.tf) { setTfReady(true); return; } + const s = document.createElement('script'); + s.src = 'https://cdn.jsdelivr.net/npm/@tensorflow/tfjs@4.20.0/dist/tf.min.js'; + s.onload = () => setTfReady(true); + document.head.appendChild(s); + }, []); + + useEffect(() => { + if (!tfReady) return; + let live = true; + setupAttention().then(a => { if (live) setAtt(a); }); + return () => { live = false; }; + }, [tfReady]); + + // input + useEffect(() => { + const dn = (e: KeyboardEvent) => { + const k = e.key.toLowerCase(); + if (['w', 'a', 's', 'd', 'arrowup', 'arrowdown', 'arrowleft', 'arrowright'].includes(k)) e.preventDefault(); + if (k === 'e' && nearProject) { setOpenProject(nearProject); } + setKeys(p => ({ ...p, [k]: true })); + }; + const up = (e: KeyboardEvent) => setKeys(p => ({ ...p, [e.key.toLowerCase()]: false })); + window.addEventListener('keydown', dn); + window.addEventListener('keyup', up); + return () => { window.removeEventListener('keydown', dn); window.removeEventListener('keyup', up); }; + }, [nearProject]); + + // movement loop (player) + useEffect(() => { + let raf: number; + function tick() { + setPlayer(p => { + let { x, y } = p; + const sp = 0.45; + let moved = false; + if (keys.w || keys.arrowup) { y -= sp; moved = true; } + if (keys.s || keys.arrowdown) { y += sp; moved = true; } + if (keys.a || keys.arrowleft) { x -= sp; moved = true; } + if (keys.d || keys.arrowright) { x += sp; moved = true; } + if (moved && !hasMoved) setHasMoved(true); + return { x: Math.max(4, Math.min(96, x)), y: Math.max(8, Math.min(94, y)) }; + }); + raf = requestAnimationFrame(tick); + } + raf = requestAnimationFrame(tick); + return () => cancelAnimationFrame(raf); + }, [keys, hasMoved]); + + // proximity to project NPCs + useEffect(() => { + let nearest: ProjectNPCData | null = null; + let best = 8; + for (const n of PROJECT_NPCS) { + const d = Math.hypot(n.x - player.x, n.y - player.y); + if (d < best) { best = d; nearest = n; } + } + setNearProject(nearest); + }, [player]); + + // ambient drift + useEffect(() => { + let raf: number; + function tick() { + setAmbients(arr => arr.map(a => { + let { x, y, vx, vy } = a; + if (Math.random() < 0.02) { vx = (Math.random() - 0.5) * 0.4; vy = (Math.random() - 0.5) * 0.4; } + x = Math.max(8, Math.min(92, x + vx)); + y = Math.max(15, Math.min(90, y + vy)); + return { ...a, x, y, vx, vy }; + })); + raf = requestAnimationFrame(tick); + } + raf = requestAnimationFrame(tick); + return () => cancelAnimationFrame(raf); + }, []); + + // attention-driven chatter + useEffect(() => { + if (!att) return; + const interval = setInterval(() => { + const speakers = ambientsRef.current; + const embs = speakers.map(s => s.embedding); + const attMat = att.attentionScores(embs); + setAttMatrix(attMat); + + const i = Math.floor(Math.random() * speakers.length); + const row = attMat[i]; + let j = -1; + let bestScore = -1; + for (let k = 0; k < row.length; k++) { + if (k === i) continue; + if (row[k] > bestScore) { bestScore = row[k]; j = k; } + } + if (j < 0) return; + + const speaker = speakers[i]; + const listener = speakers[j]; + const d = Math.hypot(speaker.x - listener.x, speaker.y - listener.y); + if (d > 35) { + setAmbients(arr => arr.map(a => { + if (a.id !== speaker.id) return a; + const dx = listener.x - a.x; + const dy = listener.y - a.y; + const dist = Math.hypot(dx, dy) || 1; + return { ...a, vx: a.vx * 0.6 + (dx / dist) * 0.15, vy: a.vy * 0.6 + (dy / dist) * 0.15 }; + })); + } + + const vocabEmbs = VOCAB.map(([, e]) => e); + const dist = att.wordSoftmax(speaker.embedding, vocabEmbs); + const w1 = VOCAB[sampleFromDist(dist, 0.7)][0]; + const w2 = VOCAB[sampleFromDist(dist, 0.9)][0]; + const tpl = PHRASE_TEMPLATES[Math.floor(Math.random() * PHRASE_TEMPLATES.length)]; + const text = tpl.replaceAll('{x}', w1).replaceAll('{y}', w2); + + const id = Math.random().toString(36).slice(2); + setBubbles(bs => [...bs, { id, npcId: speaker.id, text, x: speaker.x, y: speaker.y, born: performance.now(), ttl: 3200 }]); + setSpoken(s => s + 1); + setTranscript(t => [...t, { id, speaker: speaker.name, listener: listener.name, text, t: Date.now() }].slice(-200)); + + setAmbients(arr => arr.map(a => { + if (a.id !== speaker.id) return a; + const tgt = listener.embedding; + const newE = a.embedding.map((v, k) => v * 0.95 + tgt[k] * 0.05 + (Math.random() - 0.5) * 0.02); + return { ...a, embedding: newE }; + })); + }, 1400); + return () => clearInterval(interval); + }, [att]); + + // bubble cleanup + useEffect(() => { + const t = setInterval(() => { + setBubbles(bs => bs.filter(b => performance.now() - b.born < b.ttl)); + }, 400); + return () => clearInterval(t); + }, []); + + function handleProjectClick(n: ProjectNPCData) { + setOpenProject(n); + setClicks(c => { + const nc = c + 1; + if (nc >= 3 && !hasMoved) setShowWasdHint(true); + return nc; + }); + } + useEffect(() => { if (hasMoved) setShowWasdHint(false); }, [hasMoved]); + useEffect(() => { + if (!showWasdHint) return; + const t = setTimeout(() => setShowWasdHint(false), 6000); + return () => clearTimeout(t); + }, [showWasdHint]); + + return ( +
+
+
+
HARSH.TOWN
+
a tiny transformer chats with itself · running 100% in your browser
+
+
+ + + + + +
+
+ +
+ + + {showAttn && attMatrix && } + + {PROJECT_NPCS.map(n => ( + handleProjectClick(n)} /> + ))} + + {ambients.map(a => ( + + ))} + + {bubbles.map(b => )} + + + + {nearProject && !openProject && ( +
+ PRESS [E] · {nearProject.name} +
+ )} + +
+ + + +
WASD · move  ·  E · talk
+
+ + {showAttn && attMatrix && ( +
+
SELF-ATTENTION · 10×10
+ +
brighter = NPC i wants to talk to NPC j
+
+ )} +
+ + {openProject && ( + setOpenProject(null)} /> + )} + + {showWasdHint && ( +
+
PSST…
+
you can use WASD to walk around the town.
+
+ {['W', 'A', 'S', 'D'].map(k => {k})} +
+ +
+ )} + + {transcriptOpen && ( + setTranscriptOpen(false)} /> + )} +
+ ); +} + +function TranscriptDrawer({ transcript, onClose }: { transcript: TranscriptEntry[]; onClose: () => void }) { + const scrollRef = useRef(null); + useEffect(() => { + if (scrollRef.current) scrollRef.current.scrollTop = scrollRef.current.scrollHeight; + }, [transcript]); + function copyAll() { + const txt = transcript.map(t => `${new Date(t.t).toLocaleTimeString()} ${t.speaker} → ${t.listener}: ${t.text}`).join('\n'); + navigator.clipboard?.writeText(txt); + } + return ( +
+
+
+
SESSION LOG
+
TRANSCRIPT · {transcript.length}
+
+
+ + +
+
+
+ {transcript.length === 0 ? ( +
+ no chatter yet — give the attention layer a moment… +
+ ) : transcript.map(t => ( +
+ {new Date(t.t).toLocaleTimeString([], { hour12: false })} + {t.speaker} {t.listener} + {t.text} +
+ ))} +
+
+ ); +} + +function Stat({ label, val }: { label: string; val: string }) { + return ( +
+ {label} + {val} +
+ ); +} + +function LegendDot({ c, label }: { c: string; label: string }) { + return
+ + {label} +
; +} + +function Terrain() { + return ( + <> +
+
+
+
+ + ); +} + +function AttentionLines({ ambients, matrix }: { ambients: AmbientNPC[]; matrix: number[][] }) { + const lines: { a: AmbientNPC; b: AmbientNPC; w: number }[] = []; + for (let i = 0; i < ambients.length; i++) { + for (let j = 0; j < ambients.length; j++) { + if (i === j) continue; + const w = matrix[i][j]; + if (w < 0.13) continue; + const a = ambients[i]; + const b = ambients[j]; + const d = Math.hypot(a.x - b.x, a.y - b.y); + if (d > 22) continue; + lines.push({ a, b, w }); + } + } + return ( + + {lines.map((l, i) => ( + + ))} + + ); +} + +function PixelPerson({ x, y, color, hat, isPlayer }: { x: number; y: number; color: string; hat?: string; isPlayer?: boolean }) { + const size = 14; + return ( +
+
+
+ {hat &&
} +
+
+
+
+ ); +} + +function ProjectNPC({ npc, active, onOpen }: { npc: ProjectNPCData; active: boolean; onOpen: () => void }) { + return ( + <> +
) => { e.currentTarget.style.transform = 'translate(-50%, -100%) scale(1.08)'; }} + onMouseLeave={(e: MouseEvent) => { e.currentTarget.style.transform = 'translate(-50%, -100%) scale(1.0)'; }} + > + ★ +
+ +
{npc.name} {active && · click}
+ + ); +} + +function Bubble({ x, y, text, born }: BubbleData) { + const age = (performance.now() - born) / 3200; + const opacity = age < 0.85 ? 1 : Math.max(0, 1 - (age - 0.85) / 0.15); + return ( +
+ {text} +
+
+ ); +} + +function Heatmap({ matrix }: { matrix: number[][] }) { + const n = matrix.length; + const cell = 14; + return ( +
+ {matrix.flatMap((row, i) => row.map((v, j) => ( +
+ )))} +
+ ); +} + +function ProjectModal({ npc, onClose }: { npc: ProjectNPCData; onClose: () => void }) { + useEffect(() => { + const k = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); }; + window.addEventListener('keydown', k); + return () => window.removeEventListener('keydown', k); + }, [onClose]); + return ( +
+
e.stopPropagation()}> + +
+
+
+
NPC · {npc.name}
+
{npc.title}
+
+
+
{npc.body}
+
+ {npc.tags.map(t => ( + {t} + ))} +
+
+
TRAIT EMBEDDING
+
+ {Object.entries(npc.traits).map(([k, v]) => ( +
+
+
{k}
+
+ ))} +
+
+
+
+ ); +} + +const tStyles: Record = { + root: { width: '100vw', height: '100vh', display: 'flex', flexDirection: 'column', background: '#e9dfb9', fontFamily: 'system-ui, -apple-system, sans-serif', overflow: 'hidden', position: 'relative' }, + hud: { display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '10px 16px', background: '#fef3c7', borderBottom: '2px solid #1a1a1a', zIndex: 10 }, + hudLeft: { display: 'flex', flexDirection: 'column', gap: 1 }, + brandName: { fontFamily: 'ui-monospace, monospace', fontSize: 14, letterSpacing: 4, color: '#3a2f15', fontWeight: 700 }, + brandTag: { fontFamily: 'ui-monospace, monospace', fontSize: 10, color: '#7a6f55', letterSpacing: 0.3 }, + hudRight: { display: 'flex', gap: 18, alignItems: 'center' }, + toggle: { fontFamily: 'ui-monospace, monospace', fontSize: 10, padding: '4px 9px', background: 'transparent', border: '1.5px solid #3a2f15', color: '#3a2f15', cursor: 'pointer', letterSpacing: 2 }, + world: { flex: 1, position: 'relative', overflow: 'hidden' }, + legend: { position: 'absolute', bottom: 14, left: 14, display: 'flex', gap: 14, padding: '6px 10px', background: 'rgba(254,243,199,0.92)', border: '1.5px solid #1a1a1a', zIndex: 8 }, + legendItem: { fontFamily: 'ui-monospace, monospace', fontSize: 10, color: '#3a2f15' }, + attnPanel: { position: 'absolute', bottom: 14, right: 14, padding: '10px 12px', background: 'rgba(254,243,199,0.94)', border: '1.5px solid #1a1a1a', zIndex: 8 }, + attnTitle: { fontFamily: 'ui-monospace, monospace', fontSize: 9, letterSpacing: 2.5, color: '#3a2f15', marginBottom: 6, fontWeight: 700 }, + attnHint: { fontFamily: 'ui-monospace, monospace', fontSize: 9, color: '#7a6f55', marginTop: 6, maxWidth: 160 }, + modalBg: { position: 'fixed', inset: 0, background: 'rgba(26,26,26,0.7)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 50 }, + modal: { position: 'relative', background: '#fffdf2', border: '3px solid #3a2f15', padding: '28px 32px', maxWidth: 520, boxShadow: '6px 6px 0 rgba(0,0,0,0.25)' }, + modalClose: { position: 'absolute', top: 10, right: 14, background: 'transparent', border: 'none', fontSize: 18, cursor: 'pointer', color: '#3a2f15' }, + wasdHint: { position: 'absolute', left: '50%', bottom: 80, transform: 'translateX(-50%)', background: '#fffdf2', border: '2px solid #1a1a1a', boxShadow: '4px 4px 0 rgba(0,0,0,0.2)', padding: '14px 20px', zIndex: 30, maxWidth: 340 }, + kbd: { fontFamily: 'ui-monospace, monospace', fontSize: 11, fontWeight: 700, padding: '4px 8px', background: '#1a1a1a', color: '#fef3c7', border: '1px solid #1a1a1a', boxShadow: '2px 2px 0 #3a2f15' }, + hintClose: { position: 'absolute', top: 8, right: 10, background: 'transparent', border: 'none', fontFamily: 'ui-monospace, monospace', fontSize: 10, color: '#7a6f55', cursor: 'pointer', letterSpacing: 1 }, + drawer: { position: 'fixed', top: 0, right: 0, bottom: 0, width: 380, background: '#fffdf2', borderLeft: '2px solid #1a1a1a', boxShadow: '-6px 0 16px rgba(0,0,0,0.15)', display: 'flex', flexDirection: 'column', zIndex: 40 }, + drawerHeader: { display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '14px 16px', borderBottom: '1.5px solid #1a1a1a', background: '#fef3c7' }, + drawerBody: { flex: 1, overflowY: 'auto', padding: '10px 14px', display: 'flex', flexDirection: 'column', gap: 8 }, + drawerRow: { display: 'grid', gridTemplateColumns: '60px 1fr', gridTemplateRows: 'auto auto', columnGap: 10, paddingBottom: 6, borderBottom: '1px dotted #d4cba8' }, + drawerTime: { fontFamily: 'ui-monospace, monospace', fontSize: 9, color: '#7a6f55', gridRow: '1 / 3', paddingTop: 2 }, + drawerWho: { fontFamily: 'ui-monospace, monospace', fontSize: 10, color: '#3a2f15', letterSpacing: 1 }, + drawerText: { fontFamily: 'Georgia, serif', fontSize: 13, color: '#3a2f15', lineHeight: 1.4 }, +}; diff --git a/nextjs-site/pages/_app.tsx b/nextjs-site/pages/_app.tsx index b9f1b27..8c7b032 100644 --- a/nextjs-site/pages/_app.tsx +++ b/nextjs-site/pages/_app.tsx @@ -4,15 +4,24 @@ import '@/styles/syntax.css'; import '@/styles/custom.css'; import '@/styles/globals.css'; import type { AppProps } from 'next/app'; +import type { NextPage } from 'next'; +import type { ReactElement, ReactNode } from 'react'; import { FontProvider } from '@/contexts/FontContext'; import Layout from '@/components/Layout'; -export default function App({ Component, pageProps }: AppProps) { +export type NextPageWithLayout

= NextPage & { + getLayout?: (page: ReactElement) => ReactNode; +}; + +type AppPropsWithLayout = AppProps & { + Component: NextPageWithLayout; +}; + +export default function App({ Component, pageProps }: AppPropsWithLayout) { + const getLayout = Component.getLayout ?? ((page) => {page}); return ( - - - + {getLayout()} ); } diff --git a/nextjs-site/pages/town.tsx b/nextjs-site/pages/town.tsx new file mode 100644 index 0000000..7b4b5b2 --- /dev/null +++ b/nextjs-site/pages/town.tsx @@ -0,0 +1,28 @@ +import dynamic from 'next/dynamic'; +import Head from 'next/head'; +import type { ReactElement } from 'react'; +import type { NextPageWithLayout } from './_app'; + +// Town uses window/requestAnimationFrame and a CDN-loaded TFJS — opt out of SSR. +const NpcTown = dynamic(() => import('@/components/NpcTown'), { ssr: false }); + +const TownPage: NextPageWithLayout = () => { + return ( + <> + + Harsh.Town — A Tiny Transformer Talks To Itself + + + + + + ); +}; + +// Bypass the global Layout — town renders full-viewport. +TownPage.getLayout = (page: ReactElement) => page; + +export default TownPage;