feat(ui): paint identity polish — quiet HUD, loud finish
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 <noreply@anthropic.com>
This commit is contained in:
parent
37a363cf13
commit
1fa069729a
43
src/game.ts
43
src/game.ts
@ -12,7 +12,8 @@ import { createBlob, type BlobHandle } from './blob/createBlob'
|
|||||||
import { createBlobController } from './blob/controller'
|
import { createBlobController } from './blob/controller'
|
||||||
import { createBlobFeel } from './blob/feel'
|
import { createBlobFeel } from './blob/feel'
|
||||||
import { createFollowCamera } from './blob/camera'
|
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 {
|
import {
|
||||||
createBucketDump, createBubbleArch, createConveyorBelt, createPaintMine, createColorGate,
|
createBucketDump, createBubbleArch, createConveyorBelt, createPaintMine, createColorGate,
|
||||||
createPaintBubbles, SURFACES, applySurface,
|
createPaintBubbles, SURFACES, applySurface,
|
||||||
@ -272,6 +273,37 @@ export function installGame(world: World) {
|
|||||||
document.body.appendChild(banner)
|
document.body.appendChild(banner)
|
||||||
const fmt = (s: number) => s.toFixed(2) + 's'
|
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<PaintColor, number>): 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
|
let finishHold = 0
|
||||||
world.addSystem({
|
world.addSystem({
|
||||||
update(dt) {
|
update(dt) {
|
||||||
@ -295,11 +327,20 @@ export function installGame(world: World) {
|
|||||||
best = raceT
|
best = raceT
|
||||||
localStorage.setItem(bestKey, String(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
|
||||||
|
? `<div style="font-size:24px;color:${PALETTE[dom]};text-shadow:0 0 12px ${PALETTE[dom]}">` +
|
||||||
|
`you finished ${(cov.byColor[dom] * 100).toFixed(0)}% ${dom.toUpperCase()}</div>`
|
||||||
|
: '<div style="font-size:24px;opacity:.9">you finished squeaky clean</div>'
|
||||||
banner.innerHTML =
|
banner.innerHTML =
|
||||||
`<div>🏁 FINISH — ${fmt(raceT)}</div>` +
|
`<div>🏁 FINISH — ${fmt(raceT)}</div>` +
|
||||||
|
identity +
|
||||||
`<div style="font-size:22px">${isBest ? 'NEW BEST!' : 'best ' + fmt(best!)}</div>` +
|
`<div style="font-size:22px">${isBest ? 'NEW BEST!' : 'best ' + fmt(best!)}</div>` +
|
||||||
`<div style="font-size:16px;opacity:.85">R / Start to race again — auto in 6s</div>`
|
`<div style="font-size:16px;opacity:.85">R / Start to race again — auto in 6s</div>`
|
||||||
banner.style.display = 'flex'
|
banner.style.display = 'flex'
|
||||||
|
confetti(cov.byColor)
|
||||||
world.events.emit('race:finished', { time: raceT, best })
|
world.events.emit('race:finished', { time: raceT, best })
|
||||||
finishHold = 6
|
finishHold = 6
|
||||||
}
|
}
|
||||||
|
|||||||
@ -9,7 +9,7 @@ import { COLOR_ORDER, ACTIVATE, SUPER } from './coverage-math'
|
|||||||
|
|
||||||
export class PaintHUD {
|
export class PaintHUD {
|
||||||
readonly root: HTMLDivElement
|
readonly root: HTMLDivElement
|
||||||
private readonly bars = new Map<PaintColor, { fill: HTMLDivElement; pct: HTMLSpanElement }>()
|
private readonly bars = new Map<PaintColor, { row: HTMLDivElement; fill: HTMLDivElement; pct: HTMLSpanElement }>()
|
||||||
private readonly totalPct: HTMLSpanElement
|
private readonly totalPct: HTMLSpanElement
|
||||||
private readonly buffLine: HTMLDivElement
|
private readonly buffLine: HTMLDivElement
|
||||||
private lastUpdate = -1e9
|
private lastUpdate = -1e9
|
||||||
@ -50,7 +50,7 @@ export class PaintHUD {
|
|||||||
row.appendChild(pct)
|
row.appendChild(pct)
|
||||||
|
|
||||||
root.appendChild(row)
|
root.appendChild(row)
|
||||||
this.bars.set(color, { fill, pct })
|
this.bars.set(color, { row, fill, pct })
|
||||||
}
|
}
|
||||||
|
|
||||||
const totalRow = document.createElement('div')
|
const totalRow = document.createElement('div')
|
||||||
@ -76,9 +76,19 @@ export class PaintHUD {
|
|||||||
if (now - this.lastUpdate < 100) return
|
if (now - this.lastUpdate < 100) return
|
||||||
this.lastUpdate = now
|
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) {
|
for (const color of COLOR_ORDER) {
|
||||||
const b = this.bars.get(color)!
|
const b = this.bars.get(color)!
|
||||||
const v = cov.byColor[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.fill.style.width = `${Math.min(100, v * 100).toFixed(0)}%`
|
||||||
b.pct.textContent = `${(v * 100).toFixed(0)}%`
|
b.pct.textContent = `${(v * 100).toFixed(0)}%`
|
||||||
const active = v >= ACTIVATE
|
const active = v >= ACTIVATE
|
||||||
@ -95,6 +105,8 @@ export class PaintHUD {
|
|||||||
if (cov.byColor.red >= ACTIVATE) parts.push(`<b style="color:${PALETTE.red}">BURN</b> ×${mods.speedMul.toFixed(2)}`)
|
if (cov.byColor.red >= ACTIVATE) parts.push(`<b style="color:${PALETTE.red}">BURN</b> ×${mods.speedMul.toFixed(2)}`)
|
||||||
if (cov.byColor.green >= ACTIVATE) parts.push(`<b style="color:${PALETTE.green}">GRIP</b> ${mods.grip.toFixed(2)}`)
|
if (cov.byColor.green >= ACTIVATE) parts.push(`<b style="color:${PALETTE.green}">GRIP</b> ${mods.grip.toFixed(2)}`)
|
||||||
if (cov.byColor.blue >= ACTIVATE) parts.push(`<b style="color:${PALETTE.blue}">SLICK</b> waterproof`)
|
if (cov.byColor.blue >= ACTIVATE) parts.push(`<b style="color:${PALETTE.blue}">SLICK</b> waterproof`)
|
||||||
|
if (cov.byColor.purple >= ACTIVATE) parts.push(`<b style="color:${PALETTE.purple}">MEGA</b> ×${mods.size.toFixed(2)}`)
|
||||||
|
if (cov.byColor.pink >= ACTIVATE) parts.push(`<b style="color:${PALETTE.pink}">MINI</b> ×${mods.size.toFixed(2)}`)
|
||||||
parts.push(`mass ×${mods.massMul.toFixed(2)}`)
|
parts.push(`mass ×${mods.massMul.toFixed(2)}`)
|
||||||
if (mods.glow > 0) parts.push('<b style="color:#fff;text-shadow:0 0 6px #fff">★ SUPER</b>')
|
if (mods.glow > 0) parts.push('<b style="color:#fff;text-shadow:0 0 6px #fff">★ SUPER</b>')
|
||||||
return parts.length ? parts.join(' · ') : '<span style="opacity:0.5">no buffs</span>'
|
return parts.length ? parts.join(' · ') : '<span style="opacity:0.5">no buffs</span>'
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user