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:
parent
7ddd1b68d7
commit
3b5e965ae7
15
src/main.ts
15
src/main.ts
@ -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);
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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; }
|
||||
|
||||
@ -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 PC→router · 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();
|
||||
|
||||
@ -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 = "";
|
||||
|
||||
Loading…
Reference in New Issue
Block a user