nuts / commit
added stress ball
src/main.ts | 5 ++- src/persistence.ts | 4 +- src/physics.ts | 127 +++++++++++++++++++++++++++++++++++++++++++++------- src/renderer.ts | 128 +++++++++++++++++++++++++++++++++++++++++++++++++++++ src/share.ts | 11 +++-- src/ui.ts | 6 ++- 6 files changed, 258 insertions(+), 23 deletions(-) diff --git a/src/main.ts b/src/main.ts index 71e630b..58503c3 100644 --- a/src/main.ts +++ b/src/main.ts @@ -111,6 +111,7 @@ async function main(): Promise<void> { else if (kind === 'balloon') physics.addBalloon(x, y, r, gv) else if (kind === 'metalnut') physics.addMetalNut(x, y, r) else if (kind === 'rock') physics.addRock(x, y, r) + else if (kind === 'stressball') physics.addStressBall(x, y, r, gv) else physics.addNut(x, y, r) }, initialHasSpace, @@ -125,6 +126,7 @@ async function main(): Promise<void> { else if (n.kind === 'balloon') physics.addBalloon(x, y, n.radius, n.colorIndex) else if (n.kind === 'metalnut') physics.addMetalNut(x, y, n.radius) else if (n.kind === 'rock') physics.addRock(x, y, n.radius) + else if (n.kind === 'stressball') physics.addStressBall(x, y, n.radius, n.colorIndex) else physics.addNut(x, y, n.radius) } } @@ -198,13 +200,14 @@ async function main(): Promise<void> { if (hasSpace(cx, cy, currentNutRadius)) { let kind = currentKind if (kind === 'random') { - const pool = randomPool.length > 0 ? randomPool : ['nut', 'gem', 'balloon', 'metalnut', 'rock'] as ItemKind[] + const pool = randomPool.length > 0 ? randomPool : ['nut', 'gem', 'balloon', 'metalnut', 'rock', 'stressball'] as ItemKind[] kind = pool[Math.floor(Math.random() * pool.length)] } if (kind === 'balloon') physics.addBalloon(cx, cy, currentNutRadius) else if (kind === 'gem') physics.addGem(cx, cy, currentNutRadius) else if (kind === 'metalnut') physics.addMetalNut(cx, cy, currentNutRadius) else if (kind === 'rock') physics.addRock(cx, cy, currentNutRadius) + else if (kind === 'stressball') physics.addStressBall(cx, cy, currentNutRadius) else physics.addNut(cx, cy, currentNutRadius) } } diff --git a/src/persistence.ts b/src/persistence.ts index 05cc5f4..3680e7e 100644 --- a/src/persistence.ts +++ b/src/persistence.ts @@ -44,7 +44,7 @@ export interface NutSnapshot { angle: number radius: number // absolute px colorIndex: number - kind: 'nut' | 'gem' | 'balloon' | 'metalnut' | 'rock' + kind: 'nut' | 'gem' | 'balloon' | 'metalnut' | 'rock' | 'stressball' gemVariant: number } @@ -56,7 +56,7 @@ export interface SnapshotRecord { } export function saveSnapshot( - nuts: Array<{ body: { position: { x: number; y: number }; angle: number }; radius: number; colorIndex: number; kind: 'nut' | 'gem' | 'balloon' | 'metalnut' | 'rock'; gemVariant: number }>, + nuts: Array<{ body: { position: { x: number; y: number }; angle: number }; radius: number; colorIndex: number; kind: 'nut' | 'gem' | 'balloon' | 'metalnut' | 'rock' | 'stressball'; gemVariant: number }>, screenW: number, screenH: number, ): void { diff --git a/src/physics.ts b/src/physics.ts index 53d6c2c..e4beb25 100644 --- a/src/physics.ts +++ b/src/physics.ts @@ -12,7 +12,7 @@ export interface NutBody { body: Matter.Body radius: number colorIndex: number - kind: 'nut' | 'gem' | 'balloon' | 'metalnut' | 'rock' + kind: 'nut' | 'gem' | 'balloon' | 'metalnut' | 'rock' | 'stressball' gemVariant: number // gems: 0=diamond…4=amethyst balloons: colorIndex hitCount: number // rocks: accumulated damage templateIdx: number // rocks: which RockTemplate to use; 0 for non-rocks @@ -25,6 +25,7 @@ export interface PhysicsWorld { addNut: (x: number, y: number, nutRadius?: number) => void addGem: (x: number, y: number, radius?: number, gemVariant?: number) => void addBalloon: (x: number, y: number, radius?: number, colorIndex?: number) => void + addStressBall: (x: number, y: number, radius?: number, colorIndex?: number) => void addMetalNut: (x: number, y: number, nutRadius?: number) => void addRock: (x: number, y: number, radius?: number) => void removeAt: (x: number, y: number) => boolean @@ -89,6 +90,16 @@ function spawnMetalNut(x: number, y: number, nutRadius: number, colorIndex: numb const GEM_SIDES = [4, 6, 6, 8, 6] as const // sides per gem variant export const GEM_VARIANT_COUNT = GEM_SIDES.length export const BALLOON_COLOR_COUNT = 7 +export const STRESSBALL_COLOR_COUNT = 6 + +const STRESSBALL_COLORS = [ + '#e05050', // red + '#e07830', // orange + '#d4c020', // yellow + '#50b050', // green + '#4090d0', // blue + '#9060c0', // purple +] function spawnGem(x: number, y: number, radius: number, gemVariant: number): NutBody { const variance = 1 + (Math.random() * 2 - 1) * CONFIG.nutRadiusVariance @@ -105,19 +116,37 @@ function spawnGem(x: number, y: number, radius: number, gemVariant: number): Nut return { body, radius: actualRadius, colorIndex: 0, kind: 'gem', gemVariant, hitCount: 0, templateIdx: 0, toughness: 1 } } +const BALLOON_POP_SPEED = 18 // speed (px/tick) that instantly pops a balloon +const BALLOON_SQUISH_DECAY = 0.86 // per-physics-tick squish decay multiplier + function spawnBalloon(x: number, y: number, radius: number, colorIndex: number): NutBody { const variance = 0.5 + Math.random() * 1.0 // 0.5× to 1.5× base radius const actualRadius = radius * variance const body = Bodies.circle(x, y, actualRadius, { density: 0.00005, // air-filled balloon — nearly weightless - restitution: 0.8, - friction: 0.01, - frictionAir: 0.008, + restitution: 0.95, // very bouncy + friction: 0.005, + frictionAir: 0.006, label: 'balloon', }) return { body, radius: actualRadius, colorIndex, kind: 'balloon', gemVariant: colorIndex, hitCount: 0, templateIdx: 0, toughness: 1 } } +const STRESSBALL_SQUISH_DECAY = 0.80 // faster recovery than balloon + +function spawnStressBall(x: number, y: number, radius: number, colorIndex: number): NutBody { + const variance = 0.7 + Math.random() * 0.6 // 0.7× to 1.3× base radius + const actualRadius = radius * variance + const body = Bodies.circle(x, y, actualRadius, { + density: 0.0006, // denser than balloon — has some heft + restitution: 0.55, // bouncy but not as floaty as balloon + friction: 0.4, // grippy rubber + frictionAir: 0.015, + label: 'stressball', + }) + return { body, radius: actualRadius, colorIndex: colorIndex % STRESSBALL_COLOR_COUNT, kind: 'stressball', gemVariant: colorIndex, hitCount: 0, templateIdx: 0, toughness: 1 } +} + // ── Factory ──────────────────────────────────────────────────────────────── const ROCK_COLORS_COUNT = 4 @@ -154,20 +183,32 @@ export function createPhysicsWorld(width: number, height: number): PhysicsWorld let currentHeight = height let splitQueue: NutBody[] = [] const splittingIds = new Set<number>() + let popQueue: NutBody[] = [] + const poppingIds = new Set<number>() Composite.add(engine.world, walls) - // ── Balloon buoyancy ─────────────────────────────────────────────────── - // Apply a force opposite to the current gravity vector (2.2× magnitude), - // so balloons float against whichever direction gravity is currently pointing. + // ── Balloon buoyancy + squish decay ──────────────────────────────────── + // Balloons float against gravity (2.2× lift) and their squish decays each tick. Events.on(engine, 'beforeUpdate', () => { const scale = engine.gravity.scale * 2.2 - for (const { body, kind } of nuts) { - if (kind === 'balloon') { - Body.applyForce(body, body.position, { - x: -body.mass * engine.gravity.x * scale, - y: -body.mass * engine.gravity.y * scale, + for (const nb of nuts) { + if (nb.kind === 'balloon') { + // Decay squish (hitCount) toward zero each tick + if (nb.hitCount > 0) { + nb.hitCount *= BALLOON_SQUISH_DECAY + if (nb.hitCount < 0.005) nb.hitCount = 0 + } + // Buoyancy + Body.applyForce(nb.body, nb.body.position, { + x: -nb.body.mass * engine.gravity.x * scale, + y: -nb.body.mass * engine.gravity.y * scale, }) + } else if (nb.kind === 'stressball') { + if (nb.hitCount > 0) { + nb.hitCount *= STRESSBALL_SQUISH_DECAY + if (nb.hitCount < 0.005) nb.hitCount = 0 + } } } }) @@ -177,8 +218,8 @@ export function createPhysicsWorld(width: number, height: number): PhysicsWorld for (const pair of event.pairs) { const aWall = pair.bodyA.label === 'wall' const bWall = pair.bodyB.label === 'wall' - const aNut = pair.bodyA.label === 'nut' || pair.bodyA.label === 'gem' || pair.bodyA.label === 'balloon' || pair.bodyA.label === 'metalnut' || pair.bodyA.label === 'rock' - const bNut = pair.bodyB.label === 'nut' || pair.bodyB.label === 'gem' || pair.bodyB.label === 'balloon' || pair.bodyB.label === 'metalnut' || pair.bodyB.label === 'rock' + const aNut = pair.bodyA.label === 'nut' || pair.bodyA.label === 'gem' || pair.bodyA.label === 'balloon' || pair.bodyA.label === 'metalnut' || pair.bodyA.label === 'rock' || pair.bodyA.label === 'stressball' + const bNut = pair.bodyB.label === 'nut' || pair.bodyB.label === 'gem' || pair.bodyB.label === 'balloon' || pair.bodyB.label === 'metalnut' || pair.bodyB.label === 'rock' || pair.bodyB.label === 'stressball' const aGem = pair.bodyA.label === 'gem' || pair.bodyA.label === 'metalnut' const bGem = pair.bodyB.label === 'gem' || pair.bodyB.label === 'metalnut' @@ -207,6 +248,40 @@ export function createPhysicsWorld(width: number, height: number): PhysicsWorld if (bRock) tryQueueSplit(pair.bodyB, pair.bodyA) } + // ── Balloon squish / pop ─────────────────────────────────── + const aBalloon = pair.bodyA.label === 'balloon' + const bBalloon = pair.bodyB.label === 'balloon' + const aStress = pair.bodyA.label === 'stressball' + const bStress = pair.bodyB.label === 'stressball' + if (aBalloon || bBalloon) { + const applyPressure = (balloonBody: Matter.Body) => { + if (poppingIds.has(balloonBody.id)) return + const nb = nuts.find(n => n.body === balloonBody) + if (!nb) return + // Pressure scales as speed^1.5 so gentle bounces squish, hard hits pop + const pressure = Math.pow(speed / BALLOON_POP_SPEED, 1.5) + nb.hitCount = Math.min(nb.hitCount + pressure, 1.5) + if (nb.hitCount >= 1.0) { + poppingIds.add(balloonBody.id) + popQueue.push(nb) + } + } + if (aBalloon) applyPressure(pair.bodyA) + if (bBalloon) applyPressure(pair.bodyB) + } + + // ── Stress ball squish (no pop) ───────────────────────────── + if (aStress || bStress) { + const applySquish = (sbBody: Matter.Body) => { + const nb = nuts.find(n => n.body === sbBody) + if (!nb) return + const pressure = Math.pow(speed / 14, 1.2) + nb.hitCount = Math.min(nb.hitCount + pressure, 1.0) + } + if (aStress) applySquish(pair.bodyA) + if (bStress) applySquish(pair.bodyB) + } + if (aNut && bNut) { // Body-body collision dbg('nut-nut speed:', speed.toFixed(2)) @@ -229,7 +304,7 @@ export function createPhysicsWorld(width: number, height: number): PhysicsWorld } else if (aNut || bNut) { // Body-wall collision const nutBody = aWall ? pair.bodyB : pair.bodyA - if (nutBody.label !== 'nut' && nutBody.label !== 'gem' && nutBody.label !== 'balloon' && nutBody.label !== 'metalnut' && nutBody.label !== 'rock') continue + if (nutBody.label !== 'nut' && nutBody.label !== 'gem' && nutBody.label !== 'balloon' && nutBody.label !== 'metalnut' && nutBody.label !== 'rock' && nutBody.label !== 'stressball') continue const wallSpeed = nutBody.speed dbg('wall hit speed:', wallSpeed.toFixed(2)) if (wallSpeed > CONFIG.impactThreshold) { @@ -273,6 +348,13 @@ export function createPhysicsWorld(width: number, height: number): PhysicsWorld Composite.add(engine.world, balloon.body) } + function addStressBall(x: number, y: number, radius: number = CONFIG.nutBaseRadius, colorIndex?: number): void { + const ci = colorIndex ?? Math.floor(Math.random() * STRESSBALL_COLOR_COUNT) + const sb = spawnStressBall(x, y, radius, ci) + nuts.push(sb) + Composite.add(engine.world, sb.body) + } + function addMetalNut(x: number, y: number, nutRadius: number = CONFIG.nutBaseRadius): void { const colorIndex = nuts.length const m = spawnMetalNut(x, y, nutRadius, colorIndex) @@ -289,6 +371,19 @@ export function createPhysicsWorld(width: number, height: number): PhysicsWorld Composite.add(engine.world, rock.body) } + function processPops(): void { + if (popQueue.length === 0) return + const toPop = popQueue.splice(0) + for (const nut of toPop) { + poppingIds.delete(nut.body.id) + const idx = nuts.indexOf(nut) + if (idx === -1) continue + Composite.remove(engine.world, nut.body) + nuts.splice(idx, 1) + vibrate(70) // satisfying pop + } + } + function processSplits(): void { if (splitQueue.length === 0) return const toSplit = splitQueue.splice(0) @@ -375,6 +470,7 @@ export function createPhysicsWorld(width: number, height: number): PhysicsWorld function step(deltaMs: number): void { Engine.update(engine, Math.min(deltaMs, 50)) processSplits() + processPops() } function destroy(): void { @@ -388,6 +484,7 @@ export function createPhysicsWorld(width: number, height: number): PhysicsWorld addNut, addGem, addBalloon, + addStressBall, addMetalNut, addRock, removeAt, diff --git a/src/renderer.ts b/src/renderer.ts index 327012f..d4a8e27 100644 --- a/src/renderer.ts +++ b/src/renderer.ts @@ -439,6 +439,106 @@ function getSprite(radius: number, colorIndex: number, kind: 'nut' | 'gem' | 'me return sprite } +// ── Stress ball palette ─────────────────────────────────────────────────── +const STRESSBALL_COLORS = [ + { body: '#d94040', shadow: '#8a1515', hi: '#f08080' }, // red + { body: '#d97030', shadow: '#8a3800', hi: '#f0a878' }, // orange + { body: '#c8be18', shadow: '#807600', hi: '#e8e070' }, // yellow + { body: '#3aaa3a', shadow: '#1a6a1a', hi: '#78d878' }, // green + { body: '#3878cc', shadow: '#183a88', hi: '#80aae8' }, // blue + { body: '#8850c0', shadow: '#441878', hi: '#c094e8' }, // purple +] + +function drawStressBall( + ctx: CanvasRenderingContext2D, + x: number, y: number, + radius: number, + colorIndex: number, + squish: number, +): void { + const c = STRESSBALL_COLORS[colorIndex % STRESSBALL_COLORS.length] + const r = radius + + // Shadow + ctx.save() + ctx.filter = `blur(${Math.ceil(r * 0.12)}px)` + ctx.globalAlpha = 0.28 + ctx.fillStyle = '#000' + ctx.beginPath() + ctx.arc(x + r * 0.1, y + r * 0.12, r * 0.85, 0, Math.PI * 2) + ctx.fill() + ctx.restore() + + // Body (slightly flattened at bottom — rubber resting shape) + const grad = ctx.createRadialGradient(x - r * 0.28, y - r * 0.28, r * 0.05, x, y, r) + grad.addColorStop(0, c.hi) + grad.addColorStop(0.5, c.body) + grad.addColorStop(1, c.shadow) + ctx.beginPath() + ctx.arc(x, y, r, 0, Math.PI * 2) + ctx.fillStyle = grad + ctx.fill() + + // Subtle matte rim (no stroke, just darkened outer band) + const rimGrad = ctx.createRadialGradient(x, y, r * 0.7, x, y, r) + rimGrad.addColorStop(0, 'rgba(0,0,0,0)') + rimGrad.addColorStop(1, 'rgba(0,0,0,0.28)') + ctx.beginPath() + ctx.arc(x, y, r, 0, Math.PI * 2) + ctx.fillStyle = rimGrad + ctx.fill() + + // Dimples — small indents scattered over surface + const dimpleCount = Math.max(3, Math.floor(r / 12)) + const angleStep = (Math.PI * 2) / dimpleCount + ctx.save() + ctx.globalAlpha = 0.22 + ctx.fillStyle = c.shadow + for (let i = 0; i < dimpleCount; i++) { + const a = i * angleStep + const dr = r * 0.55 + const dx = x + Math.cos(a) * dr + const dy = y + Math.sin(a) * dr + const dimR = Math.max(2, r * 0.065) + ctx.beginPath() + ctx.arc(dx, dy, dimR, 0, Math.PI * 2) + ctx.fill() + } + ctx.restore() + + // Soft specular highlight + ctx.save() + ctx.beginPath() + ctx.arc(x, y, r, 0, Math.PI * 2) + ctx.clip() + const hiGrad = ctx.createRadialGradient(x - r * 0.28, y - r * 0.32, 0, x - r * 0.1, y - r * 0.1, r * 0.7) + hiGrad.addColorStop(0, 'rgba(255,255,255,0.45)') + hiGrad.addColorStop(0.5, 'rgba(255,255,255,0.08)') + hiGrad.addColorStop(1, 'rgba(255,255,255,0)') + ctx.fillStyle = hiGrad + ctx.fillRect(x - r, y - r, r * 2, r * 2) + ctx.restore() + + // Squish stress lines — faint concentric rings when compressed + if (squish > 0.05) { + ctx.save() + ctx.beginPath() + ctx.arc(x, y, r, 0, Math.PI * 2) + ctx.clip() + ctx.globalAlpha = squish * 0.35 + ctx.strokeStyle = c.shadow + ctx.lineWidth = Math.max(1, r * 0.04) + ctx.beginPath() + ctx.arc(x, y, r * 0.6, 0, Math.PI * 2) + ctx.stroke() + ctx.globalAlpha = squish * 0.20 + ctx.beginPath() + ctx.arc(x, y, r * 0.35, 0, Math.PI * 2) + ctx.stroke() + ctx.restore() + } +} + // ── Balloon palette ───────────────────────────────────────────────────── interface BalloonColor { body: string; hi: string; shadow: string; rim: string; string: string } const BALLOON_COLORS: BalloonColor[] = [ @@ -578,8 +678,36 @@ export function createRenderer(canvas: HTMLCanvasElement): Renderer { ctx.save() ctx.translate(position.x, position.y) ctx.rotate(gravityAngle) + // Squish deformation: compress along velocity, expand perpendicular + const squish = Math.min(hitCount, 0.85) + if (squish > 0.02) { + const vx = body.velocity.x, vy = body.velocity.y + const spd = Math.hypot(vx, vy) + if (spd > 0.5) { + const velAngle = Math.atan2(vy, vx) - gravityAngle + ctx.rotate(velAngle) + ctx.scale(1 - squish * 0.38, 1 + squish * 0.30) + ctx.rotate(-velAngle) + } + } drawBalloon(ctx, 0, 0, radius, colorIndex) ctx.restore() + } else if (kind === 'stressball') { + ctx.save() + ctx.translate(position.x, position.y) + const squish = Math.min(hitCount, 1.0) + if (squish > 0.02) { + const vx = body.velocity.x, vy = body.velocity.y + const spd = Math.hypot(vx, vy) + if (spd > 0.5) { + const velAngle = Math.atan2(vy, vx) + ctx.rotate(velAngle) + ctx.scale(1 - squish * 0.32, 1 + squish * 0.26) + ctx.rotate(-velAngle) + } + } + drawStressBall(ctx, 0, 0, radius, colorIndex, squish) + ctx.restore() } else if (kind === 'rock') { drawRock(ctx, body, radius, colorIndex, hitCount, templateIdx) } else { diff --git a/src/share.ts b/src/share.ts index 5138bf4..b192083 100644 --- a/src/share.ts +++ b/src/share.ts @@ -10,7 +10,7 @@ import type { NutBody } from './physics' export interface SharedNut { radius: number colorIndex: number - kind: 'nut' | 'gem' | 'balloon' | 'metalnut' | 'rock' + kind: 'nut' | 'gem' | 'balloon' | 'metalnut' | 'rock' | 'stressball' gemVariant: number } @@ -25,6 +25,7 @@ export function encodeNuts(nuts: NutBody[]): string { if (n.kind === 'balloon') return `r${Math.round(n.radius)}b${n.colorIndex % 7}` if (n.kind === 'metalnut') return `r${Math.round(n.radius)}m${n.colorIndex % 5}` if (n.kind === 'rock') return `r${Math.round(n.radius)}k${n.colorIndex % 4}` + if (n.kind === 'stressball') return `r${Math.round(n.radius)}s${n.colorIndex % 6}` return `r${Math.round(n.radius)}c${n.colorIndex % 5}` }).join(',') } @@ -41,6 +42,8 @@ export function decodeNuts(encoded: string): SharedNut[] | null { if (metal) return { radius: parseInt(metal[1], 10), colorIndex: parseInt(metal[2], 10), kind: 'metalnut' as const, gemVariant: 0 } const rock = part.match(/^r(\d+)k(\d+)$/) if (rock) return { radius: parseInt(rock[1], 10), colorIndex: parseInt(rock[2], 10), kind: 'rock' as const, gemVariant: 0 } + const stress = part.match(/^r(\d+)s(\d+)$/) + if (stress) return { radius: parseInt(stress[1], 10), colorIndex: parseInt(stress[2], 10), kind: 'stressball' as const, gemVariant: parseInt(stress[2], 10) } const nut = part.match(/^r(\d+)c(\d+)$/) if (nut) return { radius: parseInt(nut[1], 10), colorIndex: parseInt(nut[2], 10), kind: 'nut' as const, gemVariant: 0 } throw new Error('invalid') @@ -71,7 +74,9 @@ export function buildShareURL(nuts: NutBody[], nutRadius: number): string { ? `r${Math.round(n.radius)}m${n.colorIndex % 5}` : n.kind === 'rock' ? `r${Math.round(n.radius)}k${n.colorIndex % 4}` - : `r${Math.round(n.radius)}c${n.colorIndex % 5}` + : n.kind === 'stressball' + ? `r${Math.round(n.radius)}s${n.colorIndex % 6}` + : `r${Math.round(n.radius)}c${n.colorIndex % 5}` const candidate = base + encodeURIComponent(',' + [...parts, token].join(',')) if (candidate.length > MAX_URL_LENGTH) break parts.push(token) @@ -110,7 +115,7 @@ export function placeSharedNuts( nuts: SharedNut[], screenW: number, screenH: number, - addBody: (x: number, y: number, radius: number, kind: 'nut' | 'gem' | 'balloon' | 'metalnut' | 'rock', gemVariant: number) => void, + addBody: (x: number, y: number, radius: number, kind: 'nut' | 'gem' | 'balloon' | 'metalnut' | 'rock' | 'stressball', gemVariant: number) => void, hasSpace: (x: number, y: number, r: number) => boolean, ): void { for (const { radius, colorIndex: _ci, kind, gemVariant } of nuts) { diff --git a/src/ui.ts b/src/ui.ts index e84a7ce..4c788d4 100644 --- a/src/ui.ts +++ b/src/ui.ts @@ -4,10 +4,10 @@ import QRCode from 'qrcode' import { hasVibration } from './compat' import { setVibrateEnabled, isVibrateEnabled, setVibrateItems, isVibrateItems, setVibrateEdges, isVibrateEdges } from './vibration' -export type ItemKind = 'random' | 'nut' | 'gem' | 'balloon' | 'metalnut' | 'rock' +export type ItemKind = 'random' | 'nut' | 'gem' | 'balloon' | 'metalnut' | 'rock' | 'stressball' export type GravityMode = 'device' | 'tumbler' | 'rock' -const KIND_SEQUENCE: ItemKind[] = ['random', 'nut', 'gem', 'balloon', 'metalnut', 'rock'] +const KIND_SEQUENCE: ItemKind[] = ['random', 'nut', 'gem', 'balloon', 'metalnut', 'rock', 'stressball'] const KIND_ICON: Record<ItemKind, string> = { random: '?', nut: '🌰', @@ -15,6 +15,7 @@ const KIND_ICON: Record<ItemKind, string> = { balloon: '🎈', metalnut: '🔩', rock: '🪨', + stressball: '🟠', } export interface UICallbacks { @@ -220,6 +221,7 @@ export function initUI(callbacks: UICallbacks): void { { kind: 'balloon', label: '🎈 Balloons' }, { kind: 'metalnut', label: '🔩 Metal nuts' }, { kind: 'rock', label: '🪨 Rocks' }, + { kind: 'stressball', label: '🟠 Stress balls' }, ] let randomPool = new Set<ItemKind>(callbacks.initialRandomPool)