Onboarding clarity: labels, tutorial pointing, wire affordances, heartbeat

New players couldn't tell which sprite was which or what to click.
Now: name tags under every device (🏷 chip toggles, persisted); the
tutorial physically points — active step pulses its UI targets (wire
chip, shop items via data-def, sliders, Lab/Market buttons) and draws
amber rings around the actual devices in the room; wire mode outlines
pickable sources in cyan and valid targets in green; and the SlopTube
header shows a live "next slop N%" render progress — or an explicit
"nothing flowing — wire a PC" warning instead of silent dead air.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
type-two 2026-08-02 00:44:57 +10:00
parent 7ddd1b68d7
commit 3b5e965ae7
6 changed files with 125 additions and 9 deletions

View File

@ -65,6 +65,15 @@ async function boot() {
const syncTutChip = () => tutChip.classList.toggle("on", tutorial.enabled);
tutChip.onclick = () => { tutorial.setEnabled(!tutorial.enabled); syncTutChip(); };
syncTutChip();
const labelsChip = hud.root.querySelector("#labels-btn") as HTMLButtonElement;
const syncLabels = () => labelsChip.classList.toggle("on", scene.showLabels);
labelsChip.onclick = () => {
scene.showLabels = !scene.showLabels;
localStorage.setItem("gigaslop-labels", scene.showLabels ? "1" : "0");
syncLabels();
sfx.click();
};
syncLabels();
(hud.root.querySelector("#lab-btn") as HTMLButtonElement).onclick = () => panels.toggle("lab");
(hud.root.querySelector("#rnd-btn") as HTMLButtonElement).onclick = () => panels.toggle("rnd");
(hud.root.querySelector("#market-btn") as HTMLButtonElement).onclick = () => panels.toggle("market");
@ -155,7 +164,11 @@ async function boot() {
const ghost = shopSel && t.in
? { defId: shopSel, x: t.x, y: t.y, ok: !canPlace(state, shopSel, t.x, t.y) }
: null;
scene.render(state, ghost, 1 / 60);
scene.render(state, ghost, 1 / 60, {
wireActive: wireMode,
wireSource,
ringDefs: tutorial.ringDefs(),
});
if (wireMode && wireSource !== null) {
const src = state.devices.find((d) => d.uid === wireSource);
scene.highlightWireSource(src ?? null, state);

View File

@ -1,11 +1,17 @@
import { Application, Assets, Container, Graphics, Sprite, Texture } from "pixi.js";
import { CATALOG, DEF } from "../sim/catalog";
import { Application, Assets, Container, Graphics, Sprite, Text, Texture } from "pixi.js";
import { CATALOG, DEF, isCompute } from "../sim/catalog";
import { SimState, Device } from "../sim/state";
import { geom, setRoomGeom, tileToScreen, screenToTile, diamond, lPath, inBounds } from "./iso";
import { ROOMS } from "../sim/rooms";
export interface Overlays { heat: boolean; power: boolean; data: boolean; grid: boolean }
export interface Assist {
wireActive: boolean;
wireSource: number | null;
ringDefs: string[]; // tutorial: pulse rings over devices of these types
}
export class Scene {
app!: Application;
world = new Container(); // scaled/centered room space
@ -14,6 +20,9 @@ export class Scene {
overlayGfx = new Graphics();
ghostSprite: Sprite | null = null;
sprites = new Map<number, Sprite>();
labels = new Map<number, Text>();
labelLayer = new Container();
showLabels = localStorage.getItem("gigaslop-labels") !== "0";
textures = new Map<string, Texture>();
slopTextures: Texture[] = [];
overlays: Overlays = { heat: true, power: false, data: false, grid: false };
@ -43,7 +52,7 @@ export class Scene {
this.bgSprite = new Sprite();
this.world.addChild(this.bgSprite);
this.deviceLayer.sortableChildren = true;
this.world.addChild(this.cableGfx, this.deviceLayer, this.overlayGfx);
this.world.addChild(this.cableGfx, this.deviceLayer, this.labelLayer, this.overlayGfx);
this.app.stage.addChild(this.world);
await this.setRoom(roomId);
window.addEventListener("resize", () => this.layout());
@ -111,7 +120,8 @@ export class Scene {
return targetW / sp.texture.width;
}
render(s: SimState, ghost: { defId: string; x: number; y: number; ok: boolean } | null, dt: number) {
render(s: SimState, ghost: { defId: string; x: number; y: number; ok: boolean } | null, dt: number,
assist?: Assist) {
this.pulsePhase = (this.pulsePhase + dt * 1.6) % 1;
// --- devices ---
@ -129,9 +139,26 @@ export class Scene {
// state tinting: unpowered = dim, throttled = red-ish
sp.tint = !d.powered ? 0x777788 : d.perf < 1 ? 0xffb0a0 : 0xffffff;
sp.alpha = 1;
// name tag under the sprite — new players can't follow "click the PC" otherwise
let lb = this.labels.get(d.uid);
if (!lb) {
lb = new Text({
text: def.name,
style: { fontFamily: "ui-monospace, Menlo, monospace", fontSize: 11, fill: 0xd8d8ec,
stroke: { color: 0x0a0a12, width: 3 } },
});
lb.anchor.set(0.5, 0);
this.labels.set(d.uid, lb);
this.labelLayer.addChild(lb);
}
lb.visible = this.showLabels;
lb.position.set(c.x, c.y + geom().tileH * 0.75 + (def.wallOnly ? -55 : 0));
}
for (const [uid, sp] of this.sprites)
if (!seen.has(uid)) { sp.destroy(); this.sprites.delete(uid); }
for (const [uid, lb] of this.labels)
if (!seen.has(uid)) { lb.destroy(); this.labels.delete(uid); }
// --- ghost ---
if (ghost) {
@ -156,7 +183,7 @@ export class Scene {
}
this.drawCables(s);
this.drawOverlays(s, ghost);
this.drawOverlays(s, ghost, assist);
}
private drawCables(s: SimState) {
@ -205,9 +232,34 @@ export class Scene {
}
}
private drawOverlays(s: SimState, ghost: { defId: string } | null) {
private drawOverlays(s: SimState, ghost: { defId: string } | null, assist?: Assist) {
const g = this.overlayGfx;
g.clear();
// wire-tool affordance: cyan = pickable sources, green = valid targets
if (assist?.wireActive) {
for (const d of s.devices) {
const def = DEF[d.defId];
const c = tileToScreen(d.x + (def.footprint.w - 1) / 2, d.y + (def.footprint.h - 1) / 2);
const w = geom().tileW * (0.55 + 0.05 * Math.sin(this.pulsePhase * Math.PI * 2));
if (assist.wireSource === null) {
if (isCompute(def) || def.switchMbps)
g.ellipse(c.x, c.y + geom().tileH * 0.4, w, w / 2).stroke({ width: 2.5, color: 0x22d3ee, alpha: 0.9 });
} else if (d.uid !== assist.wireSource && (def.uplinkMbps || def.switchMbps)) {
g.ellipse(c.x, c.y + geom().tileH * 0.4, w, w / 2).stroke({ width: 2.5, color: 0x34d399, alpha: 0.95 });
}
}
}
// tutorial rings: amber pulse over the devices the current step talks about
if (assist?.ringDefs.length) {
for (const d of s.devices) {
if (!assist.ringDefs.includes(d.defId)) continue;
const def = DEF[d.defId];
const c = tileToScreen(d.x + (def.footprint.w - 1) / 2, d.y + (def.footprint.h - 1) / 2);
const w = geom().tileW * (0.7 + 0.12 * Math.sin(this.pulsePhase * Math.PI * 2));
g.ellipse(c.x, c.y + geom().tileH * 0.4, w, w / 2).stroke({ width: 3, color: 0xf59e0b, alpha: 0.9 });
}
}
// heat: subtle always, strong when toggled
const boost = this.overlays.heat ? 1 : 0.4;
const room = geom();

View File

@ -893,6 +893,12 @@ export function step(s: SimState) {
r.saasLatency = saasLatency;
}
/** HUD helper: progress toward the next slop video + whether tokens flow at all */
export function videoProgress(s: SimState): { pct: number; producing: boolean } {
const cost = BAL.VIDEO_COST_TOK * computeMods(s).videoCostMult;
return { pct: Math.min(1, s.tokenBank / cost), producing: s.rates.tokEff > 0.5 };
}
export function shopList(s: SimState) {
return CATALOG.map((def) => ({
def,

View File

@ -201,6 +201,16 @@ body { background: var(--bg); }
#hint { position: absolute; bottom: 12px; right: 215px; color: var(--dim); font-size: 11px; text-align: right; }
#adapt.hot { color: var(--red); }
/* tutorial pointing */
.tut-glow { outline: 2px solid var(--amber); outline-offset: 2px; border-radius: 8px;
animation: tutpulse 0.9s infinite alternate; }
@keyframes tutpulse {
from { box-shadow: 0 0 0 0 rgba(245, 158, 11, 0.55); }
to { box-shadow: 0 0 14px 5px rgba(245, 158, 11, 0.2); }
}
#vidprog { font-size: 9.5px; color: var(--cyan); letter-spacing: normal; margin-left: 6px; font-weight: 400; }
.feedwarn { color: var(--amber); }
#flash { position: fixed; inset: 0; pointer-events: none; z-index: 99;
background: radial-gradient(circle at 50% 45%, rgba(245, 158, 11, 0.35), transparent 60%);
animation: flashfade 0.7s forwards; }

View File

@ -1,7 +1,7 @@
import "./hud.css";
import { BAL } from "../sim/balance";
import { SimState } from "../sim/state";
import { shopList, resetBreaker } from "../sim/sim";
import { shopList, resetBreaker, videoProgress } from "../sim/sim";
import { sfx, setMuted, isMuted } from "./sfx";
import { MODEL_NAME_POOL, VERSION_NAMES } from "../sim/research";
import { MOVES, ROOMS } from "../sim/rooms";
@ -94,6 +94,7 @@ export class Hud {
<button class="chip" id="market-btn">📡 Market (M)</button>
<button class="chip" id="mute">${isMuted() ? "🔇 Muted" : "🔊 Sound"}</button>
<button class="chip" id="theme">🎨 ${THEMES.find((t) => t.id === currentTheme())?.name ?? "Midnight"}</button>
<button class="chip" id="labels-btn">🏷 Labels</button>
<button class="chip" id="tut-toggle"> Tutorial</button>
<button class="chip" id="new-game">🗑 New Game</button>
<button class="chip" id="raise" style="display:none">💰 Raise</button>
@ -114,7 +115,7 @@ export class Hud {
<button class="chip" id="move-hq" style="display:none">🚚 Move HQ</button>
</div>
<div id="shop" class="panel"><h3>HARDWARE STORE</h3></div>
<div id="feed" class="panel"><h3><b></b> SLOPTUBE STUDIO</h3><div id="vids"></div></div>
<div id="feed" class="panel"><h3><b></b> SLOPTUBE STUDIO <span id="vidprog"></span></h3><div id="vids"></div></div>
<div id="hint">click store item, place in room · W: wire PCrouter · right-click: sell · Esc: cancel</div>
<div id="toasts"></div>`;
document.body.appendChild(this.root);
@ -190,6 +191,7 @@ export class Hud {
shop.appendChild(h);
}
const el = document.createElement("div");
el.dataset.def = it.def.id;
el.className = "shop-item" + (it.locked || !it.affordable ? " dim" : "") +
(this.selectedShop === it.def.id ? " selected" : "");
el.innerHTML = `<img src="assets/gen/${it.def.sprite}_cut.png" onerror="this.style.visibility='hidden'"/>
@ -343,6 +345,12 @@ export class Hud {
(q("#trainbar") as HTMLElement).style.width = nt ? Math.min(100, (s.trainTokens / nt.tokens!) * 100) + "%" : "100%";
(q("#breaker-reset") as HTMLElement).style.display = s.breakerTripped ? "block" : "none";
// next-video heartbeat — dead air is how new players get lost
const vp = videoProgress(s);
q("#vidprog").innerHTML = vp.producing
? `⏳ next slop ${(vp.pct * 100).toFixed(0)}%`
: `<span class="feedwarn">⚠ nothing flowing — wire a PC to a router (W)</span>`;
// feed: newest 6 videos
const vids = q("#vids");
const latest = s.videos.slice(-6).reverse();

View File

@ -8,6 +8,8 @@ interface Step {
title: string;
body: string;
done: (s: SimState) => boolean;
highlight?: string[]; // CSS selectors to pulse while this step is active
ringDefs?: string[]; // device types to ring in-world
}
const STEPS: Step[] = [
@ -16,48 +18,58 @@ const STEPS: Step[] = [
title: "Plug in the slop pipe",
body: "Your PC can't sell garbage without internet. Press <b>W</b> (or the 🔌 Wire chip), click the <b>PC</b>, then the <b>router</b>.",
done: (s) => s.devices.some((d) => d.wiredTo !== null),
highlight: ["#wire-tool"],
ringDefs: ["desk_pc", "router"],
},
{
id: "first_video",
title: "Watch the slop flow",
body: "You're live. Tokens accumulate and videos publish themselves — watch <b>SLOPTUBE STUDIO</b> (bottom-left) and <b>NET/S</b> up top. Views = ad money.",
done: (s) => s.videos.length >= 1,
highlight: ["#feed"],
},
{
id: "fan",
title: "Your room is cooking",
body: "Computers make heat; above <b>85°C</b> they throttle. Buy a <b>Desk Fan ($18)</b> from the store and drop it next to the PC. Right-click sells stuff.",
done: (s) => s.devices.some((d) => d.defId === "desk_fan" || d.defId === "window_ac"),
highlight: ['[data-def="desk_fan"]'],
},
{
id: "train",
title: "The platforms are learning",
body: "Every publish raises <b>ALGO HEAT</b> — spam filters adapt and views shrink. Push the <b>TRAIN</b> slider up to bank training tokens for a fresh model.",
done: (s) => s.allocTraining >= 0.4 || s.trainTokens > 800,
highlight: ["#alloc-row"],
},
{
id: "release",
title: "Release the next version",
body: "Open the <b>🧪 Model Lab (L)</b>. When the bank covers it, train the next version — new models slip right past the filters and earn more per view.",
done: (s) => s.tier >= 1,
highlight: ["#lab-btn"],
},
{
id: "raise",
title: "Take the devil's money",
body: "Hit <b>💰 Raise Seed</b>: instant cash, but the board sets a views target with a deadline. Miss it and they take equity <i>forever</i>. Spend the money on compute immediately.",
done: (s) => s.round >= 1 || s.mandate !== null,
highlight: ["#raise"],
},
{
id: "scale",
title: "Build a real rig",
body: "Buy a <b>Rig Frame</b>, click it, and install GPU cards in its slots. Watch the <b>POWER</b> bar — trip the breaker and your training checkpoint corrupts. Sub-panels add capacity.",
done: (s) => s.devices.some((d) => d.gpus.length >= 2),
highlight: ['[data-def="gpu_rig"]'],
ringDefs: ["gpu_rig"],
},
{
id: "ship",
title: "Ship it and sell the excess",
body: "In the Lab, <b>ship a grade</b> of your model (mini/pro/MAX). Then open the <b>📡 Market (M)</b> and use the <b>HOST</b> slider to sell spare compute. Hosting your own weights invites distillation — that's the fun part.",
done: (s) => s.shippedGrades.length >= 1,
highlight: ["#lab-btn", "#market-btn"],
},
{
id: "grad",
@ -124,9 +136,24 @@ export class Tutorial {
// collapse any further steps the save has already accomplished
while (this.step < STEPS.length - 1 && STEPS[this.step].done(s)) this.bump();
}
this.applyHighlights();
this.renderNow(s);
}
/** device types the scene should ring for the active step */
ringDefs(): string[] {
if (!this.enabled || this.step >= STEPS.length || this.advanceAt !== null) return [];
return STEPS[this.step].ringDefs ?? [];
}
/** re-applied every tick — shop items etc. get rebuilt and lose classes */
private applyHighlights() {
document.querySelectorAll(".tut-glow").forEach((el) => el.classList.remove("tut-glow"));
if (!this.enabled || this.step >= STEPS.length || this.advanceAt !== null) return;
for (const sel of STEPS[this.step].highlight ?? [])
document.querySelectorAll(sel).forEach((el) => el.classList.add("tut-glow"));
}
private renderNow(s: SimState | null) {
if (!this.enabled || this.step >= STEPS.length) {
this.root.innerHTML = "";