Compare commits
6 Commits
9cc00ad29c
...
34145e24a7
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
34145e24a7 | ||
|
|
9c7c65f57c | ||
|
|
c68bba8f32 | ||
|
|
5ae635a1e9 | ||
|
|
5529d62263 | ||
|
|
e8f92a0da2 |
@ -3,7 +3,43 @@
|
||||
*Status: **v1 complete & verified**. Standalone interiors library + test page. Every shop door opens
|
||||
into a unique, seeded, themed interior, generated on demand in ~4ms, byte-identical every revisit.*
|
||||
|
||||
Last updated: 2026-07-15 (round 8) · owner: PROCITY-C · reviewer: Fable
|
||||
Last updated: 2026-07-15 (round 9) · owner: PROCITY-C · reviewer: Fable
|
||||
|
||||
---
|
||||
|
||||
## Update 2026-07-15 (round 9) — ship v2.0: browse points (C→D→F seam) + buy-anywhere
|
||||
|
||||
Round-9 §Lane C. The round hangs off C1 (two lanes build on it), so it shipped first as a handshake +
|
||||
working code; C2 widens buying beyond the dig.
|
||||
|
||||
**C1 — browse points (the round's one cross-lane seam).** `layout.js placeBrowsePoints()` exposes
|
||||
`room.browsePoints[]` — 1–3 deterministic floor poses in front of browsable fittings, facing the goods,
|
||||
where Lane D stands browser rigs (F wires occupancy → points). Same coord frame + `ry` convention as
|
||||
`counter.stand`, so D's fleet clones drop straight in. Pure data — **no meshes, no draws**; the ≤350 law
|
||||
is untouched by C1. Guarantees (all asserted headless): walkable (occ-free, not reserved), reachable from
|
||||
spawn (the door→counter flood-fill), ≥0.6 m apart + clear of the keeper stand, deterministic per
|
||||
`shop.seed`, ≥1 point in every archetype that has a browsable fitting. A rich candidate ring (4 sides × 2
|
||||
gaps + diagonals) + a nearest-free-cell fallback covers tight/mismatched rooms. Interface published in
|
||||
[LANE_C_NOTES.md](docs/LANES/LANE_C_NOTES.md) **before** code (decision #1 half-day handshake) → committed
|
||||
first so D/F unblock. Validated: **180 type×archetype×seed combos → 0 empties** (177×3 pts, 3×1 via
|
||||
fallback), determinism 0 fails, min-sep 0.7 m, 0 keeper clashes. Test page: cyan browse-post markers on the
|
||||
`path` overlay + a HUD count.
|
||||
|
||||
**C2 — buy-anywhere (decision #2).** Book spines + toy boxes get pull-and-buy over the **same wallet**;
|
||||
records keep the deep dig. New `stockpack.buildBuyableShelf()` builds each real shelf as merged,
|
||||
per-item-**addressable** meshes — one mesh per atlas (all covers on a shelf drawn from a single seeded
|
||||
atlas → **one draw/shelf**), tagged `noBatch` so `batch.js` leaves them intact. Aiming a spine/box raises a
|
||||
pull card (title / author-or-maker / $price / band from the pack index); BUY debits the wallet, adds to
|
||||
inventory, and `collapseBuyItem()` zero-areas that item's quad in place (**removed from the shelf**, one
|
||||
draw, no geometry churn). Parody stock has no buy mesh → **no card** ("not for sale"). Fail-soft per pack.
|
||||
Validated: raycast→card→BUY works end-to-end (item collapses to its centre, cash/inventory update, card
|
||||
closes); **buy-soak 6/6 bought, `collapseOk`, leak geo 0 / tex 0**; draws with `?stock=real` **record 41 ·
|
||||
book 58 · toy 57** (≤350); full drawSweep unchanged (GLB-off 168 · GLB-on 344, both pass); placement
|
||||
determinism 0 fails. Shot: [browse_buy_r9.jpg](docs/shots/laneC/browse_buy_r9.jpg).
|
||||
|
||||
**→ D**: consume `room.browsePoints` (contract in LANE_C_NOTES) — stand rigs at the first
|
||||
`min(occupancy, 3)` points. **→ F**: two new warn smokes (hooks in LANE_C_NOTES): browse-points present +
|
||||
valid, and shelf-buy (aim → card → wallet debit → collapse). C's soak covers keeper+stock+dig+buy together.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@ -1,5 +1,27 @@
|
||||
# PROCITY-D — progress (Lane D · Citizens)
|
||||
|
||||
## Round 9 (v2.0 ship) — interior presence: occupancy truth + browser rigs. qa GREEN.
|
||||
|
||||
Built the D side of the one cross-lane seam (C browse points → D occupancy+browsers → F handoff).
|
||||
Follow a ped into a shop and find them browsing. Full numbers + F wiring in `LANE_D_NOTES.md`.
|
||||
|
||||
- **Occupancy truth (D1)**: patronage records who's inside which shop by shopId. New
|
||||
`citizens.occupancyOf(shopId) → {count, occupants:[{seed,enteredAt,pedIndex}]}`. Cleaned on
|
||||
emerge/chunk-drop/stream-toggle. **Deterministic** (17 shops + occupants byte-equal across two runs).
|
||||
- **Browser rigs**: `keepers.js` gained `browse` (faces shelf, no greet) + `pedIndex` (the browser IS
|
||||
the ped who ducked in) + `seedKey`. Validated 12 enter/exit cycles: count == min(occupancy,3) **12/12**,
|
||||
worst **61 draws ≤350**, **leak-free** (0 GPU delta), disposed every exit. Merged peds = ~1 draw each.
|
||||
- **Consistency (D2)**: inside peds hidden on the street (0 rendered), re-emerge at the door.
|
||||
- **F handoff** (verified by running F's exact wiring): enter → `occupancyOf` → `browsePoints.slice(0,
|
||||
min(count,3))` → `keepers.spawn({...browse:true, pedIndex})`. `web/index.html` `setShops` door points
|
||||
need `shopId: s.id` added. Closing-time handled by the dwell model (pending A's explicit ruling).
|
||||
- Evidence: `docs/shots/laneD/r9_browsers_in_shop.jpg`. v1 path untouched.
|
||||
|
||||
Changed `web/js/citizens/sim.js` (occupancy) + `keepers.js` (browser mode) + `citizens_test.html`
|
||||
(shopId in synthetic shops).
|
||||
|
||||
---
|
||||
|
||||
## Round 8 (the crowd comes alive) — shop patronage v0 + weather reaction. qa GREEN.
|
||||
|
||||
Also did the R7 post-flip shell verification (F's flip landed) + pushed the tags to origin (main
|
||||
|
||||
@ -1,5 +1,86 @@
|
||||
# LANE C — cross-lane notes (PROCITY-C)
|
||||
|
||||
## → Lane D + Lane F: BROWSE-POINTS interface (round-9 C1 — **LANDED**, code live)
|
||||
|
||||
**Status: shipped.** `room.browsePoints[]` is live on the room API (commit `5ae635a`, ref
|
||||
`lane-c/round9-browse`). The contract below is final — build against it. It is the seam the whole round
|
||||
hangs off. **C owns the points + room fit; D owns occupancy truth; F owns the handoff.**
|
||||
|
||||
**F smoke (warn):** enter a `?stock=real` book/toy/record shop, assert `room.browsePoints.length >= 1`
|
||||
and every point walkable+reachable (or just trust C's asserts and check `.length` + that D's rig count ==
|
||||
`min(occupancy,3)`). Test page: the `path` overlay draws each point as a cyan post + facing nose.
|
||||
|
||||
**Draw headroom for D's rigs** (I measured with 3 two-draw rig proxies standing at the points): worst
|
||||
interior in the `?stock=real` path is **68 draws** (opshop/hall) — browsers add ≤6, tons of room. The one
|
||||
tight spot is the *pre-existing* GLB-on worst (opshop/hall **344/350**, unrelated to browsers): if D's rigs
|
||||
are ever on TOP of GLB hero-props in that room, keep them impostor-cheap (≤2 draws each, the keeper pattern)
|
||||
and F's sweep-with-browsers should confirm ≤350. Everywhere else there's ample headroom.
|
||||
|
||||
### What C exposes on the room API (live once C1 commits)
|
||||
|
||||
```jsonc
|
||||
room.browsePoints: BrowsePoint[] // 0..3 entries, DETERMINISTIC per shop.seed, stably ordered
|
||||
BrowsePoint = {
|
||||
x, z, // room-local floor position (metres). Origin = room centre, +Z = door/street side,
|
||||
// −Z = back — the SAME frame as room.spawn / room.counter.stand.
|
||||
ry, // facing yaw, oriented to look AT the shelf. SAME convention as room.counter.stand.ry
|
||||
// (rig-front = local −Z, i.e. the value keepers.js already consumes). Drop your
|
||||
// browser rig in with rotation.y = ry and it faces the goods.
|
||||
atKind, // string — the fitting kind being browsed ('bin' | 'shelf' | 'rack' | 'table' | 'case' …).
|
||||
// Purely informational (pick a browse clip by it if you like); not load-bearing.
|
||||
slotIndex, // 0-based, == array position. Stable across rebuilds of the same seed.
|
||||
}
|
||||
```
|
||||
|
||||
**Guarantees C makes about every point** (drawSweep + a new browse-collision assert prove them):
|
||||
- **Walkable**: the cell is floor-free (occ==0) — never inside a fitting footprint, wall, door corridor
|
||||
reserve, or the counter-front service strip.
|
||||
- **Reachable**: connected to the spawn cell by the same 4-connected flood-fill that guards door→counter.
|
||||
- **Spaced**: points are ≥0.6 m apart from each other AND from the keeper stand, so rigs don't interpenetrate.
|
||||
- **Deterministic**: pure function of `shop.seed`; same seed → same points, same order.
|
||||
- **Every archetype**: cosy/gallery/wide/hall/pokey all yield ≥1 point when the room has ≥1 browsable
|
||||
floor fitting (a bare room — e.g. a tiny pokey with only a counter — may yield 0; treat 0 as "no browsers").
|
||||
|
||||
C adds **no meshes and no draws** for browse points — they are pure poses. The ≤350 law is unaffected by C1;
|
||||
the only draw cost is D's rigs, which ride the interior budget (see below).
|
||||
|
||||
### The D → F → C handoff (who calls whom)
|
||||
|
||||
1. **D owns occupancy truth.** Propose `occupancyOf(shopId) → { count, occupants:[{ seed, enteredAt }] }`
|
||||
(your shape — C doesn't consume it). Count is however many peds patronage says are inside, pre-cap.
|
||||
2. **F wires it at interior build.** On enter, F reads D's occupancy, then:
|
||||
`const pts = room.browsePoints.slice(0, Math.min(occ.count, 3));` and asks D's fleet to stand one
|
||||
browser rig per `pt` at `(pt.x, pt.z)`, `rotation.y = pt.ry`. **Cap 3** is enforced by there being ≤3
|
||||
points — F just clamps count. Identity: pair `occupants[i].seed` with `pts[i]` so the same ped is the
|
||||
same rig deterministically.
|
||||
3. **Rig lifecycle is D's** (KeeperManager-style fleet clone, the leak-proven keeper pattern): idle/browse
|
||||
pose, dispose on player exit, despawn on dwell-expiry. Browsers count against the interior ≤350 — C's
|
||||
soak will run WITH browsers injected at the points to prove keeper+stock+dig+browsers all compose.
|
||||
|
||||
**Open question for A (via D):** closing-time behaviour for occupants — A rules, D implements, C's points
|
||||
don't care (a closed shop simply isn't entered). No blocker on C.
|
||||
|
||||
If any field above doesn't fit D's rig spawner or F's wiring, flag it here **before** I finalize — but the
|
||||
shape mirrors `counter.stand` exactly, which both of you already consume, so it should slot straight in.
|
||||
|
||||
## → Lane F: buy-anywhere (round-9 C2 landed) — shelf pull-and-buy smoke (warn)
|
||||
|
||||
Book spines + toy boxes are now buyable over the same wallet (records keep the dig). A real shelf is a
|
||||
merged, per-item-addressable mesh: `mesh.userData.buyMesh === true`, `mesh.userData.buyItems = [{item,
|
||||
center, vStart}]`, one draw per shelf (drawSweep with `?stock=real`: record 41 · book 58 · toy 57, ≤350).
|
||||
Smoke recipe (headless — no raycast needed):
|
||||
```js
|
||||
const room = buildInterior({ id:'book', type:'book', seed:1990 }, THREE, { stock:'real' });
|
||||
let mesh=null; room.group.traverse(o=>{ if(!mesh && o.userData?.buyMesh && o.userData.buyItems.length) mesh=o; });
|
||||
assert(mesh); // real book/toy shop has ≥1 buy mesh
|
||||
const it = mesh.userData.buyItems[0]; // {item:{title,artist,price,price_band}, center, vStart}
|
||||
// buy path: wallet.buy({t:it.item.title, price:it.item.price||20}); collapseBuyItem(mesh, it);
|
||||
// assert: geometry.position vert[it.vStart] collapsed to it.center (item removed) · wallet.count()++ · leak-free
|
||||
```
|
||||
Test page exposes `shelfBuySoak()` (buys one shelf item per book/toy seed, asserts collapse + leak 0/0) and
|
||||
`shelfUnderAim()/showShelfCard()/buyShelfOffer()` for the live raycast path (E key: bin→dig, shelf→card).
|
||||
Parody stock (no pack) has **no** buy mesh → aiming shows no card ("not for sale"). Fail-soft per pack.
|
||||
|
||||
## → Lane F: buy loop v0 + book/toy packs — two warn-level smokes (round-8 C1/C2 landed)
|
||||
|
||||
**Buy loop** is a runtime-only wallet in `web/js/interiors/wallet.js` — pure, headless-testable, no DOM:
|
||||
|
||||
@ -6,6 +6,56 @@ _rotOnly/head-bone normalize/upgradeStreetPeople). Measurements on the M3 Ultra
|
||||
|
||||
---
|
||||
|
||||
## ROUND 9 — interior presence: occupancy truth + browser rigs (→ Lane F handoff below)
|
||||
|
||||
**Follow a ped into a shop and find them browsing.** D owns occupancy truth; C owns the browse points;
|
||||
F wires the handoff. Validated end-to-end in-shell (`?stock=real`). `qa.sh --strict` GREEN; v1
|
||||
(`?roster=v1`) path untouched.
|
||||
|
||||
### D owns occupancy truth ✅ (the C→D→F seam)
|
||||
Patronage now records who's inside which shop, keyed by `shopId` (from the door point). New API:
|
||||
```js
|
||||
citizens.occupancyOf(shopId) → { count, occupants: [{ seed, enteredAt, pedIndex }] }
|
||||
```
|
||||
`count` = peds patronage currently has inside (pre-cap); `pedIndex` lets a browser BE the exact ped who
|
||||
ducked in off the street (identity continuity). Occupancy is cleaned on emerge, on chunk-drop, and on
|
||||
stream toggle. **Deterministic**: same seed + same update sequence → identical occupied shops AND
|
||||
occupants (verified: 17 shops, seed:pedIndex byte-equal across two runs).
|
||||
- **Consistency (D2)**: an `inside` ped is hidden on the street (not in the rendered set — verified 0
|
||||
inside peds rendered) and re-emerges at the door. No double-presence.
|
||||
|
||||
### Browser rigs ✅ (KeeperManager, the leak-proven pattern)
|
||||
`keepers.js` gained `browse:true` (faces the shelf via `ry`, **no player-greet**) + `pedIndex`
|
||||
(pick the exact fleet ped) + `seedKey` (distinct browsers per shop). Each browser is a **merged ped
|
||||
= ~1 draw**. Validated over **12 enter/exit cycles**: browser count == `min(occupancy, 3)` **12/12**,
|
||||
worst interior **61 draws ≤350** (C's headroom holds), **leak-free (0 GPU delta after warmup)**,
|
||||
disposed on every exit (`keepers.disposeAll()` in interior_mode).
|
||||
|
||||
### → Lane F: the handoff in `web/js/world/interior_mode.js` (F owns it) — verified working
|
||||
Two-line wiring at interior build (F already has `keepers` + `current.browsePoints` from C):
|
||||
```js
|
||||
// on enter(shop), after buildInterior:
|
||||
const occ = citizens.occupancyOf(shop.id);
|
||||
const pts = (current.browsePoints || []).slice(0, Math.min(occ.count, 3)); // cap 3
|
||||
pts.forEach((pt, i) => keepers.spawn(current.group, {
|
||||
x: pt.x, z: pt.z, ry: pt.ry, shopId: shop.id, browse: true,
|
||||
pedIndex: occ.occupants[i] ? occ.occupants[i].pedIndex : null,
|
||||
seedKey: `${shop.id}#${i}`,
|
||||
}));
|
||||
// exit() already calls keepers.disposeAll() → browsers freed with the room. Leak-proven.
|
||||
```
|
||||
And **`web/index.html`**: `citizens.setShops(...)` door points must carry `shopId: s.id` (add it to the
|
||||
R8 recipe — one field). Everything else (patronage, weather) is unchanged.
|
||||
|
||||
**Closing-time (pending A's ruling):** my dwell model already handles it sensibly — `_openAt(hours)`
|
||||
stops *new* entries once a shop closes, and existing occupants clear within their short seeded dwell
|
||||
(5–20 s, tiny vs the hour-long closing window), so no one is stuck inside a shut shop. I'll adopt A's
|
||||
explicit ruling when it lands if it differs.
|
||||
|
||||
Evidence: `docs/shots/laneD/r9_browsers_in_shop.jpg` (two browsers in a toy shop among the priced stock).
|
||||
|
||||
---
|
||||
|
||||
## ROUND 8 — shop patronage v0 + weather reaction (→ Lane F wiring below)
|
||||
|
||||
**The crowd comes alive: streamed peds duck into open shops they pass and re-emerge; at night only the
|
||||
|
||||
@ -1,5 +1,9 @@
|
||||
# PROCITY — Round 8 lane instructions (from Fable, integrator)
|
||||
|
||||
> **⚠ SUPERSEDED by [ROUND9_INSTRUCTIONS.md](ROUND9_INSTRUCTIONS.md) (2026-07-14).** Round 8 is
|
||||
> complete with NO slips — weather, Katoomba, 3 stock packs, buy loop, patronage all live;
|
||||
> v2.0-rc tagged (`9cc00ad`) and pushed. Historical reference only.
|
||||
|
||||
Date: 2026-07-14 · Written after the Fable round-7 review (`16d4f2b`) and the **v2.0-beta
|
||||
tag** (`4feba02`). You are an Opus 4.8 agent assigned ONE lane (read your session prompt).
|
||||
Read this file, your lane spec, your `<X>-progress.md`, and NOTES addressed to you first.
|
||||
|
||||
201
docs/LANES/ROUND9_INSTRUCTIONS.md
Normal file
201
docs/LANES/ROUND9_INSTRUCTIONS.md
Normal file
@ -0,0 +1,201 @@
|
||||
# PROCITY — Round 9 lane instructions (from Fable, integrator)
|
||||
|
||||
Date: 2026-07-14 · Written after the Fable round-8 review. You are an Opus 4.8 agent
|
||||
assigned ONE lane (read your session prompt). Read this file, your lane spec, your
|
||||
`<X>-progress.md`, and NOTES addressed to you first.
|
||||
|
||||
Repo state: `main` at `9cc00ad` = `v2.0-rc`, **fully pushed** (origin synced, all five
|
||||
version tags live). `tools/qa.sh --strict` GREEN 5/5 with **zero warnings** — every smoke
|
||||
incl. the warn-level ones (buy, patronage, book/toy stock, katoomba) is green. Round 8 had
|
||||
**no slips**: weather, Katoomba, three real-stock packs (record 350 / book 311 / toy 273),
|
||||
buy loop, patronage all live. Goldens: synthetic `0x3fa36874` · melbourne `0x34cfdec0` ·
|
||||
katoomba `0x0f652510`. fal ~$21.36.
|
||||
|
||||
## Theme: ship v2.0
|
||||
|
||||
The rc is healthy and warning-free. Round 9 hardens it into **`v2.0`**: graduate every
|
||||
smoke to strict, make the crowd visible *inside* shops, widen buying beyond the dig,
|
||||
re-shoot the tour so it shows the living town, and freeze the docs. One new toy (B's tram)
|
||||
rides along but does NOT block the tag.
|
||||
|
||||
What v2.0 means when tagged: seed → real-or-synthetic AU town → streamed streets with
|
||||
weather and a living crowd that shops → every door opens → real browsable stock you can
|
||||
buy with a wallet → all of it deterministic, leak-free, budget-clean, and enforced by a
|
||||
strict harness.
|
||||
|
||||
## Prime law (unchanged post-amendment)
|
||||
|
||||
Default boot = streamed roster + patronage at the pinned baseline; plan goldens frozen;
|
||||
new features behind default-off flags; flags compose; qa strict green at every commit.
|
||||
Baseline re-pins only by the one-commit protocol.
|
||||
|
||||
## Integrator decisions (so nobody blocks)
|
||||
|
||||
1. **Interior presence (the round's one cross-lane seam — C↔D, F wires).** Peds who duck
|
||||
into a shop should be *there* when the player follows. Contract: D tracks per-shop
|
||||
occupancy (who's inside + their identity seeds, already implied by patronage state);
|
||||
C exposes 1–3 **browse points** per room in the room API (aisle spots, like the keeper's
|
||||
counter spot); F passes occupancy → interior build so browser rigs (KeeperManager-style
|
||||
fleet clones, idle/browse pose) stand at browse points. Cap 3/room; ≤350 draws holds;
|
||||
leak-free like keepers (proven pattern). D owns occupancy truth, C owns points + room
|
||||
fit, F owns the handoff. Agree the two interfaces in NOTES before code (half-day
|
||||
handshake rule).
|
||||
2. **Buy-anywhere scope (C):** book/toy shelves get pull-and-buy (aim at a real-stock
|
||||
spine/box → pull-out card with title/price → BUY via the same wallet). No new dig
|
||||
mechanics — reuse the pull-panel pattern. Record digs stay the deep interaction.
|
||||
3. **Tram (B): now a real task, but explicitly NON-BLOCKING for v2.0** (no-hostage rule
|
||||
pre-applied). `?tram=1`: one instanced tram on a seeded main-street loop over the
|
||||
bus_shelter stops, door-pause at each, budget-neutral (≤2 draws). If it lands, it
|
||||
enters the harness at warn and the tour gets a shot.
|
||||
4. **Audio stays parked** — STANDING ASK TO JOHN (third round running): one decision line
|
||||
("generate ambience freely" / "use library X" / "skip for v2") unlocks street ambience,
|
||||
rain-on-roof, and the milk-bar door bell. Until then no lane touches audio.
|
||||
5. **fal: no new spends** (broken-prop clause stands).
|
||||
6. **Tag `v2.0`** (F, end of round) when: all round-8 warn smokes strict + green ·
|
||||
interior presence live + smoked · buy-anywhere live + smoked · v2 tour re-shot ·
|
||||
full-defaults soak green · docs frozen. Tram/third-town slippage does not block.
|
||||
This is the epoch tag — after it, Fable + John pick the v3 epoch (editor/overrides vs
|
||||
venue district vs multiplayer presence — V2_IDEAS).
|
||||
|
||||
## Process rules (unchanged)
|
||||
|
||||
- ONE shared working tree; exact-path staging; options before `--`; new files `add`ed
|
||||
first; durable lane refs; qa green at every commit; progress docs + NOTES.
|
||||
- F owns `web/index.html` + `tools/`; document call sites for F.
|
||||
- Slip rule: partial commit + cut-line note, never silent.
|
||||
- `procityprogress.txt` / `settings.local.json` untracked; staging dirs gitignored.
|
||||
- Repo `/Users/m3ultra/Documents/procity`; dev `:8130`.
|
||||
|
||||
## Sequencing
|
||||
|
||||
```
|
||||
C1 (browse points) ──┬──→ D1 (occupancy + browsers) ──→ F1 (wire handoff + smoke)
|
||||
└──→ C2 (buy-anywhere)
|
||||
B1 (tram, non-blocking) · A1 (contract freeze + docs) · E1 (VRAM audit) — parallel
|
||||
all land ──→ F2 (graduate smokes strict) ──→ F3 (tour + soak) ──→ F4 (tag v2.0)
|
||||
```
|
||||
C starts first (both seams hang off the room API). F1 needs C1+D1.
|
||||
|
||||
---
|
||||
|
||||
## Lane A (Citygen) — freeze the contract, polish the docs
|
||||
|
||||
Small round — you're the most "done" lane; make it official.
|
||||
|
||||
1. **CITY_SPEC v2 contract freeze**: document the final CityPlan producer contract as
|
||||
built — both sources, the town-key mechanism, the goldens table (synthetic/melbourne/
|
||||
katoomba + how to pin a new one), the normalization rules the osm importer applies,
|
||||
the openLate/hours/storeys laws. This becomes the reference for the v3 epoch (the
|
||||
editor/override layer reads THIS contract).
|
||||
2. **Recipe sanity**: dry-run your own "add a town" recipe against the doc (don't ship a
|
||||
third town unless it falls out for free — if it does, pin its golden and tell F).
|
||||
3. **On-call** for D's patronage/occupancy questions (shop doors, hours edge cases —
|
||||
e.g. what happens to occupants at closing time; give D a ruling and document it).
|
||||
|
||||
**Acceptance**: CITY_SPEC v2 section complete + matches reality (F will diff claims vs
|
||||
code in review); closing-time ruling documented; qa green.
|
||||
|
||||
## Lane B (Streetscape) — the tram (non-blocking) + tour poses
|
||||
|
||||
1. **`?tram=1`** (decision #3): one instanced tram/small bus, seeded main-street loop
|
||||
over your bus_shelter stops, door-pause dwell at each stop, bell-ready (no audio yet).
|
||||
Deterministic (same seed → same schedule), budget ≤2 draws, composes with weather/
|
||||
night (headlights at night if cheap). Flag-off identical. It slipping does NOT block
|
||||
v2.0 — commit partial + cut line if needed.
|
||||
2. **v2 tour bookmarks** (F shoots this round — do early): add named poses for the new
|
||||
life — `rain_verandah` (rain through a lit winmap window), `patronage_door` (peds at a
|
||||
shop door), `dig_real` (a bin of real covers), `katoomba_main` (2nd town), `tram_stop`
|
||||
(if tram lands), plus your judgment for 2–3 more. Verify via `shots.py`.
|
||||
3. **Weather polish debt** (only if quick): any visual nits from your own R8 notes.
|
||||
|
||||
**Acceptance**: tram loops deterministically under budget (or documented cut); tour poses
|
||||
live + un-letterboxed; qa green.
|
||||
|
||||
## Lane C (Interiors) — browse points + buy-anywhere
|
||||
|
||||
1. **C1 — browse points (DO FIRST — D and F build on it).** Room API: 1–3 seeded browse
|
||||
points per room (position + facing, aisle-adjacent, never blocking the door or the
|
||||
counter), exposed like the keeper's counter spot. Deterministic per shop.seed; works
|
||||
in every archetype (drawSweep proves no collisions with fittings). Document the
|
||||
interface in LANE_C_NOTES *first* (decision #1 handshake).
|
||||
2. **C2 — buy-anywhere (decision #2):** aiming at a real-stock spine/box on book/toy
|
||||
shelves raises the pull-out card (title/artist-or-maker/price from the pack index);
|
||||
BUY debits the wallet, removes the item from the shelf (your batched-stock
|
||||
addressability from R6 — same trick as dug bins), adds to inventory. Parody stock =
|
||||
not buyable (card shows "not for sale" or simply no card — your call, document it).
|
||||
Fail-soft, deterministic, leak-free, ≤350 draws; extend the soak to buy 1 shelf item
|
||||
per room type.
|
||||
3. **Support F1**: when browsers spawn at your points, verify keeper/stock/dig all still
|
||||
compose in a room WITH browsers (the soak covers it).
|
||||
|
||||
**Acceptance**: browse points in every archetype, documented, D/F consuming; shelf
|
||||
buying works end-to-end with the wallet; soaks green; ≤350; qa green.
|
||||
|
||||
## Lane D (Citizens) — occupancy truth + browsers inside
|
||||
|
||||
1. **D1 — occupancy + browser rigs (after C1's interface note).** Track per-shop
|
||||
occupancy from patronage (who ducked in: identity seed + entry time + seeded dwell).
|
||||
When the player enters an occupied shop, browser rigs (fleet clones, KeeperManager-
|
||||
style lifecycle — the leak-proven pattern) stand/idle at C's browse points, matching
|
||||
occupancy count (cap 3, decision #1). They exit on dwell-expiry (walk-out tell if
|
||||
cheap, despawn otherwise); at closing time follow A's ruling. Deterministic: same
|
||||
seed + same time → same occupants. Leak-free over N enter/exit cycles (extend your
|
||||
soak). Coordinate the F-facing handoff shape (occupancy query per shopId) in NOTES.
|
||||
2. **D2 — street↔interior consistency tells**: the ped who ducked in shouldn't ALSO be
|
||||
visible on the street (your despawn already handles it); re-emergence spawns at the
|
||||
door. Verify with patronage + occupancy both on.
|
||||
3. Budget: browsers ride the interior ≤350 law (C's sweep with browsers on proves it).
|
||||
|
||||
**Acceptance**: follow a ped into a shop and find them browsing; count matches
|
||||
occupancy; deterministic + leak-free + ≤350 with browsers; closing-time behavior per A's
|
||||
ruling; qa green.
|
||||
|
||||
## Lane E (Assets) — the VRAM/texture audit + on-call
|
||||
|
||||
1. **E1 — memory audit (the ship-round due diligence).** Nobody has measured total
|
||||
texture/VRAM weight since the packs landed. Audit: all atlases (3 stock packs), skins,
|
||||
facade atlas, ped fleet, GLB textures — sizes on disk + estimated GPU memory + what's
|
||||
resident in a default boot vs `?stock=real` boot vs worst case (all flags). Numbers in
|
||||
AUDIT.md + a one-para verdict (fine / needs compression pass / needs lazy-load).
|
||||
If a cheap win exists (e.g. a pack atlas that should be WebP/half-res), take it —
|
||||
bigger restructuring gets flagged for v3, not done now.
|
||||
2. **E2 — depot + manifest hygiene**: `validate --depot` + pack-QA + provenance-drift all
|
||||
green post-R8 (they should be — confirm); prune any orphaned depot files your audit
|
||||
finds (list first in NOTES, delete only your own procity_* uploads).
|
||||
3. **On-call**: B may want a tram texture/GLB (procedural/primitive first — a box tram
|
||||
with the right silhouette beats a heavy model; flux/local gen only if it truly reads
|
||||
wrong); C may want a "browse pose" check on fleet clips (that's D's domain — redirect).
|
||||
|
||||
**Acceptance**: audit published with numbers + verdict; cheap wins taken; depot hygiene
|
||||
confirmed; qa green.
|
||||
|
||||
## Lane F (Integration) — graduate, shoot, freeze, tag
|
||||
|
||||
1. **F1 — interior-presence wiring (after C1+D1):** occupancy query → interior build →
|
||||
browser spawn at browse points; smoke at warn (enter occupied shop, assert browser
|
||||
count > 0 matching occupancy, 0 errors, dispose clean on exit).
|
||||
2. **F2 — graduate round-8 smokes to strict**: buy-v0, patronage, book/toy stock,
|
||||
katoomba golden. New this round at warn: interior presence, shelf-buy, tram (if B
|
||||
lands). Everything green before F3.
|
||||
3. **F3 — the v2 tour + full-defaults soak**: re-shoot `docs/shots/v2_tour/` (12 shots:
|
||||
B's new poses + the classics re-taken on current defaults — busy street, rain money
|
||||
shot, dig with real covers, browsers in a shop, katoomba, night video-shop crowd,
|
||||
tram if landed) + contact sheet. Full soak on pure defaults (no flags) + the all-on
|
||||
combo; both leak-free, 0 errors.
|
||||
4. **F4 — docs freeze + tag `v2.0`** (decision #6): runbook gate table updated to the v2
|
||||
reality; flags table final (what's default, what's flagged, what graduated);
|
||||
README/CITY_SPEC claims match code (diff A's contract freeze vs reality as part of
|
||||
review); F-progress round-9 section. Tag with a proper epoch summary. Local tag —
|
||||
John pushes (the push pattern is proven now).
|
||||
5. **Marshal**: C1 is the round's single point of failure (two lanes build on it) —
|
||||
check for its commit early; if C stalls, escalate in F-progress immediately.
|
||||
|
||||
**Acceptance**: presence wired + smoked; all r8 smokes strict; tour + soaks green;
|
||||
docs frozen; `v2.0` tagged on an all-green tree.
|
||||
|
||||
---
|
||||
|
||||
*— Fable (integrator). Nine rounds from empty repo to this: a seed becomes a town where
|
||||
it rains, the crowd shops, and you can follow someone into a record store, flip past
|
||||
three hundred real sleeves, and buy one. Round 9 makes that official. Commit atomically,
|
||||
pin your refs, leave it green.*
|
||||
BIN
docs/shots/laneC/browse_buy_r9.jpg
Normal file
BIN
docs/shots/laneC/browse_buy_r9.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 169 KiB |
BIN
docs/shots/laneD/r9_browsers_in_shop.jpg
Normal file
BIN
docs/shots/laneD/r9_browsers_in_shop.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 866 KiB |
@ -191,7 +191,7 @@ if (STREAM && PATRONAGE) {
|
||||
[1, -1].forEach((side) => {
|
||||
const x = mx + px * side * 7, z = mz + pz * side * 7;
|
||||
const openLate = i === 0 && side === 1; // one openLate "video" shop
|
||||
const s = { x, z, hours: openLate ? [11, 23] : [9, 17], type: openLate ? 'video' : 'shop' };
|
||||
const s = { x, z, shopId: `e${i}s${side}`, hours: openLate ? [11, 23] : [9, 17], type: openLate ? 'video' : 'shop' };
|
||||
cell(x, z).push(s); if (openLate) vid = s;
|
||||
const box = new THREE.Mesh(new THREE.BoxGeometry(3.4, 3, 3.4),
|
||||
new THREE.MeshStandardMaterial({ color: openLate ? 0x3a5c8c : 0x54493c, roughness: 0.9 }));
|
||||
|
||||
@ -72,6 +72,7 @@ import { PointerLockControls } from 'three/addons/controls/PointerLockControls.j
|
||||
import { buildInterior, SHOP_TYPES, ARCHETYPE_KEYS, preloadStockPack, getStockPack, makeStockAdapter } from './js/interiors/interiors.js';
|
||||
import { createDig, binSeed } from './js/interiors/dig.js';
|
||||
import { createWallet } from './js/interiors/wallet.js';
|
||||
import { collapseBuyItem } from './js/interiors/stockpack.js';
|
||||
|
||||
// ?stock=real — feed Lane E's GODVERSE record-sleeve pack through the stockAdapter seam.
|
||||
const STOCK_REAL = new URLSearchParams(location.search).get('stock') === 'real';
|
||||
@ -171,6 +172,7 @@ function updateHUD() {
|
||||
`geo ${c.geometries} · mat ${c.materials} · tex ${c.canvasTextures}\n` +
|
||||
`places ${current.places.length} · counter ${current.recipe.counterPos}` +
|
||||
(current.counter ? `\nkeeper stand ${current.counter.stand.x.toFixed(2)}, ${current.counter.stand.z.toFixed(2)} · ry ${current.counter.stand.ry.toFixed(2)}` : '') +
|
||||
`\nbrowse points ${(current.browsePoints || []).length} (Lane D rigs)` +
|
||||
(useGLB ? `\nGLB props: ${glbCount()} upgraded (local)` : '');
|
||||
}
|
||||
function glbCount() {
|
||||
@ -223,6 +225,17 @@ function buildPathHelper() {
|
||||
noseGrp.add(nose); noseGrp.position.set(ks.x, 1.4, ks.z); noseGrp.rotation.y = ks.ry;
|
||||
grp.add(post, noseGrp);
|
||||
}
|
||||
// browse points (R9) — where Lane D stands browser rigs. Cyan post + white nose = facing the goods.
|
||||
for (const bp of (current.browsePoints || [])) {
|
||||
const post = new THREE.Mesh(new THREE.CylinderGeometry(0.13, 0.13, 1.6, 12),
|
||||
new THREE.MeshBasicMaterial({ color: '#2ec8d6', transparent: true, opacity: 0.6 }));
|
||||
post.position.set(bp.x, 0.8, bp.z);
|
||||
const noseGrp = new THREE.Group();
|
||||
const nose = new THREE.Mesh(new THREE.BoxGeometry(0.07, 0.07, 0.5), new THREE.MeshBasicMaterial({ color: '#ffffff' }));
|
||||
nose.position.set(0, 0, -0.4); // local −Z = facing (same convention as keeper)
|
||||
noseGrp.add(nose); noseGrp.position.set(bp.x, 1.4, bp.z); noseGrp.rotation.y = bp.ry;
|
||||
grp.add(post, noseGrp);
|
||||
}
|
||||
pathHelper = grp; scene.add(grp);
|
||||
}
|
||||
function applyWire() {
|
||||
@ -408,6 +421,107 @@ async function digSoak(N = 15) {
|
||||
return { rooms: N, bought, leakGeo: renderer.info.memory.geometries - bG, leakTex: renderer.info.memory.textures - bT };
|
||||
}
|
||||
|
||||
// ── buy-anywhere (round 9 C2): aim at a real-stock book spine / toy box → pull card → BUY ──
|
||||
// Records keep the deep dig; shelves get a light pull-and-buy over the SAME wallet. Reuses the
|
||||
// batched-but-addressable buy meshes from stockpack.buildBuyableShelf. Parody stock has no buy mesh
|
||||
// → no card ("not for sale"). UI is test-page-local; the reusable pieces are the mesh data + wallet.
|
||||
const _buyRay = new THREE.Raycaster();
|
||||
const _lp = new THREE.Vector3();
|
||||
const shelfPanel = document.createElement('div'); shelfPanel.id = 'shelfBuy';
|
||||
const shelfCss = document.createElement('style'); shelfCss.textContent = `
|
||||
#shelfBuy{position:fixed;top:50%;right:26px;transform:translateY(-50%);z-index:72;width:230px;background:rgba(18,15,10,.96);border:1px solid #b58b3a;border-radius:10px;padding:14px 15px;font:12px "Courier New",monospace;color:#e8e2d4;display:none}
|
||||
#shelfBuy h3{margin:0 0 2px;font-size:15px;color:#ffd75e}
|
||||
#shelfBuy .sub{color:#b8b0a0;margin-bottom:8px}
|
||||
#shelfBuy .price{font-size:20px;color:#3dff8b;margin-bottom:10px}
|
||||
#shelfBuy button{width:100%;padding:9px;background:#1b2436;border:1px solid #3d5273;color:#cfe0ff;font:700 13px "Courier New",monospace;border-radius:6px;cursor:pointer}
|
||||
#shelfBuy button:disabled{opacity:.45;cursor:not-allowed}
|
||||
#shelfBuy .x{position:absolute;top:8px;right:11px;color:#8a8272;cursor:pointer}`;
|
||||
document.head.appendChild(shelfCss); document.body.appendChild(shelfPanel);
|
||||
let shelfOffer = null; // { mesh, item } currently on the card
|
||||
|
||||
// Collect buy meshes in the live room, raycast from crosshair, return the nearest unsold item.
|
||||
function shelfUnderAim(maxDist = 3.0) {
|
||||
if (!current) return null;
|
||||
const meshes = []; current.group.traverse(o => { if (o.userData && o.userData.buyMesh && o.userData.buyItems.length) meshes.push(o); });
|
||||
if (!meshes.length) return null;
|
||||
_buyRay.setFromCamera({ x: 0, y: 0 }, camera);
|
||||
const hit = _buyRay.intersectObjects(meshes, false)[0];
|
||||
if (!hit || hit.distance > maxDist) return null;
|
||||
const mesh = hit.object;
|
||||
_lp.copy(hit.point); mesh.worldToLocal(_lp); // hit → mesh-local (== item centre space)
|
||||
let best = null, bd = Infinity;
|
||||
for (const it of mesh.userData.buyItems) { const d = (it.center.x - _lp.x) ** 2 + (it.center.y - _lp.y) ** 2 + (it.center.z - _lp.z) ** 2; if (d < bd) { bd = d; best = it; } }
|
||||
return best ? { mesh, item: best } : null;
|
||||
}
|
||||
const priceOf = (it) => it.price || (it.price_band === 'grail' ? 60 : it.price_band === 'collector' ? 30 : 8);
|
||||
function showShelfCard(offer) {
|
||||
shelfOffer = offer; const it = offer.item.item;
|
||||
const price = priceOf(it), afford = wallet.canBuy(price);
|
||||
shelfPanel.style.display = 'block';
|
||||
shelfPanel.innerHTML =
|
||||
`<span class="x">×</span><h3>${it.title || 'Untitled'}</h3>` +
|
||||
`<div class="sub">${it.artist ? it.artist + ' · ' : ''}${(it.price_band || 'stock')}</div>` +
|
||||
`<div class="price">$${price}</div>` +
|
||||
`<button ${afford ? '' : 'disabled'}>${afford ? 'BUY IT' : 'NOT ENOUGH CASH'}</button>`;
|
||||
shelfPanel.querySelector('.x').onclick = closeShelfCard;
|
||||
shelfPanel.querySelector('button').onclick = () => buyShelfOffer();
|
||||
}
|
||||
function closeShelfCard() { shelfPanel.style.display = 'none'; shelfOffer = null; }
|
||||
// Buy the carded item: debit wallet, collapse its quad (removed from the shelf), drop it from the pool.
|
||||
function buyShelfOffer() {
|
||||
if (!shelfOffer) return false;
|
||||
const { mesh, item } = shelfOffer, it = item.item, price = priceOf(it);
|
||||
if (!wallet.buy({ t: it.title, a: it.artist, price, s: it.price_band })) return false;
|
||||
collapseBuyItem(mesh, item);
|
||||
const arr = mesh.userData.buyItems; const i = arr.indexOf(item); if (i >= 0) arr.splice(i, 1);
|
||||
closeShelfCard();
|
||||
return true;
|
||||
}
|
||||
// E interacts: a record bin opens the dig; a real shelf raises the buy card. Works with the dig's E too.
|
||||
if (STOCK_REAL || DIG_ON) {
|
||||
addEventListener('keydown', e => {
|
||||
if (e.code !== 'KeyE' || e.target.matches('input,select')) return;
|
||||
if (dig && dig.active) return;
|
||||
if (DIG_ON) { const b = binUnderAim(); if (b) { openDigOn(b); return; } } // bins → dig first
|
||||
const off = shelfUnderAim(); if (off) showShelfCard(off); // shelves → buy card
|
||||
});
|
||||
document.getElementById('hint').innerHTML += ' · <b>E</b> bin/shelf · <b>I</b> bag';
|
||||
}
|
||||
// Buy-anywhere soak: for each shelf shop type, enter, buy one shelf item, assert wallet+collapse+leak-free.
|
||||
async function shelfBuySoak() {
|
||||
const richWallet = createWallet(7);
|
||||
const realBuy = (o) => richWallet.buy(o);
|
||||
// warmup: build+render+dispose one book & toy room so the SHARED pack atlases are resident (and thus
|
||||
// counted in the baseline) — else they'd read as a false texture "leak" on first render (like soak()).
|
||||
for (const t of ['book', 'toy']) { const w = buildInterior({ id: 'warm', type: t, seed: 5 }, THREE, { stock: 'real' }); scene.add(w.group); renderer.render(scene, camera); scene.remove(w.group); w.dispose(); }
|
||||
await new Promise(r => setTimeout(r, 0));
|
||||
const bG = renderer.info.memory.geometries, bT = renderer.info.memory.textures;
|
||||
const out = { types: {}, bought: 0, broke: 0, noMesh: 0, collapseOk: true };
|
||||
for (const type of ['book', 'toy']) {
|
||||
for (const s of [1990, 7, 42]) {
|
||||
const room = buildInterior({ id: type, type, seed: s }, THREE, { stock: 'real' });
|
||||
scene.add(room.group); renderer.render(scene, camera);
|
||||
// find a buy mesh + an item; buy it directly (headless — no raycast needed)
|
||||
let mesh = null; room.group.traverse(o => { if (!mesh && o.userData && o.userData.buyMesh && o.userData.buyItems.length) mesh = o; });
|
||||
if (!mesh) { out.noMesh++; out.types[type + '/' + s] = 'NO_BUY_MESH'; scene.remove(room.group); room.dispose(); continue; }
|
||||
const before = mesh.userData.buyItems.length; const item = mesh.userData.buyItems[0]; const it = item.item;
|
||||
const cashBefore = richWallet.cash(); const price = Math.min(priceOf(it), cashBefore); // never starve the soak
|
||||
const posBefore = mesh.geometry.attributes.position.getX(item.vStart);
|
||||
if (realBuy({ t: it.title, a: it.artist, price, s: it.price_band })) {
|
||||
collapseBuyItem(mesh, item); mesh.userData.buyItems.splice(0, 1);
|
||||
const pa = mesh.geometry.attributes.position; // all 4 verts must collapse to the centre (zero area)
|
||||
for (let k = 0; k < 4; k++) if (Math.abs(pa.getX(item.vStart + k) - item.center.x) > 1e-4 || Math.abs(pa.getY(item.vStart + k) - item.center.y) > 1e-4) out.collapseOk = false;
|
||||
out.bought++;
|
||||
out.types[type + '/' + s] = { items: before, afterBuy: mesh.userData.buyItems.length, cashΔ: cashBefore - richWallet.cash() };
|
||||
} else { out.broke++; out.types[type + '/' + s] = 'BROKE'; }
|
||||
scene.remove(room.group); room.dispose();
|
||||
}
|
||||
}
|
||||
renderer.render(scene, camera);
|
||||
out.leakGeo = renderer.info.memory.geometries - bG; out.leakTex = renderer.info.memory.textures - bT;
|
||||
return out;
|
||||
}
|
||||
|
||||
// ── loop ───────────────────────────────────────────────────────────────────────
|
||||
let last = performance.now();
|
||||
function frame() {
|
||||
@ -419,7 +533,8 @@ function frame() {
|
||||
|
||||
// expose for headless verification (screenshot harness, workflow checks)
|
||||
window.PROCITY_C = { buildInterior, THREE, SHOP_TYPES, ARCHETYPE_KEYS, soak, drawSweep, DRAW_LAW, rebuild, get current() { return current; }, scene, camera, renderer,
|
||||
DIG_ON, STOCK_REAL, digSoak, openDigOn, binUnderAim, get dig() { return dig; }, wallet, preloadStockPack, getStockPack, makeStockAdapter };
|
||||
DIG_ON, STOCK_REAL, digSoak, openDigOn, binUnderAim, get dig() { return dig; }, wallet, preloadStockPack, getStockPack, makeStockAdapter,
|
||||
shelfUnderAim, showShelfCard, buyShelfOffer, shelfBuySoak };
|
||||
|
||||
rebuild();
|
||||
frame();
|
||||
|
||||
@ -25,25 +25,29 @@ export class KeeperManager {
|
||||
this._p = new THREE.Vector3();
|
||||
}
|
||||
|
||||
// spawn(target, { x, z, ry, shopId, type }) → keeper handle. `ry` = the counter's outward facing.
|
||||
spawn(target, { x = 0, z = 0, ry = 0, shopId = 'shop', type = 'shop' } = {}) {
|
||||
const r = rng(this.citySeed, 'keeper', shopId);
|
||||
// spawn(target, { x, z, ry, shopId, type, browse, pedIndex, seedKey }) → keeper handle.
|
||||
// ry = the counter's outward facing (keeper) / the browse point's shelf-facing yaw (browser).
|
||||
// browse = R9 interior-presence: a browser rig at a C browse point — faces the goods, no greet.
|
||||
// pedIndex = pick this exact fleet ped (so a browser IS the ped who ducked in off the street).
|
||||
// seedKey = per-instance seeding key (e.g. `${shopId}#${slot}`) so browsers at one shop differ.
|
||||
spawn(target, { x = 0, z = 0, ry = 0, shopId = 'shop', type = 'shop', browse = false, pedIndex = null, seedKey = null } = {}) {
|
||||
const r = rng(this.citySeed, browse ? 'browser' : 'keeper', seedKey || shopId);
|
||||
const height = 1.58 + r() * 0.34;
|
||||
let actor, kind;
|
||||
if (this.fleet && this.fleet.ready) {
|
||||
const pk = pickRig(this.fleet, r());
|
||||
const rig = pk && this.fleet.all[pk.index];
|
||||
const idx = (pedIndex != null && this.fleet.all[pedIndex]) ? pedIndex : (pickRig(this.fleet, r()) || {}).index;
|
||||
const rig = idx != null && this.fleet.all[idx];
|
||||
const spawned = rig && spawnRig(rig, { ry, height, clip: this.fleet.idleClip, phase: r() });
|
||||
if (spawned) { actor = spawned; kind = 'rig'; }
|
||||
}
|
||||
if (!actor) { // asset-free fallback
|
||||
const ph = makePlaceholder(rng(this.citySeed, 'keeper-body', shopId), { height });
|
||||
const ph = makePlaceholder(rng(this.citySeed, browse ? 'browser-body' : 'keeper-body', seedKey || shopId), { height });
|
||||
ph.fig.rotation.y = ry;
|
||||
actor = ph; kind = 'placeholder';
|
||||
}
|
||||
actor.fig.position.set(x, 0, z);
|
||||
target.add(actor.fig);
|
||||
const k = { actor, kind, baseRy: ry, curTurn: 0, target, shopId, type };
|
||||
const k = { actor, kind, baseRy: ry, curTurn: 0, target, shopId, type, browse };
|
||||
this.keepers.push(k);
|
||||
return k;
|
||||
}
|
||||
@ -67,6 +71,7 @@ export class KeeperManager {
|
||||
if (a.mixer) a.mixer.update(dt); // rig idle
|
||||
else a.tick?.(dt, false); // placeholder idle
|
||||
|
||||
if (k.browse) continue; // browsers face the goods (ry) — no greet turn
|
||||
if (pp) {
|
||||
const dx = pp.x - a.fig.position.x, dz = pp.z - a.fig.position.z;
|
||||
const dist = Math.hypot(dx, dz);
|
||||
|
||||
@ -119,9 +119,10 @@ export class CitizenSim {
|
||||
this._livelyChunks = new Set(); // chunkKeys that resist night thinning (e.g. the open-late block)
|
||||
this._encountered = new Set(); // cumulative identity signatures seen (determinism proof)
|
||||
// R8 shop patronage (default-on for the streamed roster; ?patronage=0 off). No-op until setShops.
|
||||
this.shopsByChunk = null; // chunkKey → [{ x, z, hours:[open,close] }] door points
|
||||
this.shopsByChunk = null; // chunkKey → [{ x, z, hours:[open,close], shopId }] door points
|
||||
this.patronageOn = true;
|
||||
this.weather = { state: 'clear', intensity: 0 }; // Lane B's PROCITY.weather contract (shell feeds it)
|
||||
this._occupancy = new Map(); // R9 shopId → [{ seed, enteredAt, pedIndex }] — the interior-presence truth
|
||||
this._dropKeys = []; // scratch: chunks to drop this frame
|
||||
this._activeList = []; // this frame's active citizens (both modes) — test page reads it
|
||||
this.timeOfDay = 0.5; // noon
|
||||
@ -160,14 +161,14 @@ export class CitizenSim {
|
||||
if (this.streamMode) return;
|
||||
this.roster.forEach(c => this._releaseActor(c)); this.roster.length = 0; // tear down the v1 roster
|
||||
if (!this.chunkEdges) this._buildChunkIndex();
|
||||
this.chunkRosters.clear(); this._dropKeys.length = 0; this._encountered.clear();
|
||||
this.chunkRosters.clear(); this._dropKeys.length = 0; this._encountered.clear(); this._occupancy.clear();
|
||||
this.streamRadius = radius; this.streamPerChunk = perChunk;
|
||||
this.streamMode = true; this._hookDriven = false;
|
||||
}
|
||||
disableStream() {
|
||||
if (!this.streamMode) return;
|
||||
for (const arr of this.chunkRosters.values()) arr.forEach(c => this._releaseActor(c));
|
||||
this.chunkRosters.clear(); this.streamMode = false;
|
||||
this.chunkRosters.clear(); this._occupancy.clear(); this.streamMode = false;
|
||||
}
|
||||
|
||||
// rasterise each edge's centreline to the 64m chunks it passes through → which edges seed each chunk
|
||||
@ -220,7 +221,7 @@ export class CitizenSim {
|
||||
dropChunk(key) {
|
||||
const arr = this.chunkRosters.get(key);
|
||||
if (!arr) return;
|
||||
for (const c of arr) this._releaseActor(c);
|
||||
for (const c of arr) { if (c._occShop != null) { this._removeOccupant(c._occShop, c.id); c._occShop = null; } this._releaseActor(c); }
|
||||
this.chunkRosters.delete(key);
|
||||
}
|
||||
|
||||
@ -372,13 +373,34 @@ export class CitizenSim {
|
||||
c.patron = 'inside';
|
||||
c.patronTimer = 5 + c.patronRng() * 15; // seeded dwell 5–20s
|
||||
c.x = c.patronTarget.x; c.z = c.patronTarget.z; // parked at the door while inside (hidden)
|
||||
// R9 occupancy truth: record who's inside which shop (F reads this to stand browser rigs)
|
||||
const id = c.patronTarget.shopId;
|
||||
if (id != null) {
|
||||
let occ = this._occupancy.get(id); if (!occ) this._occupancy.set(id, occ = []);
|
||||
occ.push({ seed: c.id, enteredAt: this.timeOfDay, pedIndex: c.pedIndex });
|
||||
c._occShop = id;
|
||||
}
|
||||
}
|
||||
_emerge(c) {
|
||||
if (c._occShop != null) { this._removeOccupant(c._occShop, c.id); c._occShop = null; }
|
||||
const w = c._savedWalk;
|
||||
if (w) { c.edge = w.edge; c.forward = w.forward; c.s = w.s; }
|
||||
c.patron = null; c.patronTarget = null; c._savedWalk = null;
|
||||
this._placeOnLane(c); // back on the footpath, resumes the walk
|
||||
}
|
||||
_removeOccupant(shopId, seed) {
|
||||
const occ = this._occupancy.get(shopId); if (!occ) return;
|
||||
const i = occ.findIndex(o => o.seed === seed);
|
||||
if (i >= 0) occ.splice(i, 1);
|
||||
if (!occ.length) this._occupancy.delete(shopId);
|
||||
}
|
||||
// D's occupancy truth (the C→D→F seam): how many streamed peds patronage currently has inside this
|
||||
// shop, and who (seed + entry time + ped type). F reads this on interior build, then stands one
|
||||
// browser rig per browse point for min(count, 3). Count is pre-cap.
|
||||
occupancyOf(shopId) {
|
||||
const occ = this._occupancy.get(shopId) || [];
|
||||
return { count: occ.length, occupants: occ.map(o => ({ ...o })) };
|
||||
}
|
||||
// stable identity signature of the active set — immutable spawn identity, NOT live position, so it
|
||||
// holds while citizens walk (the determinism gate: same seed → same crowd, twice). Both modes.
|
||||
identitySignature() {
|
||||
|
||||
@ -17,6 +17,8 @@
|
||||
// places: [ …meshes with userData ], // interactables: counter, bins, case, fridge, returns, exit
|
||||
// counter: { mesh, pose, stand }, // pay point. stand:{x,z,ry} = where a shopkeeper (Lane D)
|
||||
// // stands, in room-local space, facing the customer.
|
||||
// browsePoints: [ {x,z,ry,atKind,slotIndex} ], // 0..3 seeded floor poses (R9) where Lane D stands
|
||||
// // browser rigs, facing the goods (same frame + ry as counter.stand)
|
||||
// dims: { W, D, H, archetype, type }, // room metrics
|
||||
// placement, // deterministic placement summary (deep-equal per seed)
|
||||
// pathOK, // door→counter connectivity held (always true; carved if needed)
|
||||
@ -119,6 +121,7 @@ export function buildInterior(shop, THREE, opts) {
|
||||
exits: shell.exits,
|
||||
places: lay.places,
|
||||
counter: lay.counter, // { mesh, pose:{x,z,ry}, stand:{x,z,ry} } — Lane D keeper spawn pose
|
||||
browsePoints: lay.browsePoints, // [ {x,z,ry,atKind,slotIndex} ] 0..3 — Lane D browser rig poses (R9)
|
||||
dims: { ...dims, archetype, type: recipe.key },
|
||||
recipe: { key: recipe.key, label: recipe.label, counterPos: recipe.counterPos, clutter: recipe.clutter },
|
||||
placement: lay.placement,
|
||||
|
||||
@ -124,6 +124,91 @@ function keeperStand(cx, cz, front, W, D) {
|
||||
return { x: round(x), z: round(z), ry: round(Math.atan2(-dx, -dz)) };
|
||||
}
|
||||
|
||||
// ── browse points (round 9): where Lane D stands browser rigs ─────────────────────────
|
||||
// 1–3 seeded floor poses, aisle-adjacent, in front of a browsable fitting, facing the goods. Pure
|
||||
// data (no meshes, no draws) — mirrors counter.stand so keepers.js/fleet clones drop straight in.
|
||||
// Every point is walkable (occ-free, not reserved), reachable from spawn (same flood-fill as
|
||||
// door→counter), ≥MIN_SEP from each other AND the keeper stand. Deterministic per shop.seed.
|
||||
const MIN_SEP = 0.6; // min spacing between rigs (m)
|
||||
const STAND_GAP = 0.55; // how far in front of a fitting a browser stands (m)
|
||||
function placeBrowsePoints(grid, placed, counterStand, spawnCell, r) {
|
||||
// reachable floor cells from spawn (adjust off an occupied spawn cell like reaches() does)
|
||||
let start = spawnCell;
|
||||
if (grid.occ[start] !== 0) { const s = nearestFree(grid, start); if (s < 0) return []; start = s; }
|
||||
const reach = floodFill(grid, start);
|
||||
|
||||
// browsable = surviving floor fittings (not the counter, not wall-mounted). Prefer ones that actually
|
||||
// carry interactable stock (bins/cases have `places`) so browsers cluster at the goods, then bigger
|
||||
// fittings, then a seeded tiebreak — all pure functions of seed/geometry (determinism holds).
|
||||
const cand = placed
|
||||
.filter(p => !p.removed && !p.noStamp && p.kind !== 'counter')
|
||||
.map(p => ({ p, jitter: r() })) // draw seeded keys up front so ordering is seed-stable
|
||||
.sort((a, b) => {
|
||||
const ap = (a.p.fitting.places || []).length ? 1 : 0, bp = (b.p.fitting.places || []).length ? 1 : 0;
|
||||
if (ap !== bp) return bp - ap;
|
||||
const aa = a.p.hw * a.p.hd, ba = b.p.hw * b.p.hd;
|
||||
if (Math.abs(aa - ba) > 0.05) return ba - aa;
|
||||
return a.jitter - b.jitter;
|
||||
})
|
||||
.map(o => o.p);
|
||||
|
||||
const pts = [];
|
||||
const near = (x, z, list, d) => list.some(q => Math.hypot(q.x - x, q.z - z) < d);
|
||||
// Is (x,z) a valid, unclaimed stand? (walkable free floor · reachable · clear of walls/keeper/other rigs)
|
||||
const validStand = (x, z) => {
|
||||
const [cx, cz] = grid.cellOf(x, z);
|
||||
if (cx < 1 || cz < 1 || cx >= grid.cols - 1 || cz >= grid.rows - 1) return false;
|
||||
const idx = cz * grid.cols + cx;
|
||||
if (grid.occ[idx] !== 0 || grid.reserved[idx]) return false;
|
||||
if (!reach.has(idx)) return false;
|
||||
if (counterStand && Math.hypot(counterStand.x - x, counterStand.z - z) < MIN_SEP) return false;
|
||||
if (near(x, z, pts, MIN_SEP)) return false;
|
||||
return true;
|
||||
};
|
||||
const push = (x, z, p) => {
|
||||
const dx = p.x - x, dz = p.z - z; // stand → fitting; face it (counter.stand ry convention)
|
||||
pts.push({ x: round(x), z: round(z), ry: round(Math.atan2(-dx, -dz)), atKind: p.kind, slotIndex: pts.length });
|
||||
};
|
||||
// Stand candidates around a fitting: 4 sides at two gaps + 4 diagonals, tried toward-room-centre
|
||||
// first (the open aisle) so a browser faces OUT of a wall-hugging shelf, never into the wall. Richer
|
||||
// than a single ring so a valid cell survives even in a tight/pokey room where one side is blocked.
|
||||
const standsAround = (p) => {
|
||||
const tX = p.x > 0 ? -1 : 1, gW = p.hw + STAND_GAP, gW2 = p.hw + STAND_GAP + 0.35, gD = p.hd + STAND_GAP;
|
||||
return [
|
||||
{ x: p.x + tX * gW, z: p.z }, { x: p.x, z: p.z + gD },
|
||||
{ x: p.x + tX * gW, z: p.z + 0.5 }, { x: p.x + tX * gW, z: p.z - 0.5 },
|
||||
{ x: p.x + tX * gW2, z: p.z }, { x: p.x, z: p.z - gD },
|
||||
{ x: p.x - tX * gW, z: p.z }, { x: p.x - tX * gW, z: p.z + 0.5 },
|
||||
];
|
||||
};
|
||||
for (const p of cand) {
|
||||
if (pts.length >= 3) break;
|
||||
for (const s of standsAround(p)) {
|
||||
if (!validStand(s.x, s.z)) continue;
|
||||
push(s.x, s.z, p); break; // one point per fitting
|
||||
}
|
||||
}
|
||||
// Fallback: a room with browsable goods but no point yet (all fitting sides boxed in — tight pokey/
|
||||
// mismatched archetypes) still deserves ≥1 browser. Scan the reachable free floor for the cell nearest
|
||||
// a browsable fitting and stand there, facing that fitting. Deterministic (grid + cand order are seeded).
|
||||
if (pts.length === 0 && cand.length) {
|
||||
let best = null;
|
||||
for (const idx of reach) {
|
||||
if (grid.occ[idx] !== 0 || grid.reserved[idx]) continue;
|
||||
const cx = idx % grid.cols, cz = (idx / grid.cols) | 0;
|
||||
const x = (cx + 0.5) * grid.cw - grid.W / 2, z = (cz + 0.5) * grid.cd - grid.D / 2;
|
||||
if (counterStand && Math.hypot(counterStand.x - x, counterStand.z - z) < MIN_SEP) continue;
|
||||
for (const p of cand) {
|
||||
const d = Math.hypot(p.x - x, p.z - z);
|
||||
if (d < 0.5) continue; // not on top of the fitting
|
||||
if (!best || d < best.d) best = { x, z, p, d };
|
||||
}
|
||||
}
|
||||
if (best) push(best.x, best.z, best.p);
|
||||
}
|
||||
return pts;
|
||||
}
|
||||
|
||||
// ── wall-slot system (shuffled pool → art + signs + wall fittings never overlap) ──────
|
||||
function makeWallSlots(W, D, r) {
|
||||
const slots = [];
|
||||
@ -240,6 +325,11 @@ export function layout(ctx, { recipe, dims, shell, adapter, shop }) {
|
||||
// 5) WALL DECOR — art frames + inside signage on leftover wall slots (shuffled, never overlap).
|
||||
const decor = placeWallDecor(ctx, roomGroup, recipe, wallSlots, W, D, rWall);
|
||||
|
||||
// 6) BROWSE POINTS (round 9) — 1–3 seeded floor poses for Lane D's browser rigs, in front of the
|
||||
// goods, reachable + non-blocking. Pure data (no geometry). Computed AFTER the path guarantee so
|
||||
// every point sits on a survivor's open side and on a cell reachable from the door.
|
||||
const browsePoints = placeBrowsePoints(grid, placed, counter.stand, spawnCell, ctx.stream('browse'));
|
||||
|
||||
// placement summary for the determinism deep-equal test
|
||||
const placement = placed.filter(p => !p.removed).map(p => ({
|
||||
kind: p.kind || 'fitting', kindName: p.fitting.group.userData.kind,
|
||||
@ -249,7 +339,7 @@ export function layout(ctx, { recipe, dims, shell, adapter, shop }) {
|
||||
return {
|
||||
placed: placed.filter(p => !p.removed),
|
||||
places, decor: decor.meshes, grid, pathOK, carved,
|
||||
spawnCell, targetCell, placement,
|
||||
spawnCell, targetCell, placement, browsePoints,
|
||||
counter: {
|
||||
mesh: counter.fitting.group,
|
||||
pose: { x: round(counter.x), z: round(counter.z), ry: round(counter.ry) }, // the bench itself
|
||||
|
||||
@ -15,7 +15,7 @@
|
||||
// items) → few CanvasTextures to build and dispose, fast builds, soak returns to baseline.
|
||||
|
||||
import { pick } from './context.js';
|
||||
import { realSleeveMesh } from './stockpack.js';
|
||||
import { realSleeveMesh, buildBuyableShelf } from './stockpack.js';
|
||||
|
||||
// Muted 90s stock palette (cardboard, plastic, faded card).
|
||||
const CARD = ['#b49b72', '#a6b472', '#72a6b4', '#b47298', '#8a72b4', '#b48a72', '#72b48a', '#b4a672'];
|
||||
@ -165,19 +165,24 @@ function shelfLine(ctx, parent, slot, opts, r) {
|
||||
|
||||
if (kind === 'spine') { // books / VHS: thin spines edge-out
|
||||
const step = (run - 0.04) / n;
|
||||
for (let i = 0; i < n; i++) {
|
||||
if (pack) { // real book spines → one buyable, addressable mesh
|
||||
const poolItems = pack.atlasPool(r); // single atlas → the whole slot merges to one draw
|
||||
const pls = [];
|
||||
for (let i = 0; i < n; i++) {
|
||||
const bh = clear * (0.8 + r() * 0.2);
|
||||
const x = slot.x - run / 2 + (i + 0.5) * step;
|
||||
const rz = (slot.lean && i > n - 3) ? 0.18 : 0;
|
||||
pls.push({ item: pick(r, poolItems), x, y: slot.y + bh / 2, z: slot.z + depth / 2 - 0.01, w: step * 0.86, h: bh, rz });
|
||||
}
|
||||
const meshes = buildBuyableShelf(ctx, pack, pls);
|
||||
if (meshes.length) { for (const m of meshes) parent.add(m); return; } // else fall through, fail-soft
|
||||
}
|
||||
for (let i = 0; i < n; i++) { // procedural spines (no pack / merge failed)
|
||||
const bh = clear * (0.8 + r() * 0.2);
|
||||
const x = slot.x - run / 2 + (i + 0.5) * step;
|
||||
let b;
|
||||
if (pack) { // real book spine: cover plane at the shelf front
|
||||
b = realSleeveMesh(ctx, pack, pick(r, pack.items), step * 0.86, bh);
|
||||
b.position.set(x, slot.y + bh / 2, slot.z + depth / 2 - 0.01);
|
||||
parent.add(b);
|
||||
} else {
|
||||
const m = ctx.mat('#ffffff', 0.7); m.map = pick(r, pool); m.color.set('#ffffff'); m.needsUpdate = true;
|
||||
b = ctx.box(step * 0.86, bh, depth, m, x, slot.y + bh / 2, slot.z, parent);
|
||||
b.userData.isStock = true;
|
||||
}
|
||||
const m = ctx.mat('#ffffff', 0.7); m.map = pick(r, pool); m.color.set('#ffffff'); m.needsUpdate = true;
|
||||
const b = ctx.box(step * 0.86, bh, depth, m, x, slot.y + bh / 2, slot.z, parent);
|
||||
b.userData.isStock = true;
|
||||
if (slot.lean && i > n - 3) b.rotation.z = 0.18; // a couple flopped over
|
||||
}
|
||||
return;
|
||||
@ -199,20 +204,25 @@ function shelfLine(ctx, parent, slot, opts, r) {
|
||||
// multi-material box cost one draw PER face (6×), the biggest draw-count offender pre-round-6.
|
||||
const bw = Math.min((run - 0.02) / n, kind === 'snack' ? 0.12 : 0.28);
|
||||
const step = run / n;
|
||||
for (let i = 0; i < n; i++) {
|
||||
if (pack) { // real toy boxes → one buyable, addressable mesh
|
||||
const poolItems = pack.atlasPool(r); // single atlas → the whole slot merges to one draw
|
||||
const pls = [];
|
||||
for (let i = 0; i < n; i++) {
|
||||
const bh = clear * (kind === 'snack' ? 0.6 : 0.62 + r() * 0.28);
|
||||
const x = slot.x - run / 2 + (i + 0.5) * step;
|
||||
pls.push({ item: pick(r, poolItems), x, y: slot.y + bh / 2, z: slot.z + depth / 2 - 0.01, w: bw * 0.88, h: bh });
|
||||
}
|
||||
const meshes = buildBuyableShelf(ctx, pack, pls);
|
||||
if (meshes.length) { for (const m of meshes) parent.add(m); return; } // else fall through, fail-soft
|
||||
}
|
||||
for (let i = 0; i < n; i++) { // procedural cartons (no pack / merge failed)
|
||||
const bh = clear * (kind === 'snack' ? 0.6 : 0.62 + r() * 0.28);
|
||||
const bd = Math.min(depth, kind === 'snack' ? 0.1 : 0.26);
|
||||
const x = slot.x - run / 2 + (i + 0.5) * step;
|
||||
if (pack) { // real toy box: cover plane at the carton front
|
||||
const b = realSleeveMesh(ctx, pack, pick(r, pack.items), bw * 0.88, bh);
|
||||
b.position.set(x, slot.y + bh / 2, slot.z + depth / 2 - 0.01);
|
||||
parent.add(b);
|
||||
} else {
|
||||
const m = ctx.mat('#ffffff', 0.8);
|
||||
m.map = faceTex(ctx, kind, opts, r); m.color.set('#ffffff'); m.needsUpdate = true;
|
||||
const mesh = ctx.box(bw * 0.88, bh, bd, m, x, slot.y + bh / 2, slot.z + depth / 2 - bd / 2 - 0.01, parent);
|
||||
mesh.userData.isStock = true;
|
||||
}
|
||||
const m = ctx.mat('#ffffff', 0.8);
|
||||
m.map = faceTex(ctx, kind, opts, r); m.color.set('#ffffff'); m.needsUpdate = true;
|
||||
const mesh = ctx.box(bw * 0.88, bh, bd, m, x, slot.y + bh / 2, slot.z + depth / 2 - bd / 2 - 0.01, parent);
|
||||
mesh.userData.isStock = true;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -13,6 +13,7 @@
|
||||
// Fail-soft: index/atlas missing → getStockPack returns null → callers fall back to parody canvas.
|
||||
|
||||
import * as THREE_NS from 'three';
|
||||
import { mergeGeometries } from 'three/addons/utils/BufferGeometryUtils.js';
|
||||
|
||||
const _texLoader = new THREE_NS.TextureLoader();
|
||||
const _packs = new Map(); // type → Promise<pack|null>
|
||||
@ -44,11 +45,21 @@ async function buildPack(type, idx, base, THREE) {
|
||||
materials[name] = new THREE.MeshStandardMaterial({ color: 0xffffff, roughness: 0.72, map: tex || null });
|
||||
}));
|
||||
const first = names[0];
|
||||
// Items grouped by atlas — so a buyable shelf can draw all its covers from ONE atlas and merge to a
|
||||
// single mesh (one draw), instead of fragmenting into one mesh per atlas the random picks happened to hit.
|
||||
const byAtlas = {};
|
||||
for (const name of names) byAtlas[name] = [];
|
||||
for (const it of idx.items) (byAtlas[it.atlas] || byAtlas[first] || (byAtlas[first] = [])).push(it);
|
||||
const nonEmpty = names.filter(n => byAtlas[n] && byAtlas[n].length);
|
||||
return {
|
||||
type,
|
||||
items: idx.items,
|
||||
atlases: nonEmpty.length ? nonEmpty : names,
|
||||
itemsByAtlas: byAtlas,
|
||||
// shared material for an item's atlas (batching groups sleeves by this)
|
||||
material(item) { return materials[item.atlas] || materials[first]; },
|
||||
// items from a seeded single atlas (buyable shelves use this to stay one-mesh/one-draw)
|
||||
atlasPool(r) { const a = (this.atlases.length ? this.atlases : names); const name = a[(r() * a.length) | 0]; return byAtlas[name] && byAtlas[name].length ? byAtlas[name] : idx.items; },
|
||||
// WebGL-flipped cover rect [u0, v0, u1, v1] (origin bottom-left) for a plane's UV
|
||||
uvRect(item) { const u = item.uv; return [u[0], 1 - u[3], u[2], 1 - u[1]]; },
|
||||
};
|
||||
@ -79,3 +90,57 @@ export function realSleeveMesh(ctx, pack, item, w, h) {
|
||||
mesh.userData.isStock = true;
|
||||
return mesh;
|
||||
}
|
||||
|
||||
// One quad geometry for a placement, UV-baked to its atlas rect and transformed into fitting-local
|
||||
// space (so the merged mesh sits at identity). Shared shape with realSleeveMesh, minus the Mesh.
|
||||
function itemQuad(THREE, pack, pl) {
|
||||
const g = new THREE.PlaneGeometry(pl.w, pl.h);
|
||||
const [u0, v0, u1, v1] = pack.uvRect(pl.item);
|
||||
const a = g.attributes.uv;
|
||||
a.setXY(0, u0, v1); a.setXY(1, u1, v1); a.setXY(2, u0, v0); a.setXY(3, u1, v0);
|
||||
if (pl.rz) g.rotateZ(pl.rz);
|
||||
if (pl.rx) g.rotateX(pl.rx);
|
||||
g.translate(pl.x, pl.y, pl.z);
|
||||
return g;
|
||||
}
|
||||
|
||||
// BUY-ANYWHERE (round 9): build a shelf of real covers as merged, per-item-ADDRESSABLE meshes — one
|
||||
// mesh per atlas bucket (so all share a material and draw once), tagged `noBatch` so batch.js leaves
|
||||
// them intact (they're already one draw each). Each mesh carries `buyItems:[{item,center,vStart}]` and
|
||||
// a `sold` list; collapseBuyItem() zero-areas a bought item's quad in place. Records stay the dig; this
|
||||
// is book spines + toy boxes. Returns [] on merge failure → caller falls back to plain realSleeveMesh.
|
||||
// The merged geometries are ctx-tracked → freed with the room (leak-free).
|
||||
export function buildBuyableShelf(ctx, pack, placements) {
|
||||
const THREE = ctx.THREE;
|
||||
const byAtlas = new Map(); // material.uuid → { material, pls:[] }
|
||||
for (const pl of placements) {
|
||||
const mat = pack.material(pl.item); if (!mat) continue;
|
||||
let b = byAtlas.get(mat.uuid); if (!b) { b = { material: mat, pls: [] }; byAtlas.set(mat.uuid, b); }
|
||||
b.pls.push(pl);
|
||||
}
|
||||
const out = [];
|
||||
for (const b of byAtlas.values()) {
|
||||
const geos = b.pls.map(pl => itemQuad(THREE, pack, pl));
|
||||
let merged; try { merged = mergeGeometries(geos, false); } catch (e) { merged = null; }
|
||||
geos.forEach(g => g.dispose());
|
||||
if (!merged) continue;
|
||||
ctx._geometries.add(merged); // freed at room dispose
|
||||
const mesh = new THREE.Mesh(merged, b.material);
|
||||
mesh.userData.noBatch = true; // already one draw — batch.js skips it
|
||||
mesh.userData.buyMesh = true;
|
||||
mesh.userData.buyItems = b.pls.map((pl, i) => ({ item: pl.item, center: { x: pl.x, y: pl.y, z: pl.z }, vStart: i * 4 }));
|
||||
mesh.userData.sold = [];
|
||||
out.push(mesh);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// Zero-area a bought item's quad (collapse its 4 verts to the item centre → invisible), in place, in
|
||||
// the merged buyable mesh. Leak-free (no geometry churn). `it` is an entry from mesh.userData.buyItems.
|
||||
export function collapseBuyItem(mesh, it) {
|
||||
const pos = mesh.geometry.attributes.position;
|
||||
for (let k = 0; k < 4; k++) pos.setXYZ(it.vStart + k, it.center.x, it.center.y, it.center.z);
|
||||
pos.needsUpdate = true;
|
||||
mesh.geometry.computeBoundingSphere();
|
||||
if (mesh.userData.sold) mesh.userData.sold.push(it.vStart);
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user