From 1fa069729abeba478177a58f7fd2b241e349bd1f Mon Sep 17 00:00:00 2001 From: type-two Date: Mon, 20 Jul 2026 17:41:41 +1000 Subject: [PATCH] =?UTF-8?q?feat(ui):=20paint=20identity=20polish=20?= =?UTF-8?q?=E2=80=94=20quiet=20HUD,=20loud=20finish?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The HUD now whispers and the finish line celebrates. PaintHUD hides entirely while you're clean (the body is the display — GDD pillar 2; an all-zero seven-row table was dev noise) and shows rows only for colours you actually carry. Added the missing MEGA/MINI buff labels — purple/pink have been live since Lane G but the buff line never learned their names. The finish banner now states your colour identity — "you finished 53% GREEN", tinted and glowing in that colour ("squeaky clean" if you somehow finish white) — and fires a confetti burst of falling paint chips whose colours are weighted by YOUR coverage mix. The run's identity is the celebration. Verified via world.tick: clean blob -> no HUD; green douse + cannon red -> exactly those two rows + GRIP label; teleport finish -> banner "you finished 53% GREEN" + 36 mix-tinted chips. tsc + build green. Co-Authored-By: Claude Fable 5 --- src/game.ts | 43 ++++++++++++++++++++++++++++++++++++++++++- src/paint/hud.ts | 16 ++++++++++++++-- 2 files changed, 56 insertions(+), 3 deletions(-) diff --git a/src/game.ts b/src/game.ts index a929044..495a5a7 100644 --- a/src/game.ts +++ b/src/game.ts @@ -12,7 +12,8 @@ import { createBlob, type BlobHandle } from './blob/createBlob' import { createBlobController } from './blob/controller' import { createBlobFeel } from './blob/feel' import { createFollowCamera } from './blob/camera' -import { PaintSkin, PaintCannon, BuffSystem, PaintHUD, installPaint } from './paint/index' +import { PaintSkin, PaintCannon, BuffSystem, PaintHUD, installPaint, dominantColor } from './paint/index' +import { PALETTE, type PaintColor } from './contracts' import { createBucketDump, createBubbleArch, createConveyorBelt, createPaintMine, createColorGate, createPaintBubbles, SURFACES, applySurface, @@ -272,6 +273,37 @@ export function installGame(world: World) { document.body.appendChild(banner) const fmt = (s: number) => s.toFixed(2) + 's' + // ---- finish confetti: a burst of falling paint chips tinted by YOUR mix — + // the run's colour identity is the celebration (pillar 2: you are the display). + const confetti = (byColor: Record): void => { + if (!document.getElementById('blobbo-confetti-style')) { + const st = document.createElement('style') + st.id = 'blobbo-confetti-style' + st.textContent = + '@keyframes blobbo-chip{0%{transform:translateY(-8vh) rotate(0) scale(1);opacity:1}' + + '100%{transform:translateY(105vh) rotate(720deg) scale(.8);opacity:.75}}' + document.head.appendChild(st) + } + // weighted colour bag from the mix; a clean blob gets plain white chips + const bag: string[] = [] + for (const [c, v] of Object.entries(byColor)) { + for (let i = 0; i < Math.round(v * 40); i++) bag.push(PALETTE[c as PaintColor]) + } + if (bag.length === 0) bag.push('#F5F5F7') + for (let i = 0; i < 36; i++) { + const chip = document.createElement('div') + const sz = 7 + Math.random() * 9 + chip.style.cssText = + `position:fixed;top:0;left:${(Math.random() * 100).toFixed(1)}vw;z-index:31;` + + `width:${sz.toFixed(0)}px;height:${(sz * 0.55).toFixed(0)}px;pointer-events:none;` + + `background:${bag[Math.floor(Math.random() * bag.length)]};border-radius:2px;` + + `animation:blobbo-chip ${(1.6 + Math.random() * 1.6).toFixed(2)}s ` + + `${(Math.random() * 0.5).toFixed(2)}s cubic-bezier(.2,.6,.6,1) forwards` + document.body.appendChild(chip) + window.setTimeout(() => chip.remove(), 4200) + } + } + let finishHold = 0 world.addSystem({ update(dt) { @@ -295,11 +327,20 @@ export function installGame(world: World) { best = raceT localStorage.setItem(bestKey, String(raceT)) } + // your colour identity at the line: dominant colour + how covered you are + const cov = skin.coverage() + const dom = dominantColor(cov) + const identity = dom + ? `
` + + `you finished ${(cov.byColor[dom] * 100).toFixed(0)}% ${dom.toUpperCase()}
` + : '
you finished squeaky clean
' banner.innerHTML = `
🏁 FINISH — ${fmt(raceT)}
` + + identity + `
${isBest ? 'NEW BEST!' : 'best ' + fmt(best!)}
` + `
R / Start to race again — auto in 6s
` banner.style.display = 'flex' + confetti(cov.byColor) world.events.emit('race:finished', { time: raceT, best }) finishHold = 6 } diff --git a/src/paint/hud.ts b/src/paint/hud.ts index 1cd26ed..ba04fa6 100644 --- a/src/paint/hud.ts +++ b/src/paint/hud.ts @@ -9,7 +9,7 @@ import { COLOR_ORDER, ACTIVATE, SUPER } from './coverage-math' export class PaintHUD { readonly root: HTMLDivElement - private readonly bars = new Map() + private readonly bars = new Map() private readonly totalPct: HTMLSpanElement private readonly buffLine: HTMLDivElement private lastUpdate = -1e9 @@ -50,7 +50,7 @@ export class PaintHUD { row.appendChild(pct) root.appendChild(row) - this.bars.set(color, { fill, pct }) + this.bars.set(color, { row, fill, pct }) } const totalRow = document.createElement('div') @@ -76,9 +76,19 @@ export class PaintHUD { if (now - this.lastUpdate < 100) return this.lastUpdate = now + // A clean blob shows NO panel: the body is the display (GDD pillar 2) and + // an all-zero table is dev noise. The HUD appears with your first splat. + if (cov.total < 0.005) { + this.root.style.display = 'none' + return + } + this.root.style.display = '' + for (const color of COLOR_ORDER) { const b = this.bars.get(color)! const v = cov.byColor[color] + // only the colours you actually carry get a row — your mix, not a table + b.row.style.display = v >= 0.01 ? 'flex' : 'none' b.fill.style.width = `${Math.min(100, v * 100).toFixed(0)}%` b.pct.textContent = `${(v * 100).toFixed(0)}%` const active = v >= ACTIVATE @@ -95,6 +105,8 @@ export class PaintHUD { if (cov.byColor.red >= ACTIVATE) parts.push(`BURN ×${mods.speedMul.toFixed(2)}`) if (cov.byColor.green >= ACTIVATE) parts.push(`GRIP ${mods.grip.toFixed(2)}`) if (cov.byColor.blue >= ACTIVATE) parts.push(`SLICK waterproof`) + if (cov.byColor.purple >= ACTIVATE) parts.push(`MEGA ×${mods.size.toFixed(2)}`) + if (cov.byColor.pink >= ACTIVATE) parts.push(`MINI ×${mods.size.toFixed(2)}`) parts.push(`mass ×${mods.massMul.toFixed(2)}`) if (mods.glow > 0) parts.push('★ SUPER') return parts.length ? parts.join('  ·  ') : 'no buffs'