Lane C round-9 C2: buy-anywhere — pull-and-buy on book spines + toy boxes
Book/toy shelves get pull-and-buy over the SAME wallet; records keep the deep dig. New stockpack.buildBuyableShelf(): a real shelf is merged, per-item-ADDRESSABLE meshes — one per atlas (all covers drawn from one seeded atlas → one draw/shelf), tagged noBatch so batch.js leaves them intact. Aim a spine/box → pull card (title/author-or-maker/$price/band from the pack index) → BUY debits the wallet, adds to inventory, and collapseBuyItem() zero-areas the item's quad in place (removed from the shelf, no geometry churn). Parody stock → no buy mesh → no card. Fail- soft per pack; atlasPool() keeps each shelf single-atlas (one draw). Test page: E interacts (bin→dig, shelf→card) + a #shelfBuy pull card; shelfBuySoak() buys one item per book/toy seed. Validated: raycast→card →BUY end-to-end (quad collapses to centre, cash/inventory update), buy-soak 6/6 collapseOk leak geo0/tex0, ?stock=real draws record 41/ book 58/toy 57 ≤350, drawSweep GLB-off 168/on 344 unchanged, placement determinism 0 fails, qa --strict GREEN 5/5. Shot: browse_buy_r9.jpg. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
5ae635a1e9
commit
c68bba8f32
@ -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,9 +1,14 @@
|
||||
# LANE C — cross-lane notes (PROCITY-C)
|
||||
|
||||
## → Lane D + Lane F: BROWSE-POINTS interface (round-9 C1 handshake — AGREE BEFORE CODE)
|
||||
## → Lane D + Lane F: BROWSE-POINTS interface (round-9 C1 — **LANDED**, code live)
|
||||
|
||||
This is decision #1's contract, published *first* so D and F can build in parallel. It is the seam the
|
||||
whole round hangs off. **C owns the points + room fit; D owns occupancy truth; F owns the handoff.**
|
||||
**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.
|
||||
|
||||
### What C exposes on the room API (live once C1 commits)
|
||||
|
||||
@ -52,6 +57,24 @@ 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:
|
||||
|
||||
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 |
@ -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';
|
||||
@ -420,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() {
|
||||
@ -431,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();
|
||||
|
||||
@ -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