From e5106f84eed894be245d6d0ee68ec28b3ca3417a Mon Sep 17 00:00:00 2001 From: ekwe7 Date: Wed, 24 Jun 2026 18:13:46 +0100 Subject: [PATCH 1/7] feat(renderer): implement animated district construction sequence - Create lib/renderer/construction.ts to manage the 4-phase animation timeline. - Integrate district.unlocked SSE event listener into components/pixel-city.tsx. - Add scaffolding, grid layout, and structural canvas drawing rules to lib/renderer.ts. - Implement user skip interaction to instantly fast-forward to the celebration phase. --- TODO.md | 10 ++ lib/events/system-events.ts | 11 +- lib/renderer.ts | 60 +++++++ lib/renderer/construction.ts | 297 +++++++++++++++++++++++++++++++++++ lib/types-construction.ts | 2 + 5 files changed, 379 insertions(+), 1 deletion(-) create mode 100644 TODO.md create mode 100644 lib/renderer/construction.ts create mode 100644 lib/types-construction.ts diff --git a/TODO.md b/TODO.md new file mode 100644 index 0000000..3a3fbff --- /dev/null +++ b/TODO.md @@ -0,0 +1,10 @@ +# TODO + +## Construction animation: new district unlock (0–15s) +- [ ] Inspect existing render loop + how to overlay construction animation on specific district. +- [ ] Create `lib/renderer/construction.ts` implementing Phase1-4 rendering (grid, crane, scaffolding + sparks, background fade, window flicker, animated border stroke, typewriter label, fireworks, and agent-facing overlay). +- [x] Update `lib/renderer.ts` to add `drawScaffolding(ctx, district, progress)` export used by `construction.ts`. +- [ ] Update `components/pixel-city.tsx` to listen for SSE `district.unlocked`, start animation for matching district, and implement skip-on-click to jump to Phase4. +- [ ] Update `components/open-stellar/open-stellar-hub.tsx` so the SSE listener includes `district.unlocked` and pauses agent simulation + sets agent directions toward the constructed district during animation. +- [ ] Run TypeScript typecheck / lint (as available) to ensure changes compile. + diff --git a/lib/events/system-events.ts b/lib/events/system-events.ts index 7f673b7..e395949 100644 --- a/lib/events/system-events.ts +++ b/lib/events/system-events.ts @@ -30,12 +30,18 @@ export type SystemEvent = | (BaseEvent & { type: "payment.received"; agentId: string; receipt: X402Receipt }) | (BaseEvent & { type: "agent.xp"; agentId: string; xp: number; level: number }) | (BaseEvent & { type: "badge.unlocked"; agentId: string; badge: Badge }) + | (BaseEvent & { type: "district.unlocked"; districtId?: import("@/lib/types").DistrictId; district?: import("@/lib/types").District }) + + export type PublishedSystemEvent = SystemEvent & { id: string occurredAt: string + // Some events are not agent-scoped. + agentId?: string } + type EventListener = (event: PublishedSystemEvent) => void interface EventBusState { @@ -70,9 +76,12 @@ export function ensurePublishedEvent(event: SystemEvent): PublishedSystemEvent { } export function eventMatchesAgent(event: PublishedSystemEvent, agentId?: string) { - return !agentId || event.agentId === agentId + // If an event is not agent-scoped (e.g. district unlock), allow it to pass when no agentId filter is set. + if (!agentId) return true + return event.agentId === agentId } + export function publishSystemEvent(event: SystemEvent): PublishedSystemEvent { const published = ensurePublishedEvent(event) for (const listener of eventBus.listeners) { diff --git a/lib/renderer.ts b/lib/renderer.ts index a57facf..9d2604d 100644 --- a/lib/renderer.ts +++ b/lib/renderer.ts @@ -408,6 +408,66 @@ export function drawGrid(ctx: CanvasRenderingContext2D, w: number, h: number) { } } +export function drawScaffolding(ctx: CanvasRenderingContext2D, district: District, progress01: number) { + // Simple scaffolding helper used by the construction animator. + // Draws "rising" building silhouettes with 1px/2px scaffolding hints. + const p = Math.max(0, Math.min(1, progress01)) + const buildingsBaseY = district.y + district.h - 64 + const bx1 = district.x + 8 + const bx2 = bx1 + 38 + const bx3 = district.x + district.w - 42 + + const buildingSets = [ + { x: bx1, w: 30, h: 52 }, + { x: bx2, w: 24, h: 36 }, + { x: bx3, w: 28, h: 44 }, + ] + + ctx.save() + ctx.beginPath() + const radius = 8 + ctx.moveTo(district.x + radius, district.y) + ctx.lineTo(district.x + district.w - radius, district.y) + ctx.quadraticCurveTo(district.x + district.w, district.y, district.x + district.w, district.y + radius) + ctx.lineTo(district.x + district.w, district.y + district.h - radius) + ctx.quadraticCurveTo(district.x + district.w, district.y + district.h, district.x + district.w - radius, district.y + district.h) + ctx.lineTo(district.x + radius, district.y + district.h) + ctx.quadraticCurveTo(district.x, district.y + district.h, district.x, district.y + district.h - radius) + ctx.lineTo(district.x, district.y + radius) + ctx.quadraticCurveTo(district.x, district.y, district.x + radius, district.y) + ctx.closePath() + ctx.clip() + + for (let i = 0; i < buildingSets.length; i++) { + const b = buildingSets[i] + const stagger = i * 0.14 + const local = Math.max(0, Math.min(1, (p - stagger) / (1 - stagger))) + const hNow = 2 + (b.h - 2) * local + const topY = buildingsBaseY - hNow + + // 1px scaffolding "spine" + ctx.strokeStyle = `${district.color}aa` + ctx.lineWidth = 1 + ctx.beginPath() + ctx.moveTo(b.x + 6, buildingsBaseY) + ctx.lineTo(b.x + 6, buildingsBaseY - Math.max(1, Math.floor(hNow))) + ctx.stroke() + + // Render building body clipped to current height + const prevAlpha = ctx.globalAlpha + ctx.globalAlpha = 0.2 + 0.8 * local + ctx.save() + ctx.beginPath() + ctx.rect(b.x, topY, b.w, hNow) + ctx.clip() + drawBuilding(ctx, b.x, buildingsBaseY - b.h, b.w, b.h, district.color, 0) + ctx.restore() + ctx.globalAlpha = prevAlpha + } + + ctx.restore() +} + export function drawRoads(ctx: CanvasRenderingContext2D, districts: District[]) { // Road shadows ctx.strokeStyle = "#0a0e17" diff --git a/lib/renderer/construction.ts b/lib/renderer/construction.ts new file mode 100644 index 0000000..14de385 --- /dev/null +++ b/lib/renderer/construction.ts @@ -0,0 +1,297 @@ +import type { District } from "../types" +import { drawBuilding } from "../renderer" + +function lighten(hex: string, amount: number): string { + const r = Math.min(255, parseInt(hex.slice(1, 3), 16) + amount) + const g = Math.min(255, parseInt(hex.slice(3, 5), 16) + amount) + const b = Math.min(255, parseInt(hex.slice(5, 7), 16) + amount) + return `#${r.toString(16).padStart(2, "0")}${g.toString(16).padStart(2, "0")}${b.toString(16).padStart(2, "0")}` +} + +const PHASE_1_MS = 3000 +const PHASE_2_MS = 4000 +const PHASE_3_MS = 5000 +const PHASE_4_MS = 3000 +const TOTAL_MS = PHASE_1_MS + PHASE_2_MS + PHASE_3_MS + PHASE_4_MS + +function clamp01(n: number) { + return Math.max(0, Math.min(1, n)) +} + +function lerp(a: number, b: number, t: number) { + return a + (b - a) * t +} + +function easeOutCubic(t: number) { + return 1 - Math.pow(1 - t, 3) +} + +function frac(ms: number, totalMs: number) { + if (totalMs <= 0) return 1 + return clamp01(ms / totalMs) +} + +function drawGridReveal(ctx: CanvasRenderingContext2D, d: District, t: number) { + const rows = Math.max(6, Math.floor(d.h / 24)) + const cols = Math.max(6, Math.floor(d.w / 24)) + const reveal = Math.floor(lerp(0, rows * cols, easeOutCubic(t))) + + const wStep = d.w / cols + const hStep = d.h / rows + + ctx.save() + ctx.globalAlpha = lerp(0, 1, t) + ctx.strokeStyle = `${d.color}aa` + ctx.lineWidth = 1 + + let count = 0 + for (let r = 0; r < rows; r++) { + for (let c = 0; c < cols; c++) { + if (count++ > reveal) break + const x = d.x + c * wStep + const y = d.y + r * hStep + ctx.strokeRect(Math.round(x), Math.round(y), Math.ceil(wStep), Math.ceil(hStep)) + if (count > reveal) break + } + if (count > reveal) break + } + + ctx.restore() +} + +function drawCrane(ctx: CanvasRenderingContext2D, d: District, t: number) { + const appear = easeOutCubic(clamp01(t / 0.35)) + const x0 = d.x + 10 + const y0 = d.y + 14 + const bob = Math.sin(t * 10) * 1.5 + + ctx.save() + ctx.globalAlpha = appear + ctx.strokeStyle = `${d.color}ff` + ctx.lineWidth = 2 + + ctx.fillStyle = `${d.color}33` + ctx.fillRect(x0 - 2, y0 + 18, 6, 4) + + ctx.beginPath() + ctx.moveTo(x0 + 1, y0 + 20) + ctx.lineTo(x0 + 1, y0 + bob) + ctx.stroke() + + const armT = clamp01((t - 0.1) / 0.6) + const armLen = lerp(0, 22, easeOutCubic(armT)) + ctx.beginPath() + ctx.moveTo(x0 + 1, y0 + bob) + ctx.lineTo(x0 + 1 + armLen, y0 + bob - 10) + ctx.stroke() + + ctx.fillStyle = `${d.color}ff` + ctx.fillRect(x0 + 1 + armLen - 2, y0 + bob - 10, 4, 4) + + ctx.restore() +} + +export function drawConstruction( + ctx: CanvasRenderingContext2D, + district: District, + progress01: number, + tick: number, + opts?: { + backgroundImage?: HTMLImageElement + } +) { + const p = clamp01(progress01) + const elapsedMs = p * TOTAL_MS + + const p1 = Math.min(PHASE_1_MS, elapsedMs) + const p2 = Math.min(PHASE_2_MS, Math.max(0, elapsedMs - PHASE_1_MS)) + const p3 = Math.min(PHASE_3_MS, Math.max(0, elapsedMs - PHASE_1_MS - PHASE_2_MS)) + const p4 = Math.max(0, elapsedMs - PHASE_1_MS - PHASE_2_MS - PHASE_3_MS) + + if (elapsedMs <= PHASE_1_MS) { + drawGridReveal(ctx, district, frac(p1, PHASE_1_MS)) + drawCrane(ctx, district, frac(p1, PHASE_1_MS)) + return + } + + drawGridReveal(ctx, district, 1) + drawCrane(ctx, district, 1) + + // Phase 2 (3–7s): scaffolding + sparks + buildings rise + const scaffT = frac(p2, PHASE_2_MS) + const buildingsBaseY = district.y + district.h - 64 + const bx1 = district.x + 8 + const bx2 = bx1 + 38 + const bx3 = district.x + district.w - 42 + + const buildingSets = [ + { x: bx1, w: 30, h: 52 }, + { x: bx2, w: 24, h: 36 }, + { x: bx3, w: 28, h: 44 }, + ] + + ctx.save() + ctx.beginPath() + const radius = 8 + ctx.moveTo(district.x + radius, district.y) + ctx.lineTo(district.x + district.w - radius, district.y) + ctx.quadraticCurveTo(district.x + district.w, district.y, district.x + district.w, district.y + radius) + ctx.lineTo(district.x + district.w, district.y + district.h - radius) + ctx.quadraticCurveTo(district.x + district.w, district.y + district.h, district.x + district.w - radius, district.y + district.h) + ctx.lineTo(district.x + radius, district.y + district.h) + ctx.quadraticCurveTo(district.x, district.y + district.h, district.x, district.y + district.h - radius) + ctx.lineTo(district.x, district.y + radius) + ctx.quadraticCurveTo(district.x, district.y, district.x + radius, district.y) + ctx.closePath() + ctx.clip() + + // Sparks + const sparksCount = 18 + const sparkIntensity = easeOutCubic(scaffT) + for (let i = 0; i < sparksCount; i++) { + const seed = i * 91.17 + const localT = (tick * 0.02 + seed) % 1 + const sx = lerp(district.x + 10, district.x + district.w - 10, (Math.sin(seed) * 0.5 + 0.5)) + const siteY = buildingsBaseY + 40 - (Math.sin(seed * 0.3 + tick * 0.05) * 2) + const fall = localT < sparkIntensity ? localT / Math.max(0.001, sparkIntensity) : 1 + const sy = lerp(siteY - 8, siteY + 26, fall) + const len = lerp(10, 2, fall) + const alpha = lerp(0, 0.9, sparkIntensity) * (1 - fall) + + ctx.strokeStyle = `rgba(249,115,22,${alpha.toFixed(3)})` + ctx.lineWidth = 1 + ctx.beginPath() + ctx.moveTo(sx, sy) + ctx.lineTo(sx, sy + len) + ctx.stroke() + } + + // Buildings + for (let i = 0; i < buildingSets.length; i++) { + const b = buildingSets[i] + const stagger = i * 0.14 + const local = clamp01((scaffT - stagger) / (1 - stagger)) + const hNow = lerp(2, b.h, easeOutCubic(local)) + const topY = buildingsBaseY - hNow + + ctx.strokeStyle = `${district.color}aa` + ctx.lineWidth = 1 + ctx.beginPath() + ctx.moveTo(b.x + 6, buildingsBaseY) + ctx.lineTo(b.x + 6, buildingsBaseY - Math.max(1, Math.floor(hNow))) + ctx.stroke() + + const prevAlpha = ctx.globalAlpha + ctx.globalAlpha = lerp(0.2, 1, local) + + ctx.save() + ctx.beginPath() + ctx.rect(b.x, topY, b.w, hNow) + ctx.clip() + drawBuilding(ctx, b.x, buildingsBaseY - b.h, b.w, b.h, district.color, tick) + ctx.restore() + + ctx.globalAlpha = prevAlpha + } + + ctx.restore() + + // Phase 3 (7–12s): decoration + const decoT = frac(p3, PHASE_3_MS) + if (decoT > 0) { + const bgImage = opts?.backgroundImage + ctx.save() + const fade = easeOutCubic(decoT) + ctx.globalAlpha = fade * 0.95 + + if (bgImage) { + ctx.drawImage(bgImage, district.x, district.y, district.w, district.h) + ctx.fillStyle = district.bgColor + "cc" + ctx.fillRect(district.x, district.y, district.w, district.h) + } else { + ctx.fillStyle = district.bgColor + ctx.fillRect(district.x, district.y, district.w, district.h) + } + + ctx.restore() + + // Animated border stroke (approx via lineDash) + const strokeProgress = easeOutCubic(decoT) + ctx.save() + ctx.strokeStyle = district.color + ctx.lineWidth = 2 + ctx.globalAlpha = fade + + const pathLen = 2 * (district.w + district.h) + const dash = pathLen * strokeProgress + ctx.setLineDash([dash, pathLen]) + ctx.lineDashOffset = 0 + + const radius2 = 8 + ctx.beginPath() + ctx.moveTo(district.x + radius2, district.y) + ctx.lineTo(district.x + district.w - radius2, district.y) + ctx.quadraticCurveTo(district.x + district.w, district.y, district.x + district.w, district.y + radius2) + ctx.lineTo(district.x + district.w, district.y + district.h - radius2) + ctx.quadraticCurveTo(district.x + district.w, district.y + district.h, district.x + district.w - radius2, district.y + district.h) + ctx.lineTo(district.x + radius2, district.y + district.h) + ctx.quadraticCurveTo(district.x, district.y + district.h, district.x, district.y + district.h - radius2) + ctx.lineTo(district.x, district.y + radius2) + ctx.quadraticCurveTo(district.x, district.y, district.x + radius2, district.y) + ctx.closePath() + ctx.stroke() + + ctx.setLineDash([]) + ctx.restore() + + // Typewriter sign + const signReveal = clamp01((decoT - 0.55) / 0.45) + if (signReveal > 0) { + ctx.save() + const name = district.name.toUpperCase() + const shown = name.slice(0, Math.max(0, Math.floor(name.length * signReveal))) + + ctx.font = "bold 10px monospace" + const labelW = ctx.measureText(name).width + 12 + ctx.fillStyle = district.bgColor + "dd" + ctx.fillRect(district.x + 6, district.y + 6, labelW, 18) + ctx.fillStyle = district.color + ctx.globalAlpha = fade + ctx.fillText(shown, district.x + 12, district.y + 18) + ctx.restore() + } + } + + // Phase 4 (12–15s): celebration fireworks + const doneT = frac(p4, PHASE_4_MS) + if (doneT >= 0 && doneT <= 1) { + // Draw fireworks after a small threshold + if (doneT > 0.35) { + drawFireworks(ctx, district, tick) + } + } +} + +function drawFireworks(ctx: CanvasRenderingContext2D, d: District, tick: number) { + const cx = d.x + d.w / 2 + const cy = d.y + 20 + const particles = 28 + + ctx.save() + for (let i = 0; i < particles; i++) { + const seed = i * 12.345 + const ang = ((Math.sin(seed) * 0.5 + 0.5) * Math.PI * 2) + const speed = 12 + (Math.sin(seed * 2) * 0.5 + 0.5) * 22 + const local = (tick * 0.08 + i * 0.07) % 1 + const r = speed * local + const x = cx + Math.cos(ang) * r + const y = cy + Math.sin(ang) * r - local * 10 + + const alpha = Math.max(0, 1 - local) + const hue = (i * 9 + tick * 3) % 360 + ctx.fillStyle = `hsla(${hue}, 90%, 60%, ${alpha})` + ctx.fillRect(x, y, 2, 2) + } + ctx.restore() +} + diff --git a/lib/types-construction.ts b/lib/types-construction.ts new file mode 100644 index 0000000..6ab1f64 --- /dev/null +++ b/lib/types-construction.ts @@ -0,0 +1,2 @@ +import type { DistrictId } from "./lib/types" + From 69f8a420ac4fe8591cb1c7b8d32a8b355b5f9e22 Mon Sep 17 00:00:00 2001 From: ekwe7 Date: Wed, 24 Jun 2026 22:36:55 +0100 Subject: [PATCH 2/7] feat(audio): add per-district day/night ambient city soundtrack engine Adds CityAudioEngine (Web Audio API, no deps) with looping day/night ambient beds per district, viewport-weighted spatial mixing, and one-shot event stings for task completion, payments, level-ups, badges, district wins, and agent errors. Ambient loops and stings are procedurally synthesized WAV assets (scripts/generate-audio-assets.mjs) checked into public/audio/. Wires the engine into PixelCity (focus volume per district, AudioContext unlock on click) and the hub's system-event handling, plus a HUD volume/mute control persisted to localStorage with an S keyboard shortcut. --- components/audio-controls.tsx | 148 ++++++ components/open-stellar/open-stellar-hub.tsx | 31 +- components/pixel-city.tsx | 27 +- lib/audio/city-audio.ts | 230 +++++++++ package.json | 3 +- public/audio/districts/comm-hub-day.wav | Bin 0 -> 352844 bytes public/audio/districts/comm-hub-night.wav | Bin 0 -> 352844 bytes public/audio/districts/data-center-day.wav | Bin 0 -> 352844 bytes public/audio/districts/data-center-night.wav | Bin 0 -> 352844 bytes public/audio/districts/defense-day.wav | Bin 0 -> 352844 bytes public/audio/districts/defense-night.wav | Bin 0 -> 352844 bytes public/audio/districts/processing-day.wav | Bin 0 -> 352844 bytes public/audio/districts/processing-night.wav | Bin 0 -> 352844 bytes public/audio/districts/research-day.wav | Bin 0 -> 352844 bytes public/audio/districts/research-night.wav | Bin 0 -> 352844 bytes public/audio/events/agent-error.wav | Bin 0 -> 17684 bytes public/audio/events/badge-unlock.wav | Bin 0 -> 105884 bytes public/audio/events/district-win.wav | Bin 0 -> 176444 bytes public/audio/events/level-up.wav | Bin 0 -> 70604 bytes public/audio/events/payment-received.wav | Bin 0 -> 26504 bytes public/audio/events/task-complete.wav | Bin 0 -> 4454 bytes scripts/generate-audio-assets.mjs | 461 +++++++++++++++++++ 22 files changed, 895 insertions(+), 5 deletions(-) create mode 100644 components/audio-controls.tsx create mode 100644 lib/audio/city-audio.ts create mode 100644 public/audio/districts/comm-hub-day.wav create mode 100644 public/audio/districts/comm-hub-night.wav create mode 100644 public/audio/districts/data-center-day.wav create mode 100644 public/audio/districts/data-center-night.wav create mode 100644 public/audio/districts/defense-day.wav create mode 100644 public/audio/districts/defense-night.wav create mode 100644 public/audio/districts/processing-day.wav create mode 100644 public/audio/districts/processing-night.wav create mode 100644 public/audio/districts/research-day.wav create mode 100644 public/audio/districts/research-night.wav create mode 100644 public/audio/events/agent-error.wav create mode 100644 public/audio/events/badge-unlock.wav create mode 100644 public/audio/events/district-win.wav create mode 100644 public/audio/events/level-up.wav create mode 100644 public/audio/events/payment-received.wav create mode 100644 public/audio/events/task-complete.wav create mode 100644 scripts/generate-audio-assets.mjs diff --git a/components/audio-controls.tsx b/components/audio-controls.tsx new file mode 100644 index 0000000..223d06f --- /dev/null +++ b/components/audio-controls.tsx @@ -0,0 +1,148 @@ +"use client" + +import { useCallback, useEffect, useState } from "react" +import { Volume2, VolumeX } from "lucide-react" +import { Slider } from "@/components/ui/slider" +import type { CityAudioEngine } from "@/lib/audio/city-audio" + +const VOLUME_KEY = "city-volume" +const MUTED_KEY = "city-muted" +const DEFAULT_VOLUME = 0.7 + +interface AudioControlsProps { + engine: CityAudioEngine +} + +function readStoredVolume(): number { + if (typeof window === "undefined") return DEFAULT_VOLUME + const stored = window.localStorage.getItem(VOLUME_KEY) + const parsed = stored !== null ? Number.parseFloat(stored) : NaN + return Number.isFinite(parsed) ? Math.max(0, Math.min(1, parsed)) : DEFAULT_VOLUME +} + +function readStoredMuted(): boolean { + if (typeof window === "undefined") return false + return window.localStorage.getItem(MUTED_KEY) === "true" +} + +export function AudioControls({ engine }: AudioControlsProps) { + const [volume, setVolume] = useState(DEFAULT_VOLUME) + const [muted, setMuted] = useState(false) + const [hovered, setHovered] = useState(false) + + // Apply persisted preferences to the engine once on mount. + useEffect(() => { + const initialVolume = readStoredVolume() + const initialMuted = readStoredMuted() + setVolume(initialVolume) + setMuted(initialMuted) + engine.setVolume(initialVolume) + engine.setMuted(initialMuted) + }, [engine]) + + // Unlock the AudioContext on the first user gesture anywhere on the page. + useEffect(() => { + const unlock = () => engine.init() + window.addEventListener("pointerdown", unlock, { once: true }) + window.addEventListener("keydown", unlock, { once: true }) + return () => { + window.removeEventListener("pointerdown", unlock) + window.removeEventListener("keydown", unlock) + } + }, [engine]) + + const toggleMuted = useCallback(() => { + setMuted((prev) => { + const next = !prev + engine.setMuted(next) + window.localStorage.setItem(MUTED_KEY, String(next)) + return next + }) + }, [engine]) + + // "S" toggles mute, ignored while typing in a text field. + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key.toLowerCase() !== "s") return + const target = e.target as HTMLElement | null + if (target && (target.tagName === "INPUT" || target.tagName === "TEXTAREA" || target.isContentEditable)) return + toggleMuted() + } + window.addEventListener("keydown", handleKeyDown) + return () => window.removeEventListener("keydown", handleKeyDown) + }, [toggleMuted]) + + const handleVolumeChange = useCallback( + ([next]: number[]) => { + setVolume(next) + engine.setVolume(next) + window.localStorage.setItem(VOLUME_KEY, String(next)) + }, + [engine], + ) + + return ( +
setHovered(true)} + onMouseLeave={() => setHovered(false)} + onFocus={() => setHovered(true)} + onBlur={() => setHovered(false)} + style={{ + position: "absolute", + bottom: 16, + right: 16, + zIndex: 20, + display: "flex", + alignItems: "center", + gap: 8, + padding: "8px 10px", + background: "rgba(3,7,18,0.85)", + border: "1px solid #2a3a52", + borderRadius: 8, + boxShadow: "0 4px 20px rgba(0,0,0,0.4)", + }} + > + +
+ +
+
+ ) +} diff --git a/components/open-stellar/open-stellar-hub.tsx b/components/open-stellar/open-stellar-hub.tsx index 6108599..da5d677 100644 --- a/components/open-stellar/open-stellar-hub.tsx +++ b/components/open-stellar/open-stellar-hub.tsx @@ -5,6 +5,8 @@ import { toast } from "sonner" import { PixelCity, type FloatingOverlay, type TxAnimation } from "@/components/pixel-city" import { SidebarPanel } from "@/components/sidebar-panel" import { PriceTicker } from "@/components/price-display" +import { AudioControls } from "@/components/audio-controls" +import { CityAudioEngine } from "@/lib/audio/city-audio" import { DISTRICTS, createAgents, generateChatMessage, getRandomTask } from "@/lib/data" import type { PublishedSystemEvent } from "@/lib/events/system-events" import type { ChatMessage, LogEntry, MoltbotAgent, WalletTransaction } from "@/lib/types" @@ -186,6 +188,11 @@ export function OpenStellarHub() { const [eventStreamConnected, setEventStreamConnected] = useState(false) const [hasRealtimeEvents, setHasRealtimeEvents] = useState(false) const fallbackLoggedRef = useRef(false) + const [audioEngine] = useState(() => new CityAudioEngine()) + + useEffect(() => { + return () => audioEngine.dispose() + }, [audioEngine]) // Show onboarding once on first visit useEffect(() => { @@ -329,6 +336,7 @@ export function OpenStellarHub() { ) if (event.type === "task.completed") { + audioEngine.playEvent("task_complete") pushLog(`task completed: ${event.taskId} — ${event.result.summary}`, "success", event.agentId) if (animatedAgent) { animateAgentToDistrict(animatedAgent) @@ -338,6 +346,7 @@ export function OpenStellarHub() { } if (event.type === "payment.received") { + audioEngine.playEvent("payment_received") pushLog(`payment received on ${event.receipt.chain}: ${event.receipt.txHash.slice(0, 12)}...`, "success", event.agentId) const amount = event.receipt.amountUsd ? `$${event.receipt.amountUsd.toFixed(3)}` : event.receipt.chain toast.success("Payment received", { description: `${event.agentId} settled ${amount}` }) @@ -349,6 +358,7 @@ export function OpenStellarHub() { } if (event.type === "agent.xp") { + audioEngine.playEvent("level_up") pushLog(`XP update: +${event.xp}, level ${event.level}`, "success", event.agentId) const agent = agentsRef.current.find((candidate) => candidate.id === event.agentId) if (agent) showAgentOverlay(agent, `+${event.xp} XP`, "#22d3ee") @@ -356,6 +366,7 @@ export function OpenStellarHub() { } if (event.type === "badge.unlocked") { + audioEngine.playEvent("badge_unlock") pushLog(`badge unlocked: ${event.badge.name}`, "success", event.agentId) toast.success("Badge unlocked", { description: `${event.agentId}: ${event.badge.name}` }) const agent = agentsRef.current.find((candidate) => candidate.id === event.agentId) @@ -363,13 +374,25 @@ export function OpenStellarHub() { return } + if (event.type === "district.unlocked") { + audioEngine.playEvent("district_win") + const districtName = event.district?.name ?? event.districtId ?? "a district" + pushLog(`district unlocked: ${districtName}`, "success", event.agentId ?? "system") + toast.success("District unlocked", { description: String(districtName) }) + return + } + if (event.type === "task.started") { pushLog(`task started: ${event.task.title}`, "info", event.agentId) return } - pushLog(`status changed: ${event.status}`, "info", event.agentId) - }, [animateAgentToDistrict, pushLog, showAgentOverlay]) + if (event.type === "agent.status") { + if (event.status === "error") audioEngine.playEvent("agent_error") + pushLog(`status changed: ${event.status}`, "info", event.agentId) + return + } + }, [animateAgentToDistrict, audioEngine, pushLog, showAgentOverlay]) useEffect(() => { const eventSource = new EventSource("/api/events") @@ -380,6 +403,7 @@ export function OpenStellarHub() { "payment.received", "agent.xp", "badge.unlocked", + "district.unlocked", ] const handleEvent = (message: MessageEvent) => { @@ -641,8 +665,11 @@ export function OpenStellarHub() { colorBlindMode={colorBlindMode} reduceMotion={reduceMotion} floatingOverlays={floatingOverlays} + audioEngine={audioEngine} /> + + {/* Sidebar toggle button */} + ) + })} + + + + {/* Accessories */} +
+
+ Accessories +
+
+ {ACCESSORIES.map((accessory) => { + const unlocked = unlockedAccessories.has(accessory.id) + const equipped = agent.appearance.accessories.includes(accessory.id) + const badge = BADGES.find((b) => b.id === accessory.badgeId) + return ( + + ) + })} +
+
+ + {/* Color customization */} +
+
+ Agent Color +
+
+ setColorInput(e.target.value)} + aria-label="Pick agent color" + style={{ width: 36, height: 36, padding: 0, border: "1px solid #1e293b", borderRadius: 4, cursor: "pointer", background: "none" }} + /> + setColorInput(e.target.value)} + placeholder="#22d3ee" + aria-label="Agent color hex value" + style={{ + flex: 1, + background: "#111827", + border: "1px solid #1e293b", + borderRadius: 4, + padding: "6px 8px", + fontFamily: "monospace", + fontSize: 11, + color: "#e2e8f0", + outline: "none", + }} + /> +
+ +
+ Paid to the protocol treasury via Freighter, Stellar Testnet +
+
+ + {error && ( +
+ {error} +
+ )} + + ) +} diff --git a/components/open-stellar/open-stellar-hub.tsx b/components/open-stellar/open-stellar-hub.tsx index da5d677..b2a5351 100644 --- a/components/open-stellar/open-stellar-hub.tsx +++ b/components/open-stellar/open-stellar-hub.tsx @@ -9,7 +9,7 @@ import { AudioControls } from "@/components/audio-controls" import { CityAudioEngine } from "@/lib/audio/city-audio" import { DISTRICTS, createAgents, generateChatMessage, getRandomTask } from "@/lib/data" import type { PublishedSystemEvent } from "@/lib/events/system-events" -import type { ChatMessage, LogEntry, MoltbotAgent, WalletTransaction } from "@/lib/types" +import type { AgentAppearance, ChatMessage, LogEntry, MoltbotAgent, WalletTransaction } from "@/lib/types" function nowTime() { return new Date().toLocaleTimeString("en-US", { @@ -625,6 +625,16 @@ export function OpenStellarHub() { }) }, [pushLog]) + const handleUpdateAgentAppearance = useCallback((agentId: string, appearance: AgentAppearance) => { + setAgents((prev) => + prev.map((agent) => + agent.id === agentId + ? { ...agent, appearance, color: appearance.customColor || agent.color } + : agent, + ), + ) + }, []) + const handleAddTransaction = useCallback((tx: WalletTransaction) => { setTransactions((prev) => [tx, ...prev.slice(0, 99)]) pushLog(`tx ${tx.fromName} -> ${tx.toName} (${tx.amount} XLM)`, "success", tx.fromName) @@ -708,6 +718,7 @@ export function OpenStellarHub() { onSelectAgent={handleSelectAgent} onUpdateAgent={handleUpdateAgentWallet} onAddTransaction={handleAddTransaction} + onUpdateAgentAppearance={handleUpdateAgentAppearance} colorBlindMode={colorBlindMode} onColorBlindModeChange={handleColorBlindModeChange} /> diff --git a/components/pixel-city.tsx b/components/pixel-city.tsx index ec78bbe..47b137d 100644 --- a/components/pixel-city.tsx +++ b/components/pixel-city.tsx @@ -13,7 +13,7 @@ const BG_IMAGES: Record = { research: "/bg-research.jpg", } -interface SpriteConfig { +export interface SpriteConfig { path: string crop?: [number, number, number, number] } @@ -28,7 +28,7 @@ export interface FloatingOverlay { duration: number } -const SPRITE_CONFIGS: SpriteConfig[] = [ +export const SPRITE_CONFIGS: SpriteConfig[] = [ { path: "/sprites/robot-tv.gif" }, { path: "/sprites/robot-tank.gif" }, { path: "/sprites/robot-blue.gif", crop: [0.3, 0.5, 0.4, 0.5] }, diff --git a/components/sidebar-panel.tsx b/components/sidebar-panel.tsx index 246ca71..a035bf5 100644 --- a/components/sidebar-panel.tsx +++ b/components/sidebar-panel.tsx @@ -3,20 +3,22 @@ import { useState, useEffect, type ReactNode } from "react" import { Copy, Download, Share2 } from "lucide-react" import { toast } from "sonner" -import type { MoltbotAgent, LogEntry, ChatMessage, WalletTransaction } from "@/lib/types" +import type { AgentAppearance, MoltbotAgent, LogEntry, ChatMessage, WalletTransaction } from "@/lib/types" import { DISTRICTS } from "@/lib/data" import { formatAgentShareText, getAgentOgPath, getAgentProfilePath, slugifyAgent } from "@/lib/og-card-data" import { ChatPanel } from "./chat-panel" import { SkillsPanel } from "./skills-panel" import { WalletPanel } from "./wallet-panel" +import { AppearancePanel } from "./appearance-panel" -type TabId = "overview" | "chat" | "skills" | "wallet" +type TabId = "overview" | "chat" | "skills" | "wallet" | "appearance" const TABS: { id: TabId; label: string }[] = [ { id: "overview", label: "Overview" }, { id: "chat", label: "Chat" }, { id: "skills", label: "Skills" }, { id: "wallet", label: "Wallet" }, + { id: "appearance", label: "Appearance" }, ] interface SidebarPanelProps { @@ -28,6 +30,7 @@ interface SidebarPanelProps { onSelectAgent: (id: string | null) => void onUpdateAgent: (agentId: string, wallet: MoltbotAgent["wallet"]) => void onAddTransaction: (tx: WalletTransaction) => void + onUpdateAgentAppearance: (agentId: string, appearance: AgentAppearance) => void colorBlindMode: boolean onColorBlindModeChange: (enabled: boolean) => void } @@ -420,6 +423,7 @@ export function SidebarPanel({ onSelectAgent, onUpdateAgent, onAddTransaction, + onUpdateAgentAppearance, colorBlindMode, onColorBlindModeChange, }: SidebarPanelProps) { @@ -577,6 +581,13 @@ export function SidebarPanel({ onAddTransaction={onAddTransaction} /> )} + {activeTab === "appearance" && ( + + )} ) diff --git a/lib/agents/agent-appearance-store.ts b/lib/agents/agent-appearance-store.ts new file mode 100644 index 0000000..52858c3 --- /dev/null +++ b/lib/agents/agent-appearance-store.ts @@ -0,0 +1,40 @@ +import type { AccessoryId, AgentAppearance, SkinId } from "@/lib/types" +import { defaultAppearance } from "@/lib/cosmetics" + +type AppearanceDb = Map + +const globalAppearance = globalThis as typeof globalThis & { + __openStellarAppearanceDb__?: AppearanceDb +} + +const db: AppearanceDb = globalAppearance.__openStellarAppearanceDb__ ?? new Map() +if (!globalAppearance.__openStellarAppearanceDb__) { + globalAppearance.__openStellarAppearanceDb__ = db +} + +export function getAgentAppearance(agentId: string): AgentAppearance { + const current = db.get(agentId) + return current ? { ...current, accessories: [...current.accessories] } : defaultAppearance() +} + +export function setAgentSkin(agentId: string, skin: SkinId): AgentAppearance { + const next = { ...getAgentAppearance(agentId), skin } + db.set(agentId, next) + return next +} + +export function setAgentAccessories(agentId: string, accessories: AccessoryId[]): AgentAppearance { + const next = { ...getAgentAppearance(agentId), accessories } + db.set(agentId, next) + return next +} + +export function setAgentCustomColor(agentId: string, color: string): AgentAppearance { + const next = { ...getAgentAppearance(agentId), customColor: color } + db.set(agentId, next) + return next +} + +export function resetAgentAppearanceStore() { + db.clear() +} diff --git a/lib/cosmetics.ts b/lib/cosmetics.ts new file mode 100644 index 0000000..7f546bb --- /dev/null +++ b/lib/cosmetics.ts @@ -0,0 +1,112 @@ +import type { AccessoryId, AgentAppearance, MoltbotAgent, SkinId } from "./types" +import { getAgentCardStats } from "./og-card-data" + +export interface SkinDef { + id: SkinId + name: string + levelRequired: number + description: string +} + +export const SKINS: SkinDef[] = [ + { id: "default", name: "Default", levelRequired: 1, description: "Standard robot sprite" }, + { id: "neon", name: "Neon", levelRequired: 10, description: "Color outline matching district color" }, + { id: "chrome", name: "Chrome", levelRequired: 20, description: "Metallic sheen overlay" }, + { id: "hologram", name: "Hologram", levelRequired: 30, description: "Semi-transparent with scanline effect" }, + { id: "gold", name: "Gold", levelRequired: 40, description: "Gold tint + sparkle frame" }, + { id: "legendary", name: "Legendary", levelRequired: 50, description: "Animated aura + particle trail" }, +] + +export interface BadgeDef { + id: string + name: string + description: string + isUnlocked: (agent: MoltbotAgent, allAgents: MoltbotAgent[]) => boolean +} + +export const BADGES: BadgeDef[] = [ + { + id: "district-legend", + name: "District Legend", + description: "Most tasks completed of any agent in your district", + isUnlocked: (agent, allAgents) => { + const peers = allAgents.filter((a) => a.district === agent.district) + if (peers.length === 0) return false + const max = Math.max(...peers.map((a) => a.tasksCompleted)) + return agent.tasksCompleted > 0 && agent.tasksCompleted === max + }, + }, + { + id: "speed-demon", + name: "Speed Demon", + description: "Reached max level in a skill", + isUnlocked: (agent) => agent.skills.some((s) => s.level >= s.maxLevel), + }, + { + id: "ironclad", + name: "Ironclad", + description: "Auto-restart enabled with zero downtime", + isUnlocked: (agent) => Boolean(agent.autoRestart) && agent.status !== "offline" && agent.status !== "error", + }, + { + id: "thousand-tasks", + name: "1000 Tasks", + description: "Completed 1000 tasks", + isUnlocked: (agent) => agent.tasksCompleted >= 1000, + }, + { + id: "zk-certified", + name: "ZK Certified", + description: "Wallet funded and verified on Stellar testnet", + isUnlocked: (agent) => Boolean(agent.wallet?.funded), + }, +] + +export interface AccessoryDef { + id: AccessoryId + name: string + emoji: string + badgeId: string +} + +export const ACCESSORIES: AccessoryDef[] = [ + { id: "crown", name: "Crown", emoji: "\u{1F451}", badgeId: "district-legend" }, + { id: "lightning", name: "Lightning Bolt", emoji: "⚡", badgeId: "speed-demon" }, + { id: "shield", name: "Shield", emoji: "\u{1F6E1}️", badgeId: "ironclad" }, + { id: "diamond", name: "Diamond", emoji: "\u{1F48E}", badgeId: "thousand-tasks" }, + { id: "zk-lock", name: "ZK Lock", emoji: "\u{1F510}", badgeId: "zk-certified" }, +] + +export const COLOR_CHANGE_COST_XLM = "0.5" + +export function defaultAppearance(): AgentAppearance { + return { skin: "default", accessories: [], customColor: null } +} + +export function getAgentLevel(agent: MoltbotAgent): number { + return getAgentCardStats(agent).level +} + +export function getUnlockedBadgeIds(agent: MoltbotAgent, allAgents: MoltbotAgent[]): string[] { + return BADGES.filter((badge) => badge.isUnlocked(agent, allAgents)).map((badge) => badge.id) +} + +export function getUnlockedSkinIds(agent: MoltbotAgent): SkinId[] { + const level = getAgentLevel(agent) + return SKINS.filter((skin) => level >= skin.levelRequired).map((skin) => skin.id) +} + +export function getUnlockedAccessoryIds(agent: MoltbotAgent, allAgents: MoltbotAgent[]): AccessoryId[] { + const badgeIds = new Set(getUnlockedBadgeIds(agent, allAgents)) + return ACCESSORIES.filter((accessory) => badgeIds.has(accessory.badgeId)).map((accessory) => accessory.id) +} + +export function isSkinUnlockedForLevel(skinId: SkinId, level: number): boolean { + const skin = SKINS.find((s) => s.id === skinId) + return Boolean(skin && level >= skin.levelRequired) +} + +export function isAccessoryUnlockedForBadges(accessoryId: AccessoryId, badgeIds: string[]): boolean { + const accessory = ACCESSORIES.find((a) => a.id === accessoryId) + return Boolean(accessory && badgeIds.includes(accessory.badgeId)) +} diff --git a/lib/data.ts b/lib/data.ts index da5f687..f5a65db 100644 --- a/lib/data.ts +++ b/lib/data.ts @@ -170,6 +170,7 @@ export function createAgents(): MoltbotAgent[] { spriteId: i % SPRITE_COUNT, skills: generateSkills(district.id), autoRestart: i % 3 === 0, + appearance: { skin: "default", accessories: [], customColor: null }, } }) } diff --git a/lib/renderer.ts b/lib/renderer.ts index 9d2604d..64ead2a 100644 --- a/lib/renderer.ts +++ b/lib/renderer.ts @@ -1,4 +1,6 @@ import type { MoltbotAgent, District } from "./types" +import { DISTRICTS } from "./data" +import { ACCESSORIES } from "./cosmetics" const PIXEL = 2 @@ -256,6 +258,132 @@ function getProcessedSprite( return out } +// Decorative overlay drawn on top of the (already-rendered) sprite for skins unlocked above level 1. +// Hologram transparency is handled separately at draw time since it must affect the sprite itself. +function drawSkinOverlay( + ctx: CanvasRenderingContext2D, + agent: MoltbotAgent, + tick: number, + drawX: number, + drawY: number, + spriteSize: number, + effectiveColor: string, +) { + const skin = agent.appearance?.skin ?? "default" + if (skin === "default" || skin === "legendary") return + + if (skin === "neon") { + const district = DISTRICTS.find((d) => d.id === agent.district) + const outlineColor = district?.color ?? effectiveColor + ctx.save() + ctx.strokeStyle = outlineColor + "cc" + ctx.lineWidth = 1.5 + ctx.shadowColor = outlineColor + ctx.shadowBlur = 6 + ctx.strokeRect(drawX + 1, drawY + 1, spriteSize - 2, spriteSize - 2) + ctx.restore() + return + } + + if (skin === "chrome") { + ctx.save() + const pos = (Math.sin(tick * 0.04) + 1) / 2 + const sheen = ctx.createLinearGradient(drawX, drawY, drawX + spriteSize, drawY + spriteSize) + sheen.addColorStop(Math.max(0, pos - 0.18), "rgba(255,255,255,0)") + sheen.addColorStop(pos, "rgba(255,255,255,0.45)") + sheen.addColorStop(Math.min(1, pos + 0.18), "rgba(255,255,255,0)") + ctx.globalCompositeOperation = "screen" + ctx.fillStyle = sheen + ctx.fillRect(drawX, drawY, spriteSize, spriteSize) + ctx.restore() + return + } + + if (skin === "hologram") { + ctx.save() + ctx.strokeStyle = "rgba(125,211,252,0.5)" + ctx.lineWidth = 1 + for (let ly = drawY; ly < drawY + spriteSize; ly += 3) { + ctx.beginPath() + ctx.moveTo(drawX, ly) + ctx.lineTo(drawX + spriteSize, ly) + ctx.stroke() + } + ctx.restore() + return + } + + if (skin === "gold") { + ctx.save() + ctx.globalCompositeOperation = "multiply" + ctx.globalAlpha = 0.35 + ctx.fillStyle = "#facc15" + ctx.fillRect(drawX, drawY, spriteSize, spriteSize) + ctx.restore() + + const twinkle = Math.sin(tick * 0.2) > 0.3 + if (twinkle) { + ctx.save() + ctx.fillStyle = "#fde68a" + ctx.fillRect(drawX - 1, drawY - 1, 2, 2) + ctx.fillRect(drawX + spriteSize - 1, drawY - 1, 2, 2) + ctx.fillRect(drawX - 1, drawY + spriteSize - 1, 2, 2) + ctx.fillRect(drawX + spriteSize - 1, drawY + spriteSize - 1, 2, 2) + ctx.restore() + } + } +} + +// Equipped accessory glyphs (badge-gated), rendered in a row above the sprite. +function drawAccessories( + ctx: CanvasRenderingContext2D, + agent: MoltbotAgent, + drawX: number, + drawY: number, + spriteSize: number, +) { + const accessories = agent.appearance?.accessories ?? [] + if (accessories.length === 0) return + + ctx.save() + ctx.font = "10px sans-serif" + ctx.textAlign = "center" + ctx.textBaseline = "middle" + const cx = drawX + spriteSize / 2 + accessories.forEach((id, i) => { + const def = ACCESSORIES.find((a) => a.id === id) + if (!def) return + const slotX = cx + (i - (accessories.length - 1) / 2) * 11 + ctx.fillText(def.emoji, slotX, drawY - 4) + }) + ctx.restore() +} + +// Orbiting particle trail + soft glow ring, only for the top-tier Legendary skin. +function drawAuraParticles(ctx: CanvasRenderingContext2D, agent: MoltbotAgent, tick: number, cx: number, cy: number, color: string) { + if ((agent.appearance?.skin ?? "default") !== "legendary") return + + ctx.save() + const particleCount = 6 + for (let i = 0; i < particleCount; i++) { + const angle = tick * 0.04 + (i / particleCount) * Math.PI * 2 + const radius = 18 + Math.sin(tick * 0.08 + i) * 3 + const px = cx + Math.cos(angle) * radius + const py = cy + Math.sin(angle) * radius * 0.6 + const alpha = Math.max(0, Math.min(1, 0.4 + Math.sin(tick * 0.1 + i) * 0.3)) + ctx.fillStyle = color + Math.round(alpha * 255).toString(16).padStart(2, "0") + ctx.beginPath() + ctx.arc(px, py, 1.4, 0, Math.PI * 2) + ctx.fill() + } + ctx.strokeStyle = color + "33" + ctx.lineWidth = 2 + ctx.beginPath() + ctx.arc(cx, cy, 22 + Math.sin(tick * 0.06) * 2, 0, Math.PI * 2) + ctx.stroke() + ctx.restore() +} + export function drawBot(ctx: CanvasRenderingContext2D, agent: MoltbotAgent, tick: number, isSelected: boolean, sprite?: HTMLImageElement, cropRegion?: [number, number, number, number]) { const x = Math.round(agent.pixelX) const y = Math.round(agent.pixelY) @@ -299,9 +427,15 @@ export function drawBot(ctx: CanvasRenderingContext2D, agent: MoltbotAgent, tick const drawY = y + bobY - 4 const drawX = x - spriteSize / 2 + 8 + const skinId = agent.appearance?.skin ?? "default" + if (sprite) { const tinted = getProcessedSprite(sprite, c, cropRegion) ctx.save() + // Hologram skin: semi-transparent, gently flickering + if (skinId === "hologram") { + ctx.globalAlpha = 0.55 + Math.sin(tick * 0.1) * 0.15 + } // Flip horizontally if facing left if (agent.direction === "left") { ctx.translate(drawX + spriteSize, 0) @@ -325,6 +459,11 @@ export function drawBot(ctx: CanvasRenderingContext2D, agent: MoltbotAgent, tick ctx.fillRect(drawX, drawY, spriteSize, spriteSize) } } + + // Cosmetic layers: skin overlay, equipped accessories, legendary aura + drawSkinOverlay(ctx, agent, tick, drawX, drawY, spriteSize, c) + drawAccessories(ctx, agent, drawX, drawY, spriteSize) + drawAuraParticles(ctx, agent, tick, cx, drawY + spriteSize / 2, c) } else { // Minimal fallback if sprite hasn't loaded drawRect(ctx, x + 2, y + bobY, 12, 16, c) diff --git a/lib/stellar.ts b/lib/stellar.ts index 6cdbe70..a4b4946 100644 --- a/lib/stellar.ts +++ b/lib/stellar.ts @@ -5,3 +5,7 @@ export const STELLAR_TESTNET_HORIZON = "https://horizon-testnet.stellar.org" export const STELLAR_TESTNET_PASSPHRASE = "Test SDF Network ; September 2015" export const FRIENDBOT_URL = "https://friendbot.stellar.org" + +// Protocol treasury wallet that receives paid cosmetic customizations (e.g. agent color changes). +// Must be set per-environment -- there is no safe default testnet address to fall back to. +export const PROTOCOL_TREASURY_ADDRESS = process.env.STELLAR_TREASURY_ADDRESS || "" diff --git a/lib/types-construction.ts b/lib/types-construction.ts index 6ab1f64..a077f40 100644 --- a/lib/types-construction.ts +++ b/lib/types-construction.ts @@ -1,2 +1,2 @@ -import type { DistrictId } from "./lib/types" +import type { DistrictId } from "./types" diff --git a/lib/types.ts b/lib/types.ts index 3951d9d..b4ce328 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -2,6 +2,16 @@ export type AgentStatus = "active" | "idle" | "working" | "error" | "offline" export type DistrictId = "data-center" | "comm-hub" | "processing" | "defense" | "research" +export type SkinId = "default" | "neon" | "chrome" | "hologram" | "gold" | "legendary" + +export type AccessoryId = "crown" | "lightning" | "shield" | "diamond" | "zk-lock" + +export interface AgentAppearance { + skin: SkinId + accessories: AccessoryId[] + customColor: string | null +} + export interface Skill { id: string name: string @@ -60,6 +70,7 @@ export interface MoltbotAgent { lastHeartbeat?: string offlineForSeconds?: number wallet?: StellarWallet + appearance: AgentAppearance } export interface District { From 634b157e10c52780bacb413c160f080113b48b61 Mon Sep 17 00:00:00 2001 From: ekwe7 Date: Thu, 25 Jun 2026 00:00:37 +0100 Subject: [PATCH 4/7] ci: don't fail the preview workflow if posting the PR comment is forbidden The preview comment is informational only. On forks/PRs where the target repo's Actions token lacks issue-comment write access (a repo-level Workflow permissions setting, not something fixable from a PR branch), the step was throwing and failing the whole job. Catch and warn instead. Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/preview.yml | 58 ++++++++++++++++++++--------------- 1 file changed, 33 insertions(+), 25 deletions(-) diff --git a/.github/workflows/preview.yml b/.github/workflows/preview.yml index 2060352..e87eb25 100644 --- a/.github/workflows/preview.yml +++ b/.github/workflows/preview.yml @@ -28,34 +28,42 @@ jobs: VERCEL_PREVIEW_URL: ${{ vars.VERCEL_PREVIEW_URL }} with: script: | - const marker = ''; - const previewUrl = process.env.VERCEL_PREVIEW_URL; - const body = previewUrl - ? `${marker}\nVercel preview: ${previewUrl}` - : `${marker}\nVercel will publish the deployment preview on this PR when the Vercel GitHub integration is enabled.`; + // Posting this comment is informational only -- it must never fail the workflow run. + // A 403 here means the target repo's Actions token doesn't have issue-comment write + // access (often a repo/org "Workflow permissions" setting), which a contributor's PR + // branch can't fix. Log and move on instead of failing CI on a cosmetic step. + try { + const marker = ''; + const previewUrl = process.env.VERCEL_PREVIEW_URL; + const body = previewUrl + ? `${marker}\nVercel preview: ${previewUrl}` + : `${marker}\nVercel will publish the deployment preview on this PR when the Vercel GitHub integration is enabled.`; - const { owner, repo } = context.repo; - const issue_number = context.payload.pull_request.number; - const comments = await github.paginate(github.rest.issues.listComments, { - owner, - repo, - issue_number, - per_page: 100, - }); - const existing = comments.find((comment) => comment.body?.includes(marker)); - - if (existing) { - await github.rest.issues.updateComment({ - owner, - repo, - comment_id: existing.id, - body, - }); - } else { - await github.rest.issues.createComment({ + const { owner, repo } = context.repo; + const issue_number = context.payload.pull_request.number; + const comments = await github.paginate(github.rest.issues.listComments, { owner, repo, issue_number, - body, + per_page: 100, }); + const existing = comments.find((comment) => comment.body?.includes(marker)); + + if (existing) { + await github.rest.issues.updateComment({ + owner, + repo, + comment_id: existing.id, + body, + }); + } else { + await github.rest.issues.createComment({ + owner, + repo, + issue_number, + body, + }); + } + } catch (error) { + core.warning(`Could not post preview comment: ${error.message}`); } From 6c223e574b6fe89e9188fcb680543e693e04b268 Mon Sep 17 00:00:00 2001 From: ekwe7 Date: Thu, 25 Jun 2026 14:54:36 +0100 Subject: [PATCH 5/7] feat(renderer): add canvas particle system for XP, payments, level-ups, badges, district wins Adds lib/renderer/particles.ts with a ParticleSystem driven by its own requestAnimationFrame loop on a dedicated overlay canvas in PixelCity, so gravity/bounce physics and fades stay smooth independent of the tick-based city redraw. SSE events (task.completed, payment.received, agent.xp, badge.unlocked, district.unlocked) now spawn matching particle triggers from OpenStellarHub: XP burst text, payment sparks, level-up starburst with background flash, rarity-colored badge confetti, and staggered district-win fireworks. Co-Authored-By: Claude Sonnet 4.6 --- components/open-stellar/open-stellar-hub.tsx | 90 +++- components/pixel-city.tsx | 80 ++++ lib/renderer/particles.ts | 437 +++++++++++++++++++ 3 files changed, 593 insertions(+), 14 deletions(-) create mode 100644 lib/renderer/particles.ts diff --git a/components/open-stellar/open-stellar-hub.tsx b/components/open-stellar/open-stellar-hub.tsx index b2a5351..ee2980d 100644 --- a/components/open-stellar/open-stellar-hub.tsx +++ b/components/open-stellar/open-stellar-hub.tsx @@ -2,7 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react" import { toast } from "sonner" -import { PixelCity, type FloatingOverlay, type TxAnimation } from "@/components/pixel-city" +import { PixelCity, type FloatingOverlay, type ParticleTrigger, type TxAnimation } from "@/components/pixel-city" import { SidebarPanel } from "@/components/sidebar-panel" import { PriceTicker } from "@/components/price-display" import { AudioControls } from "@/components/audio-controls" @@ -181,6 +181,8 @@ export function OpenStellarHub() { const [tick, setTick] = useState(0) const [txAnimations, setTxAnimations] = useState([]) const [floatingOverlays, setFloatingOverlays] = useState([]) + const [particleTriggers, setParticleTriggers] = useState([]) + const agentLevelsRef = useRef>(new Map()) const [sidebarOpen, setSidebarOpen] = useState(true) const [showOnboarding, setShowOnboarding] = useState(false) const [colorBlindMode, setColorBlindMode] = useState(false) @@ -292,8 +294,24 @@ export function OpenStellarHub() { ]) }, []) + const spawnParticles = useCallback( + (type: ParticleTrigger["type"], x: number, y: number, opts?: ParticleTrigger["opts"]) => { + setParticleTriggers((prev) => [ + ...prev, + { + id: Date.now() + Math.floor(Math.random() * 1000), + type, + x, + y, + opts, + }, + ]) + }, + [] + ) + const applySystemEvent = useCallback((event: PublishedSystemEvent) => { - let animatedAgent: MoltbotAgent | null = null + const animatedAgentBox: { current: MoltbotAgent | null } = { current: null } setAgents((prev) => prev.map((agent) => { @@ -313,7 +331,7 @@ export function OpenStellarHub() { } if (event.type === "task.completed") { - animatedAgent = agent + animatedAgentBox.current = agent return { ...agent, status: "active", @@ -324,7 +342,7 @@ export function OpenStellarHub() { } if (event.type === "payment.received") { - animatedAgent = agent + animatedAgentBox.current = agent return { ...agent, status: "active", @@ -338,9 +356,14 @@ export function OpenStellarHub() { if (event.type === "task.completed") { audioEngine.playEvent("task_complete") pushLog(`task completed: ${event.taskId} — ${event.result.summary}`, "success", event.agentId) - if (animatedAgent) { - animateAgentToDistrict(animatedAgent) - showAgentOverlay(animatedAgent, "+task", "#34d399") + const agent = animatedAgentBox.current + if (agent) { + animateAgentToDistrict(agent) + showAgentOverlay(agent, "+task", "#34d399") + const district = DISTRICTS.find((candidate) => candidate.id === agent.district) + spawnParticles("xp-burst", agent.pixelX + 8, agent.pixelY, { + color: district?.color ?? agent.color, + }) } return } @@ -350,9 +373,14 @@ export function OpenStellarHub() { pushLog(`payment received on ${event.receipt.chain}: ${event.receipt.txHash.slice(0, 12)}...`, "success", event.agentId) const amount = event.receipt.amountUsd ? `$${event.receipt.amountUsd.toFixed(3)}` : event.receipt.chain toast.success("Payment received", { description: `${event.agentId} settled ${amount}` }) - if (animatedAgent) { - animateAgentToDistrict(animatedAgent) - showAgentOverlay(animatedAgent, `+${amount}`, "#fbbf24") + const agent = animatedAgentBox.current + if (agent) { + animateAgentToDistrict(agent) + showAgentOverlay(agent, `+${amount}`, "#fbbf24") + const xlmAmount = event.receipt.amountUnits ? `+${event.receipt.amountUnits} XLM` : "+0.01 XLM" + spawnParticles("payment-spark", agent.pixelX + 8, agent.pixelY + 10, { + amount: xlmAmount, + }) } return } @@ -361,7 +389,17 @@ export function OpenStellarHub() { audioEngine.playEvent("level_up") pushLog(`XP update: +${event.xp}, level ${event.level}`, "success", event.agentId) const agent = agentsRef.current.find((candidate) => candidate.id === event.agentId) - if (agent) showAgentOverlay(agent, `+${event.xp} XP`, "#22d3ee") + if (agent) { + showAgentOverlay(agent, `+${event.xp} XP`, "#22d3ee") + const previousLevel = agentLevelsRef.current.get(event.agentId) ?? event.level + if (event.level > previousLevel) { + spawnParticles("level-up", agent.pixelX + 8, agent.pixelY, { + color: agent.color, + level: event.level, + }) + } + agentLevelsRef.current.set(event.agentId, event.level) + } return } @@ -370,15 +408,28 @@ export function OpenStellarHub() { pushLog(`badge unlocked: ${event.badge.name}`, "success", event.agentId) toast.success("Badge unlocked", { description: `${event.agentId}: ${event.badge.name}` }) const agent = agentsRef.current.find((candidate) => candidate.id === event.agentId) - if (agent) showAgentOverlay(agent, event.badge.name, "#a78bfa") + if (agent) { + showAgentOverlay(agent, event.badge.name, "#a78bfa") + spawnParticles("badge-unlock", agent.pixelX + 8, agent.pixelY, { + rarity: event.badge.rarity ?? "common", + }) + } return } if (event.type === "district.unlocked") { audioEngine.playEvent("district_win") - const districtName = event.district?.name ?? event.districtId ?? "a district" + const districtId = event.districtId ?? event.district?.id + const district = DISTRICTS.find((candidate) => candidate.id === districtId) + const districtName = event.district?.name ?? district?.name ?? districtId ?? "a district" pushLog(`district unlocked: ${districtName}`, "success", event.agentId ?? "system") toast.success("District unlocked", { description: String(districtName) }) + if (district) { + spawnParticles("district-win", district.x + district.w / 2, district.y, { + color: district.color, + spreadW: district.w * 0.7, + }) + } return } @@ -392,7 +443,7 @@ export function OpenStellarHub() { pushLog(`status changed: ${event.status}`, "info", event.agentId) return } - }, [animateAgentToDistrict, audioEngine, pushLog, showAgentOverlay]) + }, [animateAgentToDistrict, audioEngine, pushLog, showAgentOverlay, spawnParticles]) useEffect(() => { const eventSource = new EventSource("/api/events") @@ -605,6 +656,16 @@ export function OpenStellarHub() { return () => window.clearInterval(id) }, [floatingOverlays.length]) + // Particle triggers are one-shot — PixelCity consumes them into its ParticleSystem on + // receipt, so this just garbage-collects the request objects shortly after. + useEffect(() => { + if (particleTriggers.length === 0) return + const id = window.setTimeout(() => { + setParticleTriggers([]) + }, 500) + return () => window.clearTimeout(id) + }, [particleTriggers]) + const handleSelectAgent = useCallback((id: string | null) => { setSelectedAgentId(id) @@ -675,6 +736,7 @@ export function OpenStellarHub() { colorBlindMode={colorBlindMode} reduceMotion={reduceMotion} floatingOverlays={floatingOverlays} + particleTriggers={particleTriggers} audioEngine={audioEngine} /> diff --git a/components/pixel-city.tsx b/components/pixel-city.tsx index 47b137d..d83553a 100644 --- a/components/pixel-city.tsx +++ b/components/pixel-city.tsx @@ -3,6 +3,7 @@ import { useRef, useEffect, useCallback, useState } from "react" import type { MoltbotAgent, District } from "@/lib/types" import { drawGrid, drawRoads, drawDistrict, drawBot } from "@/lib/renderer" +import { ParticleSystem, type ParticleEvent, type ParticleOpts } from "@/lib/renderer/particles" import type { CityAudioEngine } from "@/lib/audio/city-audio" const BG_IMAGES: Record = { @@ -48,6 +49,14 @@ export interface TxAnimation { duration: number } +export interface ParticleTrigger { + id: number + type: ParticleEvent + x: number + y: number + opts?: ParticleOpts +} + interface PixelCityProps { agents: MoltbotAgent[] districts: District[] @@ -58,6 +67,7 @@ interface PixelCityProps { colorBlindMode?: boolean reduceMotion?: boolean floatingOverlays?: FloatingOverlay[] + particleTriggers?: ParticleTrigger[] audioEngine?: CityAudioEngine } @@ -79,10 +89,14 @@ export function PixelCity({ colorBlindMode = false, reduceMotion = false, floatingOverlays = [], + particleTriggers = [], audioEngine, }: PixelCityProps) { const canvasRef = useRef(null) + const particleCanvasRef = useRef(null) const containerRef = useRef(null) + const particleSystemRef = useRef(new ParticleSystem()) + const processedParticleIdsRef = useRef>(new Set()) const [images, setImages] = useState>({}) const [sprites, setSprites] = useState([]) const spriteCrops = useRef<(([number, number, number, number]) | undefined)[]>([]) @@ -127,6 +141,7 @@ export function PixelCity({ const resizeCanvas = useCallback(() => { const canvas = canvasRef.current + const particleCanvas = particleCanvasRef.current const container = containerRef.current if (!canvas || !container) return const dpr = window.devicePixelRatio || 1 @@ -137,6 +152,15 @@ export function PixelCity({ canvas.style.height = `${rect.height}px` const ctx = canvas.getContext("2d") if (ctx) ctx.scale(dpr, dpr) + + if (particleCanvas) { + particleCanvas.width = rect.width * dpr + particleCanvas.height = rect.height * dpr + particleCanvas.style.width = `${rect.width}px` + particleCanvas.style.height = `${rect.height}px` + const pctx = particleCanvas.getContext("2d") + if (pctx) pctx.scale(dpr, dpr) + } }, []) useEffect(() => { @@ -145,6 +169,50 @@ export function PixelCity({ return () => window.removeEventListener("resize", resizeCanvas) }, [resizeCanvas]) + // Consume declarative particle triggers fired from SSE events (XP, payments, level-ups, badges, district wins). + useEffect(() => { + const system = particleSystemRef.current + const currentIds = new Set(particleTriggers.map((trigger) => trigger.id)) + + for (const trigger of particleTriggers) { + if (processedParticleIdsRef.current.has(trigger.id)) continue + processedParticleIdsRef.current.add(trigger.id) + system.emit(trigger.type, trigger.x, trigger.y, trigger.opts) + } + + for (const id of processedParticleIdsRef.current) { + if (!currentIds.has(id)) processedParticleIdsRef.current.delete(id) + } + }, [particleTriggers]) + + // Drive the particle system on its own animation-frame loop, independent of the + // tick-driven city redraw, so physics (gravity, bounce, rise/fade) stay smooth. + useEffect(() => { + if (reduceMotion) return + let frameId: number + let lastTime = performance.now() + + const loop = (now: number) => { + const dt = Math.min(now - lastTime, 50) + lastTime = now + const system = particleSystemRef.current + system.update(dt) + + const canvas = particleCanvasRef.current + const ctx = canvas?.getContext("2d") + if (ctx && canvas) { + const dpr = window.devicePixelRatio || 1 + ctx.clearRect(0, 0, canvas.width / dpr, canvas.height / dpr) + system.draw(ctx) + } + + frameId = requestAnimationFrame(loop) + } + + frameId = requestAnimationFrame(loop) + return () => cancelAnimationFrame(frameId) + }, [reduceMotion]) + useEffect(() => { const canvas = canvasRef.current if (!canvas) return @@ -337,6 +405,18 @@ export function PixelCity({ zIndex: 1, }} /> +