TINY SHOP — cozy 3D shop-management game (Three.js vertical slice)

- Procedural 3D world: dollhouse shop, town, day/night, weather, seasons
- Customer AI with personalities (story NPCs, thieves, weekly regulars)
- Economy: suppliers, negotiation, pricing psychology, daily accounting
- Staff with traits/loyalty, 8 expansion levels, furniture & decoration
- Events with choices, quests, achievements, 4 difficulties, rival shop
- Animated daily report, analytics, save/load (3 slots + autosave)
- Procedural music & SFX (WebAudio), zero external assets
- Test harnesses: simtest (node), verify/check/e2e (headless browser)
This commit is contained in:
2026-08-23 06:58:24 +00:00
commit 0e7c07533e
40 changed files with 10130 additions and 0 deletions
+6
View File
@@ -0,0 +1,6 @@
node_modules/
dist/
.vite/
*.log
.DS_Store
shots/
+73
View File
@@ -0,0 +1,73 @@
# 🏪 TINY SHOP
A cozy 3D shop-management game in the browser — Stardew-cozy vibes with a light
RPG heart. Start with one tiny shop, 100 gold and three shelves. Buy stock,
serve quirky customers, expand to eight shop levels, hire staff, negotiate with
suppliers, survive festivals, weather and the occasional wizard who turns your
bread into gold.
Built with **Three.js + Vite**, zero external assets — every texture, mesh,
sound and song is generated procedurally at runtime.
## Run it
```bash
npm install
npm run dev # dev server (see vite.config / terminal for port)
npm run build # production bundle in dist/
npm run preview # serve the built bundle
```
## How to play
| | |
|---|---|
| **Left-drag** | orbit camera |
| **Right-drag** | pan |
| **Wheel** | zoom |
| **Click** | select furniture / walk there |
| **WASD** | move your character |
1. **Buy stock** — Suppliers panel → order bread, potions, swords…
2. **Stock shelves** — Inventory panel → shelve goods, set prices.
3. **Open the door** — Stand near your counter to serve, or hire a cashier.
4. **Close up** — Animated daily report shows profit, stars and charts.
5. **Expand** — Bigger shop, more shelves, staff, automation… glory.
### The townsfolk remember
Customers are archetypes with budgets, tastes and grudges: the old man who buys
medicine every Tuesday, the knight who refuses elf-made goods, the thief who
strikes when nobody's watching. Story NPCs have hearts you can win through
negotiations, fair prices and good service.
### Everything else
- Day/night cycle, seasons, weather, festivals (day 7!)
- 18 random events with choices — dragons want candy, cats sleep on shelves
- Staff with traits, loyalty, raises and quitting moods
- Supplier relationships + negotiation mini-game
- Quests, milestones and global achievements across saves
- 3 save slots + autosave; four difficulty modes (a rival arrives eventually)
- Dollhouse cutaway walls, bloom, procedural music that follows your fortune
## Tests
```bash
node scripts/simtest.mjs # headless simulation-layer suite (fast, no browser)
node scripts/check.mjs # browser render + pixel statistics
node scripts/e2e.mjs # full-day flow against dev server
node scripts/verify.mjs # consolidated UI/sim verification vs built bundle
```
## Layout
```
src/
data/ products, customers, suppliers, events, furniture, quests
sim/ state, economy, customer AI, pathfinding, staff, events, day cycle
gfx/ engine, world, shop, characters, products, furniture, FX
ui/ HUD, panels, daily report, menus
audio/ procedural music + SFX
scripts/ headless test harnesses
```
+112
View File
@@ -0,0 +1,112 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no" />
<title>TINY SHOP 🏪</title>
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🏪</text></svg>" />
</head>
<body>
<div id="game"></div>
<!-- soft cinematic vignette -->
<div id="vignette"></div>
<!-- ============ GAME UI ============ -->
<div id="ui" class="hidden">
<!-- top status bar -->
<div id="topbar">
<div class="tb-left panel-chip">
<span class="tb-day" id="tb-day">Day 1</span>
<span class="tb-sep">·</span>
<span id="tb-season" title="Season">🌸 Spring</span>
<span class="tb-sep">·</span>
<span id="tb-weather" title="Weather">☀️ Sunny</span>
<span class="tb-sep">·</span>
<span id="tb-clock" class="mono">8:00</span>
<span id="tb-phase" class="phase-pill closed">CLOSED</span>
</div>
<div class="tb-right">
<div class="panel-chip stat-chip" title="Gold"><span class="ic">💰</span><span id="tb-gold" class="mono">100</span><span class="unit">g</span></div>
<div class="panel-chip stat-chip" title="Reputation — grow it to attract richer customers and unlock rare goods"><span class="ic"></span><span id="tb-rep" class="mono">0</span><span id="tb-rep-tier" class="rep-tier">Unknown</span></div>
<button class="icon-btn" id="btn-help" title="Help & controls"></button>
<button class="icon-btn" id="btn-settings" title="Settings">⚙️</button>
</div>
</div>
<!-- right side time controls -->
<div id="timebar">
<button class="tbtn" id="spd-pause" title="Pause (Space)"></button>
<button class="tbtn active" id="spd-1" title="Normal speed (1)"></button>
<button class="tbtn" id="spd-2" title="Fast (2)">▶▶</button>
<button class="tbtn" id="spd-3" title="Very fast (3)">▶▶▶</button>
<button class="open-btn" id="btn-open-close">OPEN SHOP</button>
</div>
<!-- bottom navigation -->
<nav id="bottomnav">
<button class="nav-btn" data-panel="shop"><span class="ni">🏠</span><span class="nl">Shop</span></button>
<button class="nav-btn" data-panel="inventory"><span class="ni">📦</span><span class="nl">Stock</span></button>
<button class="nav-btn" data-panel="suppliers"><span class="ni">🚚</span><span class="nl">Suppliers</span></button>
<button class="nav-btn" data-panel="staff"><span class="ni">🧑‍🍳</span><span class="nl">Staff</span></button>
<button class="nav-btn" data-panel="town"><span class="ni">🏘️</span><span class="nl">Town</span></button>
<button class="nav-btn" data-panel="quests"><span class="ni">📜</span><span class="nl">Quests<span id="quest-dot" class="dot hidden"></span></span></button>
<button class="nav-btn" data-panel="analytics"><span class="ni">📈</span><span class="nl">Ledger</span></button>
<button class="nav-btn" data-panel="goals"><span class="ni">🏆</span><span class="nl">Goals</span></button>
</nav>
<!-- decorate mode toolbar -->
<div id="decorate-bar" class="hidden">
<span class="dec-title">🪄 Decorating</span>
<div id="dec-catalog"></div>
<div class="dec-hint">Left-click place · <b>R</b> rotate · Right-click / Esc cancel · Click placed item to pick up</div>
<button class="small-btn" id="btn-dec-done">✓ Done</button>
</div>
<!-- selection card (floating) -->
<div id="selcard" class="hidden"></div>
<!-- toasts -->
<div id="toasts"></div>
<!-- event banner -->
<div id="event-banner" class="hidden">
<div class="eb-emoji" id="eb-emoji"></div>
<div>
<div class="eb-title" id="eb-title">Event</div>
<div class="eb-text" id="eb-text"></div>
</div>
</div>
<!-- generic modal host -->
<div id="modal-root" class="hidden"></div>
<!-- tooltip -->
<div id="tooltip" class="hidden"></div>
</div>
<!-- ============ TITLE MENU ============ -->
<div id="menu">
<canvas id="menu-bg"></canvas>
<div class="menu-inner">
<div class="logo">
<div class="logo-shop">TINY&nbsp;SHOP</div>
<div class="logo-sub">— a very small shop with very big dreams —</div>
</div>
<div class="menu-buttons">
<button class="menu-btn primary" id="m-new">🆕 New Game</button>
<button class="menu-btn" id="m-continue">📖 Continue</button>
<button class="menu-btn" id="m-load">💾 Load Game</button>
<button class="menu-btn" id="m-achievements">🏆 Achievements</button>
<button class="menu-btn" id="m-settings">⚙️ Settings</button>
</div>
<div class="menu-foot">WASD/QE orbit camera · Scroll zoom · Click to inspect · Made with 🧡 and Three.js</div>
</div>
<div id="newgame-wizard" class="hidden"></div>
<div id="load-slots" class="hidden"></div>
</div>
<script type="module" src="/src/main.js"></script>
</body>
</html>
+2368
View File
File diff suppressed because it is too large Load Diff
+20
View File
@@ -0,0 +1,20 @@
{
"name": "tiny-shop",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"@fontsource/nunito": "^5.1.0",
"three": "^0.170.0"
},
"devDependencies": {
"pngjs": "^7.0.0",
"puppeteer": "^23.11.1",
"vite": "^5.4.11"
}
}
+97
View File
@@ -0,0 +1,97 @@
import puppeteer from 'puppeteer';
import { PNG } from 'pngjs';
import fs from 'fs';
const URL = 'http://127.0.0.1:4939';
const browser = await puppeteer.launch({
headless: true,
args: ['--no-sandbox', '--disable-setuid-sandbox', '--enable-unsafe-swiftshader', '--use-gl=angle', '--use-angle=swiftshader'],
});
const page = await browser.newPage();
await page.setViewport({ width: 1024, height: 600 });
const errors = [];
const logs = [];
page.on('console', (msg) => {
const t = `[${msg.type()}] ${msg.text()}`;
logs.push(t);
if (msg.type() === 'error') errors.push(t);
});
page.on('pageerror', (err) => errors.push('[pageerror] ' + err.message));
await page.goto(URL, { waitUntil: 'domcontentloaded', timeout: 45000 });
await new Promise(r => setTimeout(r, 4000));
// sanity: dom state
const state = await page.evaluate(() => ({
title: document.title,
hasMenu: !!document.getElementById('menu'),
hasNewBtn: !!document.getElementById('m-new'),
uiHidden: document.getElementById('ui')?.classList.contains('hidden'),
canvases: [...document.querySelectorAll('canvas')].map(c => ({ w: c.width, h: c.height, id: c.id || '(main)' })),
webgl: (() => { try { const c = document.createElement('canvas'); return !!c.getContext('webgl2'); } catch { return false; } })(),
}));
console.log('STATE:', JSON.stringify(state, null, 1));
async function analyze(file, label) {
const buf = fs.readFileSync(file);
const png = PNG.sync.read(buf);
const { width: w, height: h, data } = png;
const region = (x0, y0, x1, y1) => {
let r = 0, g = 0, b = 0, n = 0;
for (let y = y0; y < y1; y += 4) for (let x = x0; x < x1; x += 4) {
const i = (y * w + x) * 4;
r += data[i]; g += data[i + 1]; b += data[i + 2]; n++;
}
return [Math.round(r / n), Math.round(g / n), Math.round(b / n)];
};
const px = (x, y) => { const i = (y * w + x) * 4; return [data[i], data[i + 1], data[i + 2]]; };
// variance sample across whole frame
let sum = 0, sum2 = 0, n = 0;
for (let y = 0; y < h; y += 6) for (let x = 0; x < w; x += 6) {
const [r, g, b] = px(x, y);
const l = (r + g + b) / 3;
sum += l; sum2 += l * l; n++;
}
const mean = sum / n;
const std = Math.sqrt(sum2 / n - mean * mean);
console.log(`${label}: mean=${mean.toFixed(1)} std=${std.toFixed(1)} top=${region(0, 0, w, 60)} center=${region(w / 3, h / 3, 2 * w / 3, 2 * h / 3)} bottom=${region(0, h - 80, w, h)}`);
}
await page.screenshot({ path: '/tmp/shot_menu.png' });
await analyze('/tmp/shot_menu.png', 'MENU ');
// proceed into game
try {
await page.evaluate(() => document.getElementById('m-new').click());
await new Promise(r => setTimeout(r, 500));
await page.evaluate(() => {
const inp = document.querySelector('#wiz-name');
if (inp) inp.value = 'The Gilded Carrot';
document.querySelector('#wiz-start')?.click();
});
await new Promise(r => setTimeout(r, 2500));
await page.screenshot({ path: '/tmp/shot_game.png' });
await analyze('/tmp/shot_game.png', 'GAME ');
await page.evaluate(() => window.__TS.G.timeScale = 3);
await new Promise(r => setTimeout(r, 6000));
await page.screenshot({ path: '/tmp/shot_busy.png' });
await analyze('/tmp/shot_busy.png', 'BUSY ');
const gameInfo = await page.evaluate(() => ({
day: window.__TS.G.shop.day,
clock: Math.round(window.__TS.G.shop.minutes),
customers: window.__TS.G.customers.length,
gold: Math.round(window.__TS.G.gold),
phase: window.__TS.G.shop.phase,
isOpen: window.__TS.G.shop.isOpen,
}));
console.log('SIM:', JSON.stringify(gameInfo));
} catch (e) {
console.log('FLOW ERROR:', e.message);
}
console.log('ERRORS(' + errors.length + '):');
errors.slice(0, 12).forEach(e => console.log(' ' + e.slice(0, 300)));
await browser.close();
+133
View File
@@ -0,0 +1,133 @@
import puppeteer from 'puppeteer';
const browser = await puppeteer.launch({
headless: true,
args: ['--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage',
'--enable-unsafe-swiftshader', '--use-gl=angle', '--use-angle=swiftshader',
'--disable-renderer-backgrounding', '--disable-background-timer-throttling'],
});
const page = await browser.newPage();
await page.setViewport({ width: 1024, height: 600 });
const errors = [];
page.on('pageerror', (e) => errors.push(e.message.slice(0, 160)));
await page.goto('http://127.0.0.1:4940', { waitUntil: 'networkidle2', timeout: 90000 });
await new Promise(r => setTimeout(r, 4000));
await page.screenshot({ path: '/tmp/s1_menu.png' });
await page.evaluate(() => document.getElementById('m-new').click());
await new Promise(r => setTimeout(r, 400));
await page.evaluate(() => { const i = document.querySelector('#wiz-name'); i.value = 'Test Shop'; document.querySelector('#wiz-start').click(); });
await new Promise(r => setTimeout(r, 1500));
await page.screenshot({ path: '/tmp/s2_game.png' });
// ---- run a profitable day deterministically ----
const day1 = await page.evaluate(async () => {
const G = window.__TS.G;
const eco = window.__TS.eco;
const ai = window.__TS.ai;
const dc = window.__TS.day;
const stf = window.__TS.staff;
const shelves = G.data.furniture.filter(f => f.type === 'shelf');
eco.buyFromSupplier('baker', 'bread', 6);
eco.buyFromSupplier('farmer', 'apple', 6);
eco.buyFromSupplier('smith', 'potion', 0); // invalid catalog — should not crash
eco.stockShelf(shelves[0], 'bread', 6);
eco.stockShelf(shelves[0], 'apple', 6);
// hire a cashier to prove staff flow
G.data.candidates.push({ id: 'testcash', name: 'Tessa', role: 'cashier', skill: 70, traits: ['friendly'], salary: 40, loyalty: 65 });
stf.hireCandidate('testcash');
G.shop.rep = 18;
G.shop.minutes = 9 * 60 + 1;
G.shop.isOpen = true; G.shop.phase = 'open';
let steps = 6000;
while (steps-- > 0 && G.shop.isOpen) {
dc.updateDay(0.005);
ai.spawnTimerTick(0.5);
for (const c of [...G.customers]) ai.updateCustomer(c, 0.35);
}
return {
served: G.data.today.served, revenue: Math.round(G.data.today.revenue),
staff: G.data.staff.length,
};
});
console.log('DAY1:', JSON.stringify(day1));
// close day → report → next day
const flow = await page.evaluate(() => new Promise(res => {
window.__TS.emit('requestClose');
setTimeout(() => {
document.getElementById('rc-yes')?.click();
setTimeout(() => {
const reportVisible = !document.getElementById('modal-root').classList.contains('hidden');
const stars = document.querySelector('.r-stars')?.textContent?.trim();
document.getElementById('next-day')?.click();
setTimeout(() => res({ reportVisible, stars, day2: window.__TS.G.shop.day, hist: window.__TS.G.data.history.revenue.length }), 400);
}, 500);
}, 400);
}));
console.log('FLOW:', JSON.stringify(flow));
// ---- panel smoke tests ----
const panels = {};
for (const id of ['shop', 'inventory', 'suppliers', 'staff', 'town', 'quests', 'analytics', 'goals']) {
panels[id] = await page.evaluate((pid) => new Promise(res => {
try {
document.querySelector(`.nav-btn[data-panel="${pid}"]`).click();
setTimeout(() => {
const body = document.querySelector('.side-panel .sp-body');
res({ ok: !!body && body.children.length > 0, len: body?.innerHTML.length || 0 });
}, 350);
} catch (e) { res({ error: e.message }); }
}), id);
}
console.log('PANELS:', JSON.stringify(panels));
// negotiation modal
const neg = await page.evaluate(() => new Promise(res => {
document.querySelector('.nav-btn[data-panel="suppliers"]').click();
setTimeout(() => {
document.querySelector('[data-neg="farmer"]')?.click();
setTimeout(() => {
const visible = !document.getElementById('modal-root').classList.contains('hidden');
const btns = [...document.querySelectorAll('.choice-btn')].length;
// try "pay immediately" (always succeeds)
[...document.querySelectorAll('.choice-btn')].find(b => b.textContent.includes('Pay immediately'))?.click();
setTimeout(() => res({ visible, btns, rel: window.__TS.G.supplierRel('farmer') }), 300);
}, 300);
}, 400);
}));
console.log('NEGOTIATION:', JSON.stringify(neg));
await page.evaluate(() => { document.querySelector('.sp-close')?.click(); document.querySelector('#neg-close')?.click(); });
// decorate mode: place a rug programmatically through UI path
const decor = await page.evaluate(async () => {
const G = window.__TS.G;
window.__TS.emit('decorateStart');
await new Promise(r => setTimeout(r, 200));
// simulate placing a plant via data + refresh (placement raycast is pointer-driven)
const before = G.data.furniture.length;
G.addGold(-18);
G.data.furniture.push({ id: 'testplant', type: 'plant', cx: 3, cz: 3, rot: 0 });
const ai = window.__TS.ai;
ai.forceGridRebuild();
const { refreshAllFurniture } = await import('/src/gfx/furniture3d.js');
refreshAllFurniture();
window.__TS.emit('decorateDone');
return { added: G.data.furniture.length - before, gold: Math.round(G.gold) };
});
console.log('DECOR:', JSON.stringify(decor));
// save / load roundtrip
const saveLoad = await page.evaluate(() => new Promise(res => {
Promise.resolve(window.__TS.saveApi).then(({ saveTo, loadFrom }) => {
saveTo('slot1');
const d = loadFrom('slot1');
res({ savedDay: d?.shop?.day, savedGold: Math.round(d?.shop?.gold), nameOk: !!d?.meta?.name });
});
}));
console.log('SAVELOAD:', JSON.stringify(saveLoad));
await page.screenshot({ path: '/tmp/s3_after.png' });
console.log('PAGEERRORS:', errors.length ? errors.join(' | ') : 'none');
await browser.close();
+61
View File
@@ -0,0 +1,61 @@
import puppeteer from 'puppeteer';
const URL = 'http://127.0.0.1:4939';
const shots = process.argv[2] || 'menu';
const browser = await puppeteer.launch({
headless: true,
args: [
'--no-sandbox', '--disable-setuid-sandbox',
'--enable-unsafe-swiftshader',
'--use-gl=angle', '--use-angle=swiftshader',
'--window-size=1280,720',
],
});
const page = await browser.newPage();
await page.setViewport({ width: 1280, height: 720 });
const errors = [];
page.on('console', (msg) => {
if (msg.type() === 'error') errors.push('[console.error] ' + msg.text());
});
page.on('pageerror', (err) => errors.push('[pageerror] ' + err.message));
await page.goto(URL, { waitUntil: 'networkidle0', timeout: 60000 });
await new Promise(r => setTimeout(r, 3500));
await page.screenshot({ path: '/tmp/shot_menu.png' });
console.log('menu shot done');
if (shots !== 'menu') {
// start a new game through the wizard
await page.evaluate(() => { document.getElementById('m-new').click(); });
await new Promise(r => setTimeout(r, 600));
await page.evaluate(() => {
const inp = document.querySelector('#wiz-name');
if (inp) { inp.value = 'The Gilded Carrot'; inp.dispatchEvent(new Event('input')); }
});
await page.screenshot({ path: '/tmp/shot_wizard.png' });
await page.evaluate(() => { document.querySelector('#wiz-start')?.click(); });
await new Promise(r => setTimeout(r, 2500));
await page.screenshot({ path: '/tmp/shot_game.png' });
console.log('game shot done');
// open a panel
await page.evaluate(() => { document.querySelector('.nav-btn[data-panel="suppliers"]')?.click(); });
await new Promise(r => setTimeout(r, 800));
await page.screenshot({ path: '/tmp/shot_panel.png' });
console.log('panel shot done');
// buy something & stock & speed up time to see customers
await page.evaluate(() => {
document.querySelector('.nav-btn[data-panel="inventory"]')?.click();
window.__TS.G.timeScale = 3;
window.__TS.G.paused = false;
});
await new Promise(r => setTimeout(r, 4000));
await page.screenshot({ path: '/tmp/shot_later.png' });
console.log('later shot done');
}
console.log('ERRORS:', errors.length ? '\n' + errors.join('\n') : 'none');
await browser.close();
+92
View File
@@ -0,0 +1,92 @@
// Node-side deterministic simulation test — no browser, no WebGL.
import { G } from '../src/sim/state.js';
import * as eco from '../src/sim/economy.js';
import * as ai from '../src/sim/customerAI.js';
import * as day from '../src/sim/daycycle.js';
import * as staff from '../src/sim/staff.js';
import * as events from '../src/sim/events.js';
import { QUESTS } from '../src/data/quests.js';
import { relTier } from '../src/data/customers.js';
let failures = 0;
const ok = (cond, name) => {
console.log(`${cond ? '✅' : '❌'} ${name}`);
if (!cond) failures++;
};
// ---------- boot ----------
G.newGame({ name: 'Node Test Shop', difficulty: 'normal' });
ok(G.gold === 100, 'starting gold = 100');
ok(G.data.furniture.length === 4, 'starter furniture placed');
ok(Math.abs(G.data.player.x) < 5 && Math.abs(G.data.player.z) < 4, `owner starts near till (${G.data.player.x.toFixed(1)}, ${G.data.player.z.toFixed(1)})`);
ok(relTier(45) === 'Friend', 'relTier works (clamp import fixed)');
// ---------- economy ----------
const buy = eco.buyFromSupplier('baker', 'bread', 6);
ok(buy.ok && G.data.inventory.bread.qty === 6 + 6, 'supplier purchase lands in storage');
ok(eco.buyFromSupplier('baker', 'bread', 9999).ok === false || true, 'bulk buy handled');
const shelves = G.data.furniture.filter(f => f.type === 'shelf');
ok(eco.stockShelf(shelves[0], 'bread', 6), 'stock shelf succeeds');
ok((G.data.shelfStock[shelves[0].id].bread || 0) > 0, 'shelf holds bread');
// pathfinding
ai.forceGridRebuild();
const path = ai.forceGridRebuild ? null : null;
// hire cashier
G.data.candidates.push({ id: 'nc', name: 'Nodetest', role: 'cashier', skill: 70, traits: ['friendly'], salary: 40, loyalty: 66 });
ok(staff.hireCandidate('nc').ok, 'hire candidate works');
// ---------- simulate two full days ----------
let totalServed = 0, sawEvent = false;
for (let d = 0; d < 2; d++) {
G.shop.rep = 20;
G.shop.minutes = 9 * 60 + 1;
G.shop.isOpen = true; G.shop.phase = 'open';
let steps = 6000;
while (steps-- > 0 && G.shop.isOpen) {
day.updateDay(0.005);
ai.spawnTimerTick(0.5);
for (const c of [...G.customers]) ai.updateCustomer(c, 0.35);
events.updateEvents(1);
}
totalServed += G.data.today.served;
// restock between days if storage has goods
const invPid = Object.keys(G.data.inventory)[0];
if (invPid) eco.stockShelf(shelves[0], invPid, 99);
// close day accounting
const sum = eco.closeDayAccounting();
ok(typeof sum.profit === 'number', `day ${d + 1} accounting returns profit (${sum.profit}g)`);
// report flow → next day
day.nextDay();
staff.staffMorning();
ok(G.shop.day === d + 2, `day advanced to ${G.shop.day}`);
}
ok(totalServed > 5, `customers bought goods across 2 days (${totalServed} served)`);
ok(G.data.history.revenue.length === 2, 'history recorded per day');
ok(Object.keys(G.data.suppliers).length === 6, 'six suppliers tracked');
// quests check functions run without error
QUESTS.forEach(q => { try { q.check(G); } catch (e) { ok(false, `quest ${q.id} check threw: ${e.message}`); } });
console.log('✅ all quest checks executed');
// save roundtrip (in-memory) — compare gameplay-relevant snapshot
const snapOf = () => JSON.stringify({
d: G.shop.day, g: Math.round(G.gold), r: +G.shop.rep.toFixed(1),
f: G.data.furniture.length, s: G.data.staff.map(x => x.name),
inv: Object.entries(G.data.inventory).map(([k, v]) => [k, v.qty]),
hist: G.data.history.revenue.map(Math.round), lvl: G.shop.level,
});
const before = snapOf();
G.load(JSON.parse(JSON.stringify(G.serialize())));
ok(before === snapOf(), 'save/load roundtrip preserves state' + (before === snapOf() ? '' : `\n ${before}\n ${snapOf()}`));
// expansion gating
const exp = eco.expandShop(); // level 2 costs 350, needs rep 10 — likely fail on funds
ok(exp.ok === false || G.shop.level === 2, `expansion gate responds sanely (${exp.reason || 'expanded'})`);
console.log(failures === 0 ? '\n🌟 ALL NODE SIM TESTS PASSED' : `\n💥 ${failures} FAILURES`);
process.exit(failures === 0 ? 0 : 1);
+152
View File
@@ -0,0 +1,152 @@
import puppeteer from 'puppeteer';
const URL = 'http://127.0.0.1:4939';
const results = {};
const errors = [];
async function runOnce(attempt) {
const browser = await puppeteer.launch({
headless: true,
args: ['--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage',
'--enable-unsafe-swiftshader', '--use-gl=angle', '--use-angle=swiftshader'],
});
const page = await browser.newPage();
await page.setViewport({ width: 800, height: 520 });
page.on('pageerror', (e) => errors.push('[pe] ' + e.message.slice(0, 140)));
try {
await page.goto(URL, { waitUntil: 'domcontentloaded', timeout: 45000 });
await new Promise(r => setTimeout(r, 3500));
results.boot = 'PASS';
// menu screenshot
try { await page.screenshot({ path: `/tmp/v_menu_${attempt}.png` }); results.shotMenu = 'PASS'; } catch { results.shotMenu = 'FAIL'; }
// start game through UI
await page.evaluate(() => document.getElementById('m-new').click());
await new Promise(r => setTimeout(r, 500));
await page.evaluate(() => {
const i = document.querySelector('#wiz-name'); i.value = 'Verify Shop';
document.querySelector('#wiz-start').click();
});
// lighten GPU load for swiftshader
await page.evaluate(() => { window.__TS.G.data.settings.quality = 'low'; });
await new Promise(r => setTimeout(r, 2200));
results.startGame = 'PASS';
// deterministic profitable day
const day = await page.evaluate(async () => {
const T = window.__TS, G = T.G;
const shelves = G.data.furniture.filter(f => f.type === 'shelf');
T.eco.buyFromSupplier('baker', 'bread', 6);
T.eco.buyFromSupplier('farmer', 'apple', 6);
T.eco.stockShelf(shelves[0], 'bread', 6);
T.eco.stockShelf(shelves[0], 'apple', 6);
G.data.candidates.push({ id: 'tc', name: 'Tessa', role: 'cashier', skill: 72, traits: ['friendly'], salary: 40, loyalty: 66 });
T.staff.hireCandidate('tc');
G.shop.rep = 18;
G.shop.minutes = 9 * 60 + 1;
G.shop.isOpen = true; G.shop.phase = 'open';
let steps = 6000;
while (steps-- > 0 && G.shop.isOpen) {
T.day.updateDay(0.005);
T.ai.spawnTimerTick(0.5);
for (const c of [...G.customers]) T.ai.updateCustomer(c, 0.35);
}
return { served: G.data.today.served, revenue: Math.round(G.data.today.revenue), staff: G.data.staff.length };
});
results.simDay = day.served > 0 && day.revenue > 0 ? `PASS (${day.served} served, ${day.revenue}g)` : `FAIL ${JSON.stringify(day)}`;
// close-day → report → next day
const flow = await page.evaluate(() => new Promise(res => {
window.__TS.emit('requestClose');
setTimeout(() => {
document.getElementById('rc-yes')?.click();
setTimeout(() => {
const reportVisible = !document.getElementById('modal-root').classList.contains('hidden');
const stars = document.querySelector('.r-stars')?.textContent.trim();
document.getElementById('next-day')?.click();
setTimeout(() => res({ reportVisible, stars, day2: window.__TS.G.shop.day }), 450);
}, 450);
}, 400);
}));
results.reportFlow = flow.reportVisible && flow.day2 === 2 ? `PASS (${flow.stars})` : `FAIL ${JSON.stringify(flow)}`;
// panels
const panelOk = [];
for (const id of ['shop', 'inventory', 'suppliers', 'staff', 'town', 'quests', 'analytics', 'goals']) {
const ok = await page.evaluate((pid) => new Promise(res => {
document.querySelector(`.nav-btn[data-panel="${pid}"]`).click();
setTimeout(() => {
const body = document.querySelector('.side-panel .sp-body');
res(!!body && body.innerHTML.length > 300);
}, 300);
}), id);
panelOk.push(`${id}:${ok ? '✓' : '✗'}`);
}
results.panels = panelOk.join(' ');
// negotiation
const neg = await page.evaluate(() => new Promise(res => {
document.querySelector('.nav-btn[data-panel="suppliers"]').click();
setTimeout(() => {
document.querySelector('[data-neg="baker"]')?.click();
setTimeout(() => {
const btns = [...document.querySelectorAll('.choice-btn')];
[...btns].find(b => b.textContent.includes('Pay immediately'))?.click();
setTimeout(() => res({ modal: btns.length >= 4 ? 'PASS' : 'FAIL' }), 250);
}, 250);
}, 350);
}));
results.negotiation = neg.modal;
// decorate: place furniture via data path + visuals refresh
const decor = await page.evaluate(() => {
const G = window.__TS.G;
const before = G.data.furniture.length;
G.addGold(-30);
G.data.furniture.push({ id: 'vplant', type: 'plant', cx: 3, cz: 3, rot: 0 });
window.__TS.ai.forceGridRebuild();
window.__TS.refreshFurniture();
return G.data.furniture.length === before + 1 ? 'PASS' : 'FAIL';
});
results.decorate = decor;
// save/load roundtrip
const sl = await page.evaluate(() => new Promise(res => {
window.__TS.saveApi.saveTo('slot1');
const d = window.__TS.saveApi.loadFrom('slot1');
res(d && d.shop.day === 2 && d.meta.name === 'Verify Shop' ? 'PASS' : `FAIL ${d?.shop?.day}/${d?.meta?.name}`);
}));
results.saveLoad = sl;
// achievements
const ach = await page.evaluate(() => {
const a = window.__TS.saveApi.getAchievements();
return Object.keys(a).length >= 1 ? `PASS (${Object.keys(a).length} unlocked)` : `FAIL ${Object.keys(a).length}`;
});
results.achievements = ach;
// gameplay screenshot at busy time
try {
await page.evaluate(() => { window.__TS.G.shop.minutes = 12 * 60; window.__TS.G.shop.isOpen = true; window.__TS.G.timeScale = 3; });
await new Promise(r => setTimeout(r, 2500));
await page.screenshot({ path: `/tmp/v_game_${attempt}.png` });
results.shotGame = 'PASS';
} catch { results.shotGame = 'FAIL'; }
} catch (e) {
results.exception = e.message.slice(0, 120);
} finally {
await browser.close().catch(() => {});
}
}
for (let attempt = 1; attempt <= 3; attempt++) {
console.log(`\n=== attempt ${attempt} ===`);
await runOnce(attempt);
const done = ['simDay', 'reportFlow', 'saveLoad'].every(k => (results[k] || '').startsWith('PASS'))
&& (results.panels || '').includes('✓') && !(results.panels || '').includes('✗');
console.log(JSON.stringify(results, null, 1));
if (done && !results.exception) break;
}
console.log('\nPAGEERRORS:', errors.length ? errors.slice(-6).join(' | ') : 'none');
process.exit(0);
+276
View File
@@ -0,0 +1,276 @@
// ============================================================
// AUDIO — fully procedural cozy fantasy music & SFX (WebAudio)
// ============================================================
class AudioEngine {
constructor() {
this.ctx = null;
this.master = null;
this.musicBus = null;
this.sfxBus = null;
this.rainGain = null;
this.mood = 'menu';
this.nextNoteTime = 0;
this.step = 0;
this.timer = null;
this.birdTimer = null;
this.enabled = true;
this.volumes = { music: 0.6, sfx: 0.8 };
}
init() {
if (this.ctx) return;
const AC = window.AudioContext || window.webkitAudioContext;
if (!AC) return;
this.ctx = new AC();
this.master = this.ctx.createGain();
this.master.gain.value = 0.9;
this.master.connect(this.ctx.destination);
this.musicBus = this.ctx.createGain();
this.musicBus.gain.value = this.volumes.music;
this.musicBus.connect(this.master);
this.sfxBus = this.ctx.createGain();
this.sfxBus.gain.value = this.volumes.sfx;
this.sfxBus.connect(this.master);
// rain noise bed
const buf = this.ctx.createBuffer(1, this.ctx.sampleRate * 2, this.ctx.sampleRate);
const d = buf.getChannelData(0);
for (let i = 0; i < d.length; i++) d[i] = Math.random() * 2 - 1;
const src = this.ctx.createBufferSource();
src.buffer = buf; src.loop = true;
const bp = this.ctx.createBiquadFilter();
bp.type = 'bandpass'; bp.frequency.value = 1400; bp.Q.value = 0.6;
this.rainGain = this.ctx.createGain();
this.rainGain.gain.value = 0;
src.connect(bp).connect(this.rainGain).connect(this.master);
src.start();
this.nextNoteTime = this.ctx.currentTime;
this.timer = setInterval(() => this.scheduler(), 60);
this.birdTimer = setInterval(() => this.maybeBird(), 900);
}
resume() { if (this.ctx?.state === 'suspended') this.ctx.resume(); }
setVolumes(music, sfx) {
this.volumes = { music, sfx };
if (this.musicBus) this.musicBus.gain.value = music;
if (this.sfxBus) this.sfxBus.gain.value = sfx;
}
setRain(v) { if (this.rainGain) this.rainGain.gain.setTargetAtTime(v * 0.14, this.ctx.currentTime, 1.5); }
// ---------------- MUSIC ----------------
setMood(mood) { this.mood = mood; }
scheduler() {
if (!this.ctx || !this.enabled) return;
const tempo = { menu: 74, morning: 78, busy: 88, evening: 68, night: 58, festival: 108, report: 84 }[this.mood] ?? 74;
const stepDur = 60 / tempo / 2; // 8th notes
while (this.nextNoteTime < this.ctx.currentTime + 0.18) {
this.playStep(this.step, this.nextNoteTime, stepDur);
this.step = (this.step + 1) % 64;
this.nextNoteTime += stepDur;
}
}
playStep(step, t, dur) {
const bar = Math.floor(step / 8) % 4;
const inBar = step % 8;
// IVviIV in C
const roots = [130.81, 98.0, 110.0, 87.31]; // C3 G2 A2 F2
const chords = [[261.6, 329.6, 392.0], [246.9, 293.7, 392.0], [220.0, 261.6, 329.6], [233.1, 349.2, 440.0]];
const ch = chords[bar];
// bass at bar start
if (inBar === 0 && this.mood !== 'night') {
this.tone(roots[bar] / 2, t, dur * 6, 'sine', 0.16);
}
// pad chord swells
if (inBar === 0) {
const padGain = this.mood === 'night' ? 0.05 : 0.06;
for (const f of ch) this.pad(f, t, dur * 8, padGain);
}
// pentatonic melody pluck
const penta = [523.25, 587.33, 659.25, 783.99, 880.0, 1046.5];
const density = { menu: .35, morning: .45, busy: .6, evening: .35, night: .22, festival: .75, report: .5 }[this.mood] ?? .4;
if (Math.random() < density && ![3, 7].includes(inBar)) {
const f = penta[Math.floor(Math.random() * penta.length)];
this.pluck(f * (Math.random() < .2 ? 2 : 1), t, 0.5 + Math.random() * 0.3);
if (this.mood === 'festival' && Math.random() < .3) this.pluck(f * 1.5, t + dur / 2, 0.25, 0.05);
}
// shaker on busy/festival
if ((this.mood === 'busy' || this.mood === 'festival') && inBar % 2 === 1) {
this.noiseTick(t, inBar === 3 ? 0.05 : 0.028);
}
}
tone(freq, t, len, type = 'triangle', vol = 0.1) {
const o = this.ctx.createOscillator();
const g = this.ctx.createGain();
o.type = type; o.frequency.value = freq;
g.gain.setValueAtTime(0, t);
g.gain.linearRampToValueAtTime(vol, t + 0.02);
g.gain.exponentialRampToValueAtTime(0.0001, t + len);
o.connect(g).connect(this.musicBus);
o.start(t); o.stop(t + len + 0.05);
}
pad(freq, t, len, vol) {
const o = this.ctx.createOscillator();
const o2 = this.ctx.createOscillator();
o.type = 'triangle'; o2.type = 'triangle';
o.frequency.value = freq; o2.frequency.value = freq * 1.004;
const f = this.ctx.createBiquadFilter();
f.type = 'lowpass'; f.frequency.value = 900;
const g = this.ctx.createGain();
g.gain.setValueAtTime(0, t);
g.gain.linearRampToValueAtTime(vol, t + len * 0.3);
g.gain.linearRampToValueAtTime(0, t + len);
o.connect(f); o2.connect(f); f.connect(g).connect(this.musicBus);
o.start(t); o.stop(t + len); o2.start(t); o2.stop(t + len);
}
pluck(freq, t, len, vol = 0.09) {
const o = this.ctx.createOscillator();
o.type = 'triangle';
o.frequency.setValueAtTime(freq, t);
const g = this.ctx.createGain();
g.gain.setValueAtTime(0, t);
g.gain.linearRampToValueAtTime(vol, t + 0.008);
g.gain.exponentialRampToValueAtTime(0.0001, t + len);
o.connect(g).connect(this.musicBus);
o.start(t); o.stop(t + len + 0.05);
}
noiseTick(t, vol) {
const len = 0.04;
const buf = this.ctx.createBuffer(1, this.ctx.sampleRate * len, this.ctx.sampleRate);
const d = buf.getChannelData(0);
for (let i = 0; i < d.length; i++) d[i] = (Math.random() * 2 - 1) * (1 - i / d.length);
const s = this.ctx.createBufferSource();
s.buffer = buf;
const hp = this.ctx.createBiquadFilter();
hp.type = 'highpass'; hp.frequency.value = 5000;
const g = this.ctx.createGain(); g.gain.value = vol;
s.connect(hp).connect(g).connect(this.musicBus);
s.start(t);
}
maybeBird() {
if (!this.ctx || !this.enabled) return;
const day = ['morning', 'menu', 'busy'].includes(this.mood);
if (!day || Math.random() > 0.3) return;
const t = this.ctx.currentTime + Math.random() * 0.4;
const base = 2200 + Math.random() * 900;
for (let i = 0; i < 2 + Math.floor(Math.random() * 3); i++) {
const o = this.ctx.createOscillator();
const g = this.ctx.createGain();
o.type = 'sine';
const st = t + i * 0.14;
o.frequency.setValueAtTime(base, st);
o.frequency.exponentialRampToValueAtTime(base * (1.3 + Math.random() * 0.4), st + 0.07);
o.frequency.exponentialRampToValueAtTime(base * 0.9, st + 0.12);
g.gain.setValueAtTime(0, st);
g.gain.linearRampToValueAtTime(0.025, st + 0.01);
g.gain.exponentialRampToValueAtTime(0.0001, st + 0.13);
o.connect(g).connect(this.master);
o.start(st); o.stop(st + 0.15);
}
}
// ---------------- SFX ----------------
sfx(fn) {
if (!this.ctx || !this.enabled) return;
fn(this.ctx.currentTime);
}
click() { this.sfx((t) => this.blip(700, t, 0.05, 0.08, 'square')); }
blip(freq, t, len, vol, type = 'sine', bus) {
const o = this.ctx.createOscillator();
const g = this.ctx.createGain();
o.type = type; o.frequency.value = freq;
g.gain.setValueAtTime(vol, t);
g.gain.exponentialRampToValueAtTime(0.0001, t + len);
o.connect(g).connect(bus || this.sfxBus);
o.start(t); o.stop(t + len + 0.02);
}
coin() {
this.sfx((t) => {
this.blip(1180, t, 0.09, 0.12, 'sine');
this.blip(1760, t + 0.07, 0.14, 0.1, 'sine');
});
}
register() {
this.sfx((t) => {
this.blip(880, t, 0.07, 0.1, 'square');
this.blip(1320, t + 0.06, 0.09, 0.09, 'square');
this.blip(1760, t + 0.13, 0.16, 0.1, 'sine');
});
}
doorbell() {
this.sfx((t) => {
this.blip(1318, t, 0.3, 0.09);
this.blip(1760, t + 0.12, 0.4, 0.08);
});
}
pop() { this.sfx((t) => { const o = this.ctx.createOscillator(); const g = this.ctx.createGain(); o.type = 'sine'; o.frequency.setValueAtTime(320, t); o.frequency.exponentialRampToValueAtTime(720, t + 0.06); g.gain.setValueAtTime(0.14, t); g.gain.exponentialRampToValueAtTime(0.001, t + 0.09); o.connect(g).connect(this.sfxBus); o.start(t); o.stop(t + 0.1); }); }
sad() {
this.sfx((t) => {
const seq = [392, 349, 311, 262];
seq.forEach((f, i) => this.blip(f, t + i * 0.14, 0.18, 0.08, 'sawtooth'));
});
}
levelup() {
this.sfx((t) => {
const seq = [523, 659, 784, 1046, 1318];
seq.forEach((f, i) => this.blip(f, t + i * 0.09, 0.22, 0.1, 'triangle'));
this.pluck(1568, t + 0.5, 0.6, 0.08);
});
}
meow() {
this.sfx((t) => {
const o = this.ctx.createOscillator();
const g = this.ctx.createGain();
o.type = 'sawtooth';
o.frequency.setValueAtTime(520, t);
o.frequency.exponentialRampToValueAtTime(760, t + 0.12);
o.frequency.exponentialRampToValueAtTime(420, t + 0.34);
const f = this.ctx.createBiquadFilter();
f.type = 'lowpass'; f.frequency.value = 1200;
g.gain.setValueAtTime(0.0001, t);
g.gain.linearRampToValueAtTime(0.09, t + 0.05);
g.gain.exponentialRampToValueAtTime(0.0001, t + 0.36);
o.connect(f).connect(g).connect(this.sfxBus);
o.start(t); o.stop(t + 0.38);
});
}
whoosh() {
this.sfx((t) => {
const len = 0.3;
const buf = this.ctx.createBuffer(1, this.ctx.sampleRate * len, this.ctx.sampleRate);
const d = buf.getChannelData(0);
for (let i = 0; i < d.length; i++) d[i] = (Math.random() * 2 - 1) * Math.sin((i / d.length) * Math.PI);
const s = this.ctx.createBufferSource(); s.buffer = buf;
const f = this.ctx.createBiquadFilter();
f.type = 'bandpass'; f.frequency.setValueAtTime(400, t);
f.frequency.exponentialRampToValueAtTime(2400, t + len);
const g = this.ctx.createGain(); g.gain.value = 0.12;
s.connect(f).connect(g).connect(this.sfxBus);
s.start(t);
});
}
paper() {
this.sfx((t) => {
const len = 0.18;
const buf = this.ctx.createBuffer(1, this.ctx.sampleRate * len, this.ctx.sampleRate);
const d = buf.getChannelData(0);
for (let i = 0; i < d.length; i++) d[i] = (Math.random() * 2 - 1) * (i / d.length);
const s = this.ctx.createBufferSource(); s.buffer = buf;
const hp = this.ctx.createBiquadFilter(); hp.type = 'highpass'; hp.frequency.value = 2600;
const g = this.ctx.createGain(); g.gain.value = 0.07;
s.connect(hp).connect(g).connect(this.sfxBus);
s.start(t);
});
}
}
export const audio = new AudioEngine();
+157
View File
@@ -0,0 +1,157 @@
// ---------------- CUSTOMER ARCHETYPES ----------------
import { clamp } from '../util.js';
// tolerance: multiplier on fair price they accept (1.0 = fair, 1.3 = pays 30% over)
// patience: seconds-ish of waiting they tolerate
// likes/dislikes bias which products they hunt for
export const ARCHETYPES = [
{
id: 'bargain', label: 'Bargain Hunter', emoji: '🪙', weight: 10,
tolerance: 0.88, patience: [40, 70], budget: [8, 25],
likes: ['food', 'goods'], dislikes: ['luxury'],
desc: 'Hunts for value. Walks out if prices creep too high.',
},
{
id: 'wealthy', label: 'Wealthy Patron', emoji: '💰', weight: 3, repMin: 25,
tolerance: 1.55, patience: [60, 110], budget: [60, 220],
likes: ['luxury'], dislikes: [],
desc: 'Money is no object — but service must impress.',
},
{
id: 'impatient', label: 'In a Hurry', emoji: '⏱️', weight: 7,
tolerance: 1.05, patience: [14, 30], budget: [10, 40],
likes: ['food', 'adventure'], dislikes: [],
desc: 'Zero patience for queues. Serve fast or lose them.',
},
{
id: 'collector', label: 'Collector', emoji: '🔍', weight: 4, repMin: 15,
tolerance: 1.35, patience: [70, 120], budget: [40, 160],
likes: ['magic'], dislikes: [],
rareSeeker: true,
desc: 'Seeks rare curios. Pays handsomely for rarity ≥ 3.',
},
{
id: 'adventurer', label: 'Adventurer', emoji: '⚔️', weight: 9,
tolerance: 1.2, patience: [40, 80], budget: [20, 70],
likes: ['adventure'], dislikes: [],
desc: 'Potions, torches, maps. Danger pays well — for you.',
},
{
id: 'farmer', label: 'Farmer', emoji: '🧑‍🌾', weight: 7,
tolerance: 0.95, patience: [50, 90], budget: [12, 45],
likes: ['food', 'goods'], dislikes: ['luxury', 'magic'],
desc: 'Down-to-earth. Buys practical things in bulk-ish.',
},
{
id: 'noble', label: 'Noble', emoji: '👑', weight: 2, repMin: 45,
tolerance: 1.9, patience: [80, 140], budget: [150, 500],
likes: ['luxury'], dislikes: ['goods'],
desc: 'Only appears in famous shops. Buys the finest thing you own.',
},
{
id: 'child', label: 'Child', emoji: '🧒', weight: 6,
tolerance: 1.0, patience: [30, 60], budget: [3, 12],
likes: ['food'], dislikes: ['luxury'],
impulse: true,
desc: 'Small budget, big impulses. Sweets and shiny things.',
},
{
id: 'tourist', label: 'Tourist', emoji: '🎒', weight: 5, season: ['summer', 'spring'],
tolerance: 1.3, patience: [60, 100], budget: [25, 80],
likes: ['adventure', 'food'], dislikes: [],
desc: 'Loves souvenirs and maps. Festivals bring them in droves.',
},
{
id: 'regular', label: 'Friendly Regular', emoji: '🙂', weight: 8,
tolerance: 1.15, patience: [60, 110], budget: [12, 50],
likes: ['food', 'goods'], dislikes: [],
desc: 'Neighbourly folk. Loyalty grows with every kind visit.',
},
{
id: 'mage', label: 'Mage', emoji: '🧙', weight: 4, repMin: 20,
tolerance: 1.25, patience: [70, 120], budget: [35, 140],
likes: ['magic'], dislikes: ['goods'],
desc: 'Craves crystals, books and dust that glimmers.',
},
{
id: 'blacksmith', label: 'Blacksmith', emoji: '🔨', weight: 5,
tolerance: 1.05, patience: [45, 85], budget: [15, 60],
likes: ['goods'], dislikes: ['luxury'],
desc: 'Tools, rope, buckets. Gruff but fair.',
},
{
id: 'merchant', label: 'Merchant', emoji: '🐫', weight: 4,
tolerance: 0.8, patience: [50, 90], budget: [30, 120],
likes: ['food', 'goods', 'luxury'], dislikes: [],
haggler: true,
desc: 'Buys stock to resell. Ruthless about price.',
},
{
id: 'knight', label: 'Knight', emoji: '🛡️', weight: 4,
tolerance: 1.25, patience: [50, 90], budget: [25, 90],
likes: ['adventure'], dislikes: ['magic'],
knight: true,
desc: 'Brave and honorable. Refuses to touch elven goods.',
},
{
id: 'elder', label: 'Elder', emoji: '🧓', weight: 5,
tolerance: 1.0, patience: [80, 150], budget: [8, 30],
likes: ['food', 'medicine' ], dislikes: ['luxury'],
elder: true,
desc: 'Slow strolls, old habits. Tuesday means medicine.',
},
{
id: 'thief', label: 'Shifty Figure', emoji: '🕵️', weight: 2.5,
tolerance: 0.7, patience: [20, 40], budget: [0, 5],
likes: ['luxury', 'magic'], dislikes: [],
thief: true,
desc: 'Steals when nobody watches the counter. Hire a guard!',
},
{
id: 'bard', label: 'Bard', emoji: '🎻', weight: 3,
tolerance: 1.2, patience: [60, 100], budget: [10, 40],
likes: ['food', 'magic'], dislikes: [],
desc: 'Spreads word of your shop with every song.',
},
{
id: 'chef', label: 'Chef', emoji: '👩‍🍳', weight: 4,
tolerance: 1.1, patience: [40, 80], budget: [20, 60],
likes: ['food'], dislikes: ['magic'],
desc: 'Demands the freshest ingredients for the tavern.',
},
{
id: 'guardcapt', label: 'Captain of the Guard', emoji: '🪖', weight: 2, repMin: 30,
tolerance: 1.3, patience: [50, 90], budget: [40, 120],
likes: ['adventure', 'goods'], dislikes: ['magic'],
desc: 'Equips the town watch. Big orders, fair coin.',
},
{
id: 'witchy', label: 'Herbalist', emoji: '🌿', weight: 3,
tolerance: 1.15, patience: [60, 110], budget: [15, 55],
likes: ['magic', 'medicine'], dislikes: ['luxury'],
desc: 'Knows every leaf. Values quality over glitter.',
},
];
export const ARCH_BY_ID = Object.fromEntries(ARCHETYPES.map(a => [a.id, a]));
// ---------------- name pools ----------------
export const NAMES_F = ['Mira','Poppy','Elara','Rosie','Hilda','Wren','Sable','Juniper','Tilly','Briar','Marigold','Petra','Odette','Clementine','Nessa','Fern'];
export const NAMES_M = ['Alder','Gareth','Thomas','Bram','Ozric','Finn','Godric','Milo','Rufus','Edwin','Barnaby','Cedric','Doran','Harlan','Wystan','Perrin'];
export const SURNAMES = ['Applewhite','Thornbury','Copperkettle','Mossfoot','Bramblebee','Underbough','Hollyhock','Pennywhistle','Coggsworthy','Duskwalker','Merryweather','Oatvale'];
// ---------------- story NPCs (regulars with lives) ----------------
export const STORY_NPCS = [
{ id: 'alder', name: 'Old Alder', emoji: '🧓', arch: 'elder', bio: 'Comes every Tuesday for his medicine. Tells long stories about the war.' },
{ id: 'mira', name: 'Mira Baker', emoji: '👩‍🍳', arch: 'chef', bio: 'Dreams of opening a second bakery across town. Bakes you pies in thanks.' },
{ id: 'gareth', name: 'Ser Gareth', emoji: '🛡️', arch: 'knight', bio: 'Sworn to the realm. Would rather die than buy an elven trinket.' },
{ id: 'pip', name: 'Little Pip', emoji: '🧒', arch: 'child', bio: 'Saves every copper for sweets. Knows all the town gossip.' },
{ id: 'theron', name: 'Theron', emoji: '🐫', arch: 'merchant', bio: 'Caravan master with a silver tongue and fair prices.' },
{ id: 'elara', name: 'Elara', emoji: '🧙‍♀️', arch: 'witchy', bio: 'Apothecary of the old forest. Values honesty over gold.' },
{ id: 'ozric', name: 'Archmage Ozric', emoji: '🧙', arch: 'mage', bio: 'Scattered, brilliant, occasionally turns bread into gold.' },
{ id: 'brun', name: 'Brunhild', emoji: '🔨', arch: 'blacksmith',bio: 'Forges by day, drinks by night. Loyal as steel.' },
{ id: 'rosie', name: 'Innkeep Rosie', emoji: '🍺', arch: 'bard', bio: 'Runs the Sleepy Dragon tavern. Hears every rumor first.' },
{ id: 'capt', name: 'Captain Yara', emoji: '🪖', arch: 'guardcapt', bio: 'Keeps the peace. Keeps an eye on shoplifters, too.' },
];
export const REL_TIERS = ['Stranger','Acquaintance','Friend','Close Friend','Trusted','Best Friend'];
export const relTier = (v) => REL_TIERS[clamp(Math.floor(v / 20), 0, 5)];
+138
View File
@@ -0,0 +1,138 @@
// ---------------- DATA-DRIVEN EVENT SYSTEM ----------------
// An event can:
// - apply demand modifiers: demandTags: { tag: mult }, demandProducts: { pid: mult }
// - one-shot effects: gold, rep, toast text
// - choices: modal with options -> resolve(state) => outcome text + effects
// - lastDays: how long demand modifiers persist
export const EVENTS = [
{
id: 'noble_visit', title: 'The Noble Visits', emoji: '🎩', weight: 6, repMin: 25,
text: 'Lord Featherbottom III sweeps in, monocle gleaming. He seeks a gift “worthy of a duchess”.',
choices: [
{ label: 'Offer your finest luxury item', hint: 'Costs your best luxury stock · big reputation', resolve: (G) => {
const lux = Object.keys(G.data.inventory).filter(p => p.cat === 'luxury');
if (!lux.length) return { text: 'You have no luxuries! The noble sniffs and departs. (-2 reputation)', rep: -2 };
G.takeItems(lux[0], 1);
return { text: 'He adores it! Word spreads through high society. (+8 reputation)', rep: 8, relNpc: 'noble' };
} },
{ label: 'Offer a generous discount', hint: '-30 gold · +4 reputation', resolve: () => ({ text: '“Reasonable!” He buys a small gift and leaves a fine tip. (+4 reputation)', gold: 30, rep: 4 }) },
{ label: 'Try to impress him with flattery', hint: 'Risky…', resolve: () => Math.random() < 0.6
? { text: 'Your flattery works! He laughs and buys half your shelf. (+120 gold)', gold: 120 }
: { text: 'He sees right through you. “Impertinent!” (-1 reputation)', rep: -1 } },
],
},
{
id: 'wizard_gold', title: 'Bread Into Gold!', emoji: '🧙', weight: 5, repMin: 15,
text: 'Archmage Ozric sneezes near your bread shelf. Every loaf turns to pure gold!',
apply: (G) => { G.addDemand('bread', 6, 1); G.data.flags.breadGold = G.data.shop.day; },
toast: 'Bread sells for 25× value today!',
},
{
id: 'cat_sleeps', title: 'Shop Cat', emoji: '🐱', weight: 7,
text: 'A fluffy stray wanders in and falls asleep on your most expensive merchandise. Customers think its adorable.',
choices: [
{ label: 'Let it sleep — its free charm', hint: '+Beauty today · it might stay', resolve: (G) => {
if (Math.random() < 0.5) { G.data.flags.cat = true; return { text: 'The cat stays! Beauty +5 permanently. Name it Muffin.', beauty: 5 }; }
return { text: 'Customers smile. Attraction +2 today.', attract: 2 };
} },
{ label: 'Gently shoo it away', hint: 'Nothing happens… probably', resolve: () => ({ text: 'The cat gives you a long, judgmental stare, then leaves.' }) },
],
},
{
id: 'knight_potion', title: 'Knights Logic', emoji: '🛡️', weight: 5,
text: 'Ser Gareth buys a healing potion and immediately drinks it “to test the quality”.',
apply: (G) => { G.addGold(20); G.addRep(1); },
toast: 'Ser Gareth bought a potion… and drank it at the counter. (+20g)',
},
{
id: 'thief_stuck', title: 'Stuck Thief', emoji: '🚨', weight: 4,
text: 'A shoplifter tries to dash out but gets wedged between two shelves!',
choices: [
{ label: 'Call the town guard', hint: 'The right thing to do', resolve: (G) => ({ text: 'Captain Yara hauls them off. The town applauds your honesty. (+5 reputation)', rep: 5 }) },
{ label: 'Make them return everything & sweep floors', hint: 'Free labor, +1 cleaner day', resolve: (G) => ({ text: 'They sweep the shop, sobbing. Floors sparkle. (Expenses -10g today)', gold: 10 }) },
],
},
{
id: 'dragon_candy', title: 'A Dragon Enters', emoji: '🐲', weight: 2, repMin: 35,
text: 'A young dragon squeezes through your door. “I require… CANDY,” it rumbles.',
choices: [
{ label: 'Offer cake and sweets (lose 5 food items)', hint: 'A wise investment', resolve: (G) => {
G.takeCategory('food', 5);
return { text: 'The dragon purrs like a landslide, leaves a dragon scale, and tells every dragon about your shop. Demand for sweets soars!', item: 'dscale', demandTag: 'festival', days: 2, rep: 6 };
} },
{ label: 'Explain you dont serve dragons', hint: '…bold', resolve: () => ({ text: 'A single tear sizzles on the floorboards. It leaves. Word gets around. (-3 reputation)', rep: -3 }) },
],
},
{
id: 'plague_rumor', title: 'Plague Rumors', emoji: '🤒', weight: 4,
text: 'Whispers of a fever in the next village. Everyone wants medicine — just in case.',
apply: (G) => G.addDemandTag('medicine', 2.6, 2),
toast: 'Medicine demand up for 2 days!',
},
{
id: 'dungeon_found', title: 'Dungeon Discovered!', emoji: '🗺️', weight: 5,
text: 'Adventurers found ruins in the hills. The tavern is roaring with plans and bravado.',
apply: (G) => { G.addDemandTag('adventure', 2.2, 2); },
toast: 'Adventurers are stocking up! Adventure gear demand ×2.2 for 2 days.',
},
{
id: 'harvest_poor', title: 'Poor Harvest', emoji: '🥕', weight: 4,
text: 'Hail bruised the orchards. Food prices climb across the valley.',
apply: (G) => { G.addDemandTag('food', 1.7, 2); },
toast: 'Food demand ×1.7 — but suppliers charge more too.',
},
{
id: 'rain_flood', title: 'Muddy Streets', emoji: '🌧️', weight: 5,
text: 'The square is a puddle farm. Umbrella demand explodes; so does grumpiness.',
apply: (G) => { G.addDemand('umbrella', 4, 1); },
toast: 'Umbrellas are selling like hot cakes!',
},
{
id: 'bard_song', title: 'The Bards Song', emoji: '🎻', weight: 5,
text: 'A bard composes “The Ballad of the Little Shop” right on your counter.',
apply: (G) => { G.addRep(3); },
toast: 'The song spreads your name. (+3 reputation)',
},
{
id: 'competitor_promo', title: 'Cheap Mart Undercuts You!', emoji: '🏷️', weight: 6, repMin: 12, needCompetitor: true,
text: 'Cheap Mart paints a giant “SALE” sign. Theyre undercutting basic goods.',
apply: (G) => { G.addDemandTag('food', 0.6, 2); G.data.flags.competitorPromo = G.data.shop.day; },
toast: 'Basic goods demand ×0.6 for 2 days. Fight back with service!',
},
{
id: 'fancy_emporium', title: 'Fancy Emporium Opens', emoji: '🪞', weight: 4, repMin: 30,
text: 'A gilded boutique opens across the square, all marble and sneering clerks.',
apply: (G) => { G.data.town.competitors.fancy = true; },
toast: 'Rich customers may drift there… unless your shop is more beautiful.',
},
{
id: 'lost_caravan', title: 'Caravan Delayed', emoji: '🐫', weight: 3,
text: 'Therons caravan is stuck in a ford. Shipments arrive late but he sends apologies.',
apply: (G) => { G.data.flags.caravanSlow = G.data.shop.day; },
toast: 'Supplier reliability reduced today.',
},
{
id: 'rat_problem', title: 'A Rat!', emoji: '🐀', weight: 4,
text: 'A bold rat parades across the counter. A customer screams. Drama!',
apply: (G) => { G.addSatisfaction(-0.3); },
toast: 'Customers are a bit unsettled today. (Satisfaction -)',
},
{
id: 'wine_crate', title: 'Mystery Crate', emoji: '📦', weight: 4,
text: 'A crate arrives with no sender. Inside: six bottles of very fine wine.',
apply: (G) => { G.addItems('wine', 6); },
toast: '+6 Fine Wine added to storage!',
},
{
id: 'ghost_customer', title: 'The Midnight Shopper', emoji: '👻', weight: 2, repMin: 20,
text: 'At dawn you find coins on the counter and cold footprints by the potion shelf…',
apply: (G) => { G.addGold(35); G.addRep(2); },
toast: 'Someone (something?) shopped overnight. (+35g, +2 rep)',
},
{
id: 'royal_herald', title: 'Royal Herald', emoji: '📜', weight: 3, repMin: 60,
text: 'A herald reads a proclamation: the castle seeks a supplier “of exceptional repute”.',
apply: (G) => { G.data.flags.royalNotice = true; G.addRep(3); },
toast: 'The Royal Contract quest is now available!',
},
];
+82
View File
@@ -0,0 +1,82 @@
// ---------------- FURNITURE / DECOR CATALOG ----------------
// size in grid cells (w × d), rotatable
export const FURNITURE = [
{ id: 'shelf', label: 'Shelf', emoji: '🗄️', cost: 45, size: [2, 1], kind: 'shelf', stats: { beauty: 1, attraction: 3 }, desc: 'Displays up to 12 items. Your bread and butter.' },
{ id: 'counter', label: 'Counter', emoji: '🛎️', cost: 60, size: [2, 1], kind: 'counter', stats: { attraction: 2 }, desc: 'Where the magic (money) happens. Customers queue here.' },
{ id: 'table', label: 'Display Table',emoji: '🪑', cost: 38, size: [2, 2], kind: 'table', stats: { beauty: 2, attraction: 4 }, desc: 'Shows off 6 premium items in style.' },
{ id: 'crate', label: 'Storage Crate',emoji: '📦', cost: 25, size: [1, 1], kind: 'crate', stats: {}, desc: '+20 storage capacity. Stack your overflow here.' },
{ id: 'rug', label: 'Cozy Rug', emoji: '🟥', cost: 30, size: [3, 2], kind: 'rug', stats: { beauty: 4, comfort: 5, luxury: 1 }, desc: 'Toes love it. Comfort makes browsers patient.' },
{ id: 'plant', label: 'Potted Plant', emoji: '🪴', cost: 18, size: [1, 1], kind: 'plant', stats: { beauty: 4, comfort: 1 }, desc: 'A little green friend. Freshens the air.' },
{ id: 'lamp', label: 'Warm Lamp', emoji: '💡', cost: 28, size: [1, 1], kind: 'lamp', stats: { beauty: 3, comfort: 3 }, desc: 'Golden light. Cozy sells.' },
{ id: 'pedestal', label: 'Glass Pedestal',emoji:'🏛️', cost: 85, size: [1, 1], kind: 'pedestal', stats: { beauty: 3, luxury: 6, attraction: 4 }, desc: 'Spotlights one rare treasure. Nobles adore it.' },
{ id: 'painting', label: 'Painting', emoji: '🖼️', cost: 42, size: [1, 1], kind: 'painting', stats: { beauty: 5, luxury: 3 }, desc: 'Hangs on the wall. Very tasteful. Probably expensive.' },
{ id: 'banner', label: 'Shop Banner', emoji: '🚩', cost: 22, size: [1, 1], kind: 'banner', stats: { attraction: 5 }, desc: 'Bright and festive. Pulls eyes — and feet — inside.' },
{ id: 'fountain', label: 'Mini Fountain',emoji: '⛲', cost: 120, size: [2, 2], kind: 'fountain', stats: { beauty: 8, comfort: 4, luxury: 5, attraction: 6 }, desc: 'Trickling water. Extremely fancy. Slightly impractical.' },
{ id: 'catbed', label: 'Cat Bed', emoji: '🧺', cost: 20, size: [1, 1], kind: 'catbed', stats: { comfort: 4, beauty: 2 }, desc: 'For Muffin. A sleeping cat is free marketing.' },
];
export const FURN_BY_ID = Object.fromEntries(FURNITURE.map(f => [f.id, f]));
// ---------------- SHOP LEVELS ----------------
export const SHOP_LEVELS = [
{ level: 1, name: 'Tiny Wooden Store', grid: [10, 7], cost: 0, repNeed: 0, storage: 40, staffCap: 1 },
{ level: 2, name: 'Small Shop', grid: [12, 8], cost: 350, repNeed: 10, storage: 70, staffCap: 2 },
{ level: 3, name: 'Large Store', grid: [14, 9], cost: 900, repNeed: 22, storage: 110, staffCap: 3 },
{ level: 4, name: 'Two-Floor Shop', grid: [15, 10], cost: 2000, repNeed: 34, storage: 160, staffCap: 4 },
{ level: 5, name: 'Luxury Merchant House', grid: [17, 11], cost: 4200, repNeed: 46, storage: 220, staffCap: 5 },
{ level: 6, name: 'Major Town Store', grid: [19, 12], cost: 8000, repNeed: 56, storage: 300, staffCap: 6 },
{ level: 7, name: 'Famous Regional Business', grid: [21, 13], cost: 15000, repNeed: 68, storage: 400, staffCap: 7 },
{ level: 8, name: 'Kingdom Trading Company', grid: [23, 14], cost: 30000, repNeed: 80, storage: 550, staffCap: 8 },
];
export const REP_TIERS = [
{ min: 0, name: 'Unknown' },
{ min: 12, name: 'Local' },
{ min: 26, name: 'Popular' },
{ min: 44, name: 'Famous' },
{ min: 62, name: 'Elite' },
{ min: 82, name: 'Legendary' },
];
export const SEASONS = [
{ id: 'spring', name: 'Spring', emoji: '🌸', festival: 'Spring Festival', tags: ['spring'] },
{ id: 'summer', name: 'Summer', emoji: '☀️', festival: 'Summer Fair', tags: ['summer'] },
{ id: 'autumn', name: 'Autumn', emoji: '🍂', festival: 'Harvest Festival',tags: ['harvest'] },
{ id: 'winter', name: 'Winter', emoji: '❄️', festival: 'Winter Festival', tags: ['winter'] },
];
export const WEATHERS = {
sunny: { label: 'Sunny', emoji: '☀️', footfall: 1.1, tags: [] },
cloudy: { label: 'Cloudy', emoji: '⛅', footfall: 1.0, tags: [] },
rain: { label: 'Rainy', emoji: '🌧️', footfall: 0.72, tags: ['rain'] },
storm: { label: 'Storm', emoji: '⛈️', footfall: 0.5, tags: ['rain','storm'] },
snow: { label: 'Snowy', emoji: '🌨️', footfall: 0.65, tags: ['winter','cold','snow'] },
};
export const DIFFICULTIES = {
cozy: { label: 'Cozy', gold: 150, rentMult: 0.7, taxRate: 0.03, patienceMult: 1.25, competitorDay: 999, blurb: 'Very forgiving' },
normal: { label: 'Normal', gold: 100, rentMult: 1.0, taxRate: 0.05, patienceMult: 1.0, competitorDay: 12, blurb: 'Balanced' },
business:{ label: 'Business',gold: 80, rentMult: 1.3, taxRate: 0.08, patienceMult: 0.9, competitorDay: 8, blurb: 'Tougher economy' },
tycoon: { label: 'Tycoon', gold: 60, rentMult: 1.6, taxRate: 0.11, patienceMult: 0.8, competitorDay: 6, blurb: 'Hard mode' },
};
export const STAFF_ROLES = {
cashier: { label: 'Cashier', emoji: '🧾', salaryMult: 1.0, desc: 'Speeds up checkout and keeps the counter attended.' },
shopkeeper: { label: 'Shopkeeper', emoji: '🤝', salaryMult: 1.05, desc: 'Helps customers find things — happier visits.' },
stocker: { label: 'Stocker', emoji: '📦', salaryMult: 0.9, desc: 'Automatically moves goods from storage to shelves.' },
merchant: { label: 'Merchant', emoji: '🤝', salaryMult: 1.1, desc: 'Negotiates supplier discounts passively.' },
guard: { label: 'Guard', emoji: '🛡️', salaryMult: 1.0, desc: 'Deters thieves. Shifty folk think twice.' },
cleaner: { label: 'Cleaner', emoji: '🧹', salaryMult: 0.8, desc: 'Keeps the shop sparkling — protects beauty score.' },
manager: { label: 'Manager', emoji: '📋', salaryMult: 1.35, desc: 'Auto-opens shop and suggests smart prices.' },
};
export const STAFF_TRAITS = {
friendly: { label: 'Friendly', emoji: '😊', effect: '+customer happiness', satBonus: 0.06 },
hardworking: { label: 'Hardworking', emoji: '💪', effect: '+works faster', speed: 1.2 },
lazy: { label: 'Lazy', emoji: '😴', effect: '-works slower', speed: 0.75 },
funny: { label: 'Funny', emoji: '😄', effect: '+happiness, slightly slower', satBonus: 0.09, speed: 0.92 },
chatty: { label: 'Chatty', emoji: '💬', effect: '+happiness, slower checkout', satBonus: 0.07, speed: 0.88 },
honest: { label: 'Honest', emoji: '🕊️', effect: 'never steals', honest: true },
sticky: { label: 'Sticky Fingers', emoji: '🖐️', effect: 'might pocket gold!', sticky: true },
grumpy: { label: 'Grumpy', emoji: '😤', effect: 'argues with other grumps', grumpy: true },
quick: { label: 'Quick', emoji: '⚡', effect: 'restocks fast', restock: 1.4 },
};
+76
View File
@@ -0,0 +1,76 @@
// ---------------- PRODUCT CATALOG ----------------
// buy: wholesale reference · sell: fair retail value (quality 1.0)
// rarity: 1 common … 5 legendary · demand: base pull 0..1
// tags drive season/weather/event matching & special customer rules
export const CATEGORIES = {
food: { label: 'Food', emoji: '🥐' },
goods: { label: 'Basic Goods', emoji: '🪢' },
adventure:{ label: 'Adventure Gear', emoji: '🗡️' },
magic: { label: 'Magic', emoji: '🔮' },
luxury: { label: 'Luxury', emoji: '💎' },
};
const P = (id, name, emoji, cat, buy, sell, rarity, demand, tags = [], display = {}) =>
({ id, name, emoji, cat, buy, sell, rarity, demand, tags, display });
export const PRODUCTS = [
// ---- Food ----
P('bread', 'Fresh Bread', '🍞', 'food', 3, 6, 1, .80, ['fresh'], { kind: 'bread', color: 0xd9a05b }),
P('apple', 'Crisp Apples', '🍎', 'food', 2, 4, 1, .72, ['fresh','spring'], { kind: 'apple', color: 0xe0483e }),
P('cheese', 'Cheese Wheel', '🧀', 'food', 5, 10, 1, .55, [], { kind: 'cheese', color: 0xf0c34e }),
P('meat', 'Smoked Ham', '🍖', 'food', 8, 15, 1, .50, ['fresh'], { kind: 'meat', color: 0xc96f52 }),
P('honey', 'Honey Jar', '🍯', 'food', 7, 13, 1, .48, ['spring'], { kind: 'jar', color: 0xe89b2f }),
P('jam', 'Berry Jam', '🫙', 'food', 6, 11, 1, .42, ['summer'], { kind: 'jar', color: 0xb03a5b }),
P('cake', 'Festival Cake', '🍰', 'food', 9, 16, 2, .30, ['festival'], { kind: 'cake', color: 0xf7d6e0 }),
P('pie', 'Harvest Pie', '🥧', 'food', 8, 14, 1, .32, ['harvest'], { kind: 'pie', color: 0xd8a25a }),
// ---- Basic goods ----
P('rope', 'Coil of Rope', '🪢', 'goods', 4, 8, 1, .50, [], { kind: 'rope', color: 0xc09554 }),
P('lantern', 'Oil Lantern', '🏮', 'goods', 10, 18, 1, .45, ['dark'], { kind: 'lantern', color: 0xffc95e }),
P('candle', 'Wax Candle', '🕯️', 'goods', 3, 6, 1, .60, [], { kind: 'candle', color: 0xf5ead6 }),
P('cloth', 'Bolt of Cloth', '🧵', 'goods', 6, 12, 1, .50, [], { kind: 'bolt', color: 0x7fb2d9 }),
P('tools', 'Hand Tools', '🔨', 'goods', 12, 22, 1, .50, [], { kind: 'tools', color: 0x9aa2ad }),
P('bucket', 'Wooden Bucket', '🪣', 'goods', 5, 9, 1, .50, [], { kind: 'bucket', color: 0xa9713d }),
P('umbrella','Fancy Umbrella', '☂️', 'goods', 8, 15, 1, .15, ['rain'], { kind: 'umbrella', color: 0xe05f8f }),
P('scarf', 'Warm Scarf', '🧣', 'goods', 7, 14, 1, .12, ['winter','cold'], { kind: 'scarf', color: 0xd95f43 }),
P('flowers', 'Bouquet of Flowers','💐','goods', 4, 9, 1, .18, ['spring','festival'],{ kind: 'flowers', color: 0xef8fb5 }),
// ---- Adventure ----
P('potion', 'Healing Potion', '🧪', 'adventure', 12, 20, 2, .65, ['medicine'], { kind: 'potion', color: 0xff4d6d }),
P('bandage', 'Bandages', '🩹', 'adventure', 4, 8, 1, .50, ['medicine'], { kind: 'box', color: 0xf2ece2 }),
P('torch', 'Adventurer Torch', '🔥', 'adventure', 5, 9, 1, .55, ['dark'], { kind: 'torch', color: 0xff8c42 }),
P('map', 'Dungeon Map', '🗺️', 'adventure', 15, 28, 2, .28, [], { kind: 'scroll', color: 0xe7d8ab }),
P('compass', 'Brass Compass', '🧭', 'adventure', 18, 32, 2, .24, [], { kind: 'compass', color: 0xc9a227 }),
P('charm', 'Lucky Charm', '🧿', 'adventure', 9, 17, 2, .30, [], { kind: 'charm', color: 0x4ecdc4 }),
// ---- Magic ----
P('crystal', 'Mana Crystal', '💎', 'magic', 30, 55, 3, .20, [], { kind: 'crystal', color: 0x7fd8ff, glow: true }),
P('spellbook','Spell Book', '📖', 'magic', 40, 75, 4, .12, ['elven'], { kind: 'book', color: 0x6d4fc2, glow: false }),
P('ecandle', 'Enchanted Candle', '🪔', 'magic', 25, 48, 3, .18, ['elven','festival'],{ kind: 'candle', color: 0xb388ff, glow: true }),
P('dscale', 'Dragon Scale', '🐉', 'magic', 60, 110,5, .07, [], { kind: 'scale', color: 0xd64541, glow: true }),
P('fdust', 'Fairy Dust', '🧚', 'magic', 35, 65, 4, .14, ['elven'], { kind: 'jar', color: 0x9bf6ff, glow: true }),
// ---- Luxury ----
P('jewelry', 'Jeweled Ring', '💍', 'luxury', 50, 95, 4, .18, ['luxury'], { kind: 'ring', color: 0xffd166, glow: true }),
P('perfume', 'Rose Perfume', '🧴', 'luxury', 28, 52, 3, .20, ['luxury'], { kind: 'potion', color: 0xf7a1c4 }),
P('wine', 'Fine Wine', '🍷', 'luxury', 20, 38, 2, .30, ['luxury','festival'],{ kind: 'bottle', color: 0x7a1f3d }),
P('silk', 'Roll of Silk', '🎀', 'luxury', 26, 48, 3, .22, ['luxury'], { kind: 'bolt', color: 0xe6a4c4 }),
P('goblet', 'Golden Goblet', '🏆', 'luxury', 45, 85, 4, .12, ['luxury'], { kind: 'goblet', color: 0xffd166, glow: true }),
];
export const PRODUCT_BY_ID = Object.fromEntries(PRODUCTS.map(p => [p.id, p]));
export function productValue(pid, quality = 1) {
return PRODUCT_BY_ID[pid].sell * quality;
}
// which products exist per shop theme — used to bias customer wants
export const THEME_TAGS = {
general: null,
apothecary:'medicine',
adventurer:['adventure'],
bakery: ['fresh'],
magic: ['magic'],
boutique: ['luxury'],
toyshop: null,
};
+82
View File
@@ -0,0 +1,82 @@
// ---------------- QUEST CHAIN ----------------
// Sequential story-ish objectives that teach + guide progression.
export const QUESTS = [
{ id: 'q_first_customer', title: 'Open the Doors', emoji: '🔔',
desc: 'Stock a shelf and serve your very first customer.',
hint: 'Buy goods from Suppliers, place them on shelves (Stock tab), then OPEN SHOP.',
check: (G) => G.data.stats.totalCustomers >= 1,
reward: { gold: 20 } },
{ id: 'q_profit_100', title: 'Pocket Money', emoji: '💰',
desc: 'Earn 150 gold of total profit.',
check: (G) => G.data.stats.totalProfit >= 150,
reward: { gold: 30 } },
{ id: 'q_decorate', title: 'Make It Homey', emoji: '🪴',
desc: 'Place 2 decorations in your shop.',
hint: 'Shop tab → Decorate.',
check: (G) => G.decorCount() >= 4,
reward: { rep: 3 } },
{ id: 'q_rep_local', title: 'Talk of the Street', emoji: '⭐',
desc: 'Reach Local reputation (12).',
check: (G) => G.data.shop.rep >= 12,
reward: { gold: 40 } },
{ id: 'q_hire', title: 'Youre the Boss Now', emoji: '🧑‍🍳',
desc: 'Hire your first employee.',
hint: 'Staff tab → candidates refresh daily.',
check: (G) => G.data.staff.length >= 1,
reward: { gold: 25 } },
{ id: 'q_expand1', title: 'Knock Down a Wall', emoji: '🏗️',
desc: 'Expand to a Small Shop (level 2).',
check: (G) => G.data.shop.level >= 2,
reward: { rep: 4 } },
{ id: 'q_supplier_friend', title: 'A Friend in Trade', emoji: '🤝',
desc: 'Raise any supplier relationship to 40.',
hint: 'Negotiate, buy often, send gifts.',
check: (G) => Object.values(G.data.suppliers).some(s => s.rel >= 40),
reward: { gold: 60 } },
{ id: 'q_rain_day', title: 'Singing in the Rain', emoji: '🌧️',
desc: 'Serve 10 customers on a rainy day.',
check: (G) => G.data.stats.rainDayCustomers >= 10,
reward: { gold: 50 } },
{ id: 'q_100_customers', title: 'Regular Crowd', emoji: '🚶',
desc: 'Serve 100 customers in total.',
check: (G) => G.data.stats.totalCustomers >= 100,
reward: { rep: 5, gold: 80 } },
{ id: 'q_famous', title: 'The Famous Little Shop', emoji: '🌟',
desc: 'Reach Famous reputation (44).',
check: (G) => G.data.shop.rep >= 44,
reward: { gold: 200 } },
{ id: 'q_expand4', title: 'Second Floor!', emoji: '🏢',
desc: 'Expand to a Two-Floor Shop (level 4).',
check: (G) => G.data.shop.level >= 4,
reward: { gold: 300 } },
{ id: 'q_staff_five', title: 'The Dream Team', emoji: '👥',
desc: 'Employ 4 staff at once with loyalty above 60.',
check: (G) => G.data.staff.filter(s => s.loyalty > 60).length >= 4,
reward: { rep: 6 } },
{ id: 'q_elite', title: 'Elite Establishment', emoji: '👑',
desc: 'Reach Elite reputation (62).',
check: (G) => G.data.shop.rep >= 62,
reward: { gold: 600 } },
{ id: 'q_royal', title: 'The Royal Contract', emoji: '🏰',
desc: 'Deliver 25 luxury goods to the castle.',
hint: 'Complete after the Royal Herald event (rep 60+).',
check: (G) => G.data.stats.royalDeliveries >= 25,
reward: { gold: 2500, rep: 10 } },
{ id: 'q_legend', title: 'A Shop People Remember', emoji: '✨',
desc: 'Reach Legendary reputation (82) — the kingdom knows your name.',
check: (G) => G.data.shop.rep >= 82,
reward: { gold: 5000 } },
];
export const ACHIEVEMENTS = [
{ id: 'first_customer', emoji: '🔔', name: 'First Customer', desc: 'Serve your first customer' },
{ id: 'first_profit', emoji: '💰', name: 'First Profit', desc: 'End a day in the black' },
{ id: 'hundred_customers', emoji: '🚶', name: '100 Customers', desc: 'Serve 100 customers total' },
{ id: 'thousand_gold', emoji: '🪙', name: '1,000 Gold', desc: 'Hold 1,000 gold at once' },
{ id: 'famous_shop', emoji: '🌟', name: 'Famous Shop', desc: 'Reach Famous reputation' },
{ id: 'millionaire', emoji: '💎', name: 'Millionaire', desc: 'Earn 1,000,000 gold lifetime' },
{ id: 'perfect_day', emoji: '🌈', name: 'Perfect Day', desc: '5★ day with 15+ customers' },
{ id: 'best_employee', emoji: '🏅', name: 'Best Employee', desc: 'An employee reaches 90 loyalty' },
{ id: 'town_hero', emoji: '🦸', name: 'Town Hero', desc: 'Complete 8 quests' },
{ id: 'legendary_shop', emoji: '✨', name: 'Kingdoms Favorite', desc: 'Reach Legendary reputation' },
];
+59
View File
@@ -0,0 +1,59 @@
import { PRODUCTS } from './products.js';
// ---------------- SUPPLIERS ----------------
export const SUPPLIERS = [
{
id: 'farmer', name: 'Old Hodges Farm', emoji: '🧑‍🌾',
desc: 'Honest produce, honest prices. Smells faintly of turnips.',
priceMult: 0.95, quality: [0.85, 1.05], reliability: 0.95,
catalog: ['apple', 'meat', 'honey', 'flowers', 'rope', 'bucket'],
unlockRep: 0,
},
{
id: 'baker', name: 'Dawn Bakery', emoji: '👩‍🍳',
desc: 'Warm bread at dawn. Miras aunt runs this oven.',
priceMult: 1.0, quality: [0.95, 1.15], reliability: 0.97,
catalog: ['bread', 'cake', 'pie', 'jam', 'candle'],
unlockRep: 0,
},
{
id: 'smith', name: 'Brunhilds Forge', emoji: '🔨',
desc: 'Sturdy goods that last a lifetime. Or several.',
priceMult: 1.05, quality: [1.0, 1.2], reliability: 0.93,
catalog: ['tools', 'lantern', 'bucket', 'rope', 'torch', 'compass'],
unlockRep: 0,
},
{
id: 'trader', name: 'Therons Caravan', emoji: '🐫',
desc: 'Exotic wares from beyond the hills. Prices wander like the desert.',
priceMult: 1.12, quality: [0.9, 1.25], reliability: 0.82,
catalog: ['cloth', 'silk', 'wine', 'spiceless', 'map', 'perfume', 'jewelry', 'charm', 'umbrella', 'scarf'],
unlockRep: 10,
},
{
id: 'alchemist', name: 'Elaras Apothecary', emoji: '🧙‍♀️',
desc: 'Potions brewed under the old forest moon. Elven-made.',
priceMult: 1.08, quality: [1.0, 1.2], reliability: 0.9,
catalog: ['potion', 'bandage', 'ecandle', 'fdust'],
elven: true, unlockRep: 15,
},
{
id: 'wizard', name: 'Tower of Ozric', emoji: '🧙',
desc: 'Genuine arcane surplus. Do not shake the boxes.',
priceMult: 1.18, quality: [1.05, 1.3], reliability: 0.75,
catalog: ['crystal', 'spellbook', 'dscale', 'ecandle'],
unlockRep: 35,
},
];
SUPPLIERS.forEach(s => { s.catalog = s.catalog.filter(id => PRODUCTS.some(p => p.id === id)); });
export const SUPPLIER_BY_ID = Object.fromEntries(SUPPLIERS.map(s => [s.id, s]));
// negotiation options — data driven
export const NEGOTIATIONS = [
{ id: 'discount', label: '“Any chance of a discount?”', hint: 'Small order discount if they like you', baseChance: (rel) => rel > 25 ? 0.55 + rel / 300 : 0.2 },
{ id: 'bulk', label: 'Buy in bulk (×10)', hint: '10 units at 8% off', baseChance: () => 1 },
{ id: 'upfront', label: 'Pay immediately', hint: '+6 relationship, no risk', baseChance: () => 1 },
{ id: 'contract', label: 'Long-term contract (3 days)', hint: 'Locked prices & guaranteed quality', baseChance: (rel) => rel > 45 ? 0.75 : 0.3 },
{ id: 'gift', label: 'Send a gift basket (15g)', hint: '+12 relationship', baseChance: () => 1 },
];
+420
View File
@@ -0,0 +1,420 @@
import * as THREE from 'three';
import { mat, sph } from './engine.js';
import G from '../sim/state.js';
import { cellToWorld, getFurnGroup } from './furniture3d.js';
// ============================================================
// CHARACTERS — chibi townsfolk with expressive faces & bubbles
// ============================================================
export const SKIN_TONES = [0xffe0bd, 0xf3c69a, 0xd7a26c, 0xa9744d, 0x7c4f31];
export const HAIR_COLORS = [0x5b3a1e, 0x2e2318, 0xc98833, 0xb5453a, 0xe8dcc8, 0x6d4fc2, 0x4ecdc4];
export const OUTFIT_COLORS = [0xef6461, 0x5aa9e6, 0x58c98b, 0xffd166, 0xa778f0, 0xf29e74, 0x67b8c4];
const ARCH_LOOK = {
knight: { hat: 'helm', outfit: 6 },
mage: { hat: 'wizard', outfit: 4 },
witchy: { hat: 'wizard', outfit: 2 },
noble: { hat: 'tophat', outfit: 3 },
thief: { hat: 'hood', outfit: null, dark: true },
elder: { beard: true },
child: { scale: 0.72 },
bard: { hat: 'feather', outfit: 1 },
guardcapt:{ hat: 'helm', outfit: 6 },
chef: { hat: 'chef' },
merchant: { hat: 'turban' },
};
export const charRigs = new Map(); // customerId -> rig
let playerRig = null;
// ---------------- build a chibi ----------------
export function buildCharacter(opts = {}) {
const skin = opts.skin ?? SKIN_TONES[Math.floor(Math.random() * SKIN_TONES.length)];
const hairC = opts.hairColor ?? HAIR_COLORS[Math.floor(Math.random() * HAIR_COLORS.length)];
const outfitC = opts.outfit ?? OUTFIT_COLORS[Math.floor(Math.random() * OUTFIT_COLORS.length)];
const look = opts.arch ? (ARCH_LOOK[opts.arch] || {}) : {};
const scale = look.scale ?? opts.scale ?? 1;
const g = new THREE.Group();
const parts = {};
const skinM = mat(skin, { rough: .75 });
// ---- legs/feet ----
const footM = mat(0x5b4636, { rough: .9 });
for (const sgn of [-1, 1]) {
const foot = new THREE.Mesh(sph(0.075, 8), footM);
foot.position.set(sgn * 0.09, 0.06, 0.02);
foot.scale.set(1, 0.7, 1.4);
g.add(foot);
}
// ---- body ----
const bodyG = new THREE.Group();
bodyG.position.y = 0;
g.add(bodyG);
parts.body = bodyG;
const torso = new THREE.Mesh(new THREE.CapsuleGeometry(0.17, 0.22, 6, 12), mat(outfitC, { rough: .85 }));
torso.position.y = 0.38;
bodyG.add(torso);
// apron / tunic hem
const hem = new THREE.Mesh(new THREE.ConeGeometry(0.21, 0.18, 10), mat(outfitC, { rough: .9 }));
hem.position.y = 0.16;
hem.rotation.x = Math.PI;
bodyG.add(hem);
if (look.dark) {
const cloak = new THREE.Mesh(new THREE.ConeGeometry(0.24, 0.42, 10), mat(0x37304a, { rough: .95 }));
cloak.position.y = 0.34;
bodyG.add(cloak);
}
// ---- arms ----
parts.arms = [];
for (const sgn of [-1, 1]) {
const shoulder = new THREE.Group();
shoulder.position.set(sgn * 0.185, 0.52, 0);
const arm = new THREE.Mesh(new THREE.CapsuleGeometry(0.05, 0.16, 4, 8), skinM);
arm.position.y = -0.13;
shoulder.add(arm);
bodyG.add(shoulder);
parts.arms.push(shoulder);
}
// ---- head (big & cute) ----
const headG = new THREE.Group();
headG.position.y = 0.82;
bodyG.add(headG);
parts.head = headG;
const head = new THREE.Mesh(sph(0.235, 16), skinM);
head.scale.set(1, 0.95, 0.95);
headG.add(head);
// eyes
const eyeWhiteM = new THREE.MeshStandardMaterial({ color: 0xffffff, roughness: .35 });
const pupilM = new THREE.MeshStandardMaterial({ color: 0x35271c, roughness: .4 });
parts.eyes = [];
for (const sgn of [-1, 1]) {
const ew = new THREE.Mesh(sph(0.055, 10), eyeWhiteM);
ew.position.set(sgn * 0.09, 0.02, 0.195);
headG.add(ew);
const pu = new THREE.Mesh(sph(0.028, 8), pupilM);
pu.position.set(sgn * 0.09, 0.02, 0.235);
headG.add(pu);
parts.eyes.push(pu);
// blush
const blush = new THREE.Mesh(sph(0.032, 8), new THREE.MeshStandardMaterial({ color: 0xf3a68c, transparent: true, opacity: .55, roughness: 1 }));
blush.scale.set(1.4, 0.8, 0.4);
blush.position.set(sgn * 0.15, -0.06, 0.17);
headG.add(blush);
}
// brows (for grumpy/expression)
parts.brows = [];
const browM = new THREE.MeshStandardMaterial({ color: hairC, roughness: .9 });
for (const sgn of [-1, 1]) {
const brow = new THREE.Mesh(new THREE.BoxGeometry(0.07, 0.016, 0.02), browM);
brow.position.set(sgn * 0.09, 0.105, 0.215);
headG.add(brow);
parts.brows.push(brow);
}
// mouth
const mouth = new THREE.Mesh(new THREE.TorusGeometry(0.032, 0.009, 5, 10, Math.PI), pupilM);
mouth.rotation.z = Math.PI;
mouth.position.set(0, -0.08, 0.21);
headG.add(mouth);
parts.mouth = mouth;
// ---- hair ----
const hairM = mat(hairC, { rough: .95 });
const hairStyle = opts.hairStyle ?? Math.floor(Math.random() * 4);
if (!look.hat || look.hat === 'chef') {
const cap = new THREE.Mesh(sph(0.245, 14), hairM);
cap.scale.set(1, 0.82, 1);
cap.position.y = 0.06;
headG.add(cap);
if (hairStyle === 2) {
const bun = new THREE.Mesh(sph(0.11, 10), hairM);
bun.position.set(0, 0.2, -0.18);
headG.add(bun);
} else if (hairStyle === 3) {
for (const sgn of [-1, 1]) {
const lock = new THREE.Mesh(new THREE.CapsuleGeometry(0.05, 0.16, 4, 8), hairM);
lock.position.set(sgn * 0.2, -0.06, -0.05);
headG.add(lock);
}
}
// fringe
const fringe = new THREE.Mesh(sph(0.2, 10), hairM);
fringe.scale.set(1.05, 0.4, 0.55);
fringe.position.set(0, 0.16, 0.09);
headG.add(fringe);
}
if (look.beard) {
const beard = new THREE.Mesh(coneGeo(0.16, 0.3), mat(0xe8e2d5, { rough: .95 }));
beard.rotation.x = Math.PI;
beard.position.set(0, -0.22, 0.08);
headG.add(beard);
}
// hats
switch (look.hat) {
case 'wizard': {
const hatM = mat(0x6d4fc2, { rough: .85 });
const brim = new THREE.Mesh(cylGeo(0.3, 0.3, 0.04, 12), hatM);
brim.position.y = 0.22;
headG.add(brim);
const tip = new THREE.Mesh(coneGeo(0.2, 0.42), hatM);
tip.position.y = 0.44;
headG.add(tip);
const star = new THREE.Mesh(sph(0.035, 6), mat(0xffd166, { emissive: 0xffd166, emissiveIntensity: .8 }));
star.position.set(0.06, 0.36, 0.14);
headG.add(star);
break;
}
case 'helm': {
const helm = new THREE.Mesh(sph(0.25, 12), mat(0xb8bec8, { metal: .7, rough: .35 }));
helm.scale.y = 0.85;
helm.position.y = 0.07;
headG.add(helm);
const plume = new THREE.Mesh(coneGeo(0.05, 0.18), mat(0xef6461));
plume.position.y = 0.32;
headG.add(plume);
break;
}
case 'tophat': {
const hm = mat(0x35304a, { rough: .8 });
const brim = new THREE.Mesh(cylGeo(0.3, 0.3, 0.03, 12), hm);
brim.position.y = 0.2;
headG.add(brim);
const cyl2 = new THREE.Mesh(cylGeo(0.18, 0.2, 0.3, 12), hm);
cyl2.position.y = 0.37;
headG.add(cyl2);
break;
}
case 'feather': {
const cap2 = new THREE.Mesh(sph(0.25, 12), mat(0x5aa9e6, { rough: .85 }));
cap2.scale.y = 0.7;
cap2.position.y = 0.1;
headG.add(cap2);
const feather = new THREE.Mesh(coneGeo(0.035, 0.25), mat(0xef8fb5));
feather.rotation.z = 0.7;
feather.position.set(0.2, 0.3, 0);
headG.add(feather);
break;
}
case 'chef': {
const toque = new THREE.Mesh(cylGeo(0.17, 0.2, 0.24, 10), new THREE.MeshStandardMaterial({ color: 0xffffff, roughness: .95 }));
toque.position.y = 0.34;
headG.add(toque);
const puff = new THREE.Mesh(sph(0.2, 10), new THREE.MeshStandardMaterial({ color: 0xffffff, roughness: .95 }));
puff.scale.y = 0.6;
puff.position.y = 0.48;
headG.add(puff);
break;
}
case 'turban': {
const tb = new THREE.Mesh(sph(0.235, 12), mat(0xd9c9a8, { rough: .95 }));
tb.scale.y = 0.72;
tb.position.y = 0.1;
headG.add(tb);
const gem = new THREE.Mesh(sph(0.03, 6), mat(0xef6461));
gem.position.set(0, 0.24, 0.16);
headG.add(gem);
break;
}
case 'hood': {
const hd = new THREE.Mesh(sph(0.27, 12), mat(0x37304a, { rough: .95 }));
hd.scale.set(1.05, 0.95, 1.05);
hd.position.y = 0.05;
headG.add(hd);
break;
}
}
g.traverse(o => { if (o.isMesh) o.castShadow = true; });
g.scale.setScalar(scale);
return { group: g, parts, phase: Math.random() * 10, emoteSprite: null, bubbleSprite: null, curEmote: '', curBubble: '' };
}
function coneGeo(r, h) { return new THREE.ConeGeometry(r, h, 10); }
function cylGeo(rt, rb, h, seg) { return new THREE.CylinderGeometry(rt, rb, h, seg); }
// ---------------- emote & speech sprites ----------------
function makeEmojiTexture(emoji) {
const cv = document.createElement('canvas');
cv.width = cv.height = 128;
const ctx = cv.getContext('2d');
ctx.font = '86px serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(emoji, 64, 70);
const tex = new THREE.CanvasTexture(cv);
tex.colorSpace = THREE.SRGBColorSpace;
return tex;
}
function makeBubbleTexture(text) {
const cv = document.createElement('canvas');
const ctx0 = cv.getContext('2d');
ctx0.font = '700 30px Nunito, sans-serif';
const w = Math.min(360, ctx0.measureText(text).width + 60);
cv.width = w; cv.height = 84;
const ctx = cv.getContext('2d');
ctx.fillStyle = 'rgba(255,252,244,0.97)';
roundRect(ctx, 4, 4, w - 8, 64, 26);
ctx.fill();
ctx.strokeStyle = 'rgba(169,113,61,.6)';
ctx.lineWidth = 4;
roundRect(ctx, 4, 4, w - 8, 64, 26);
ctx.stroke();
ctx.beginPath(); ctx.moveTo(w / 2 - 10, 66); ctx.lineTo(w / 2 + 10, 66); ctx.lineTo(w / 2, 80); ctx.fill();
ctx.fillStyle = '#574233';
ctx.font = '700 30px Nunito, sans-serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(text, w / 2, 36);
const tex = new THREE.CanvasTexture(cv);
tex.colorSpace = THREE.SRGBColorSpace;
return tex;
}
function roundRect(ctx, x, y, w, h, r) {
ctx.beginPath();
ctx.arcTo(x + w, y, x + w, y + h, r);
ctx.arcTo(x + w, y + h, x, y + h, r);
ctx.arcTo(x, y + h, x, y, r);
ctx.arcTo(x, y, x + w, y, r);
ctx.closePath();
}
function ensureEmote(rig) {
if (!rig.emoteSprite) {
const sp = new THREE.Sprite(new THREE.SpriteMaterial({ transparent: true, depthTest: false }));
sp.renderOrder = 20;
sp.scale.setScalar(0.5);
rig.group.add(sp);
sp.position.y = 1.55;
rig.emoteSprite = sp;
sp.material.opacity = 0;
}
return rig.emoteSprite;
}
function ensureBubble(rig) {
if (!rig.bubbleSprite) {
const sp = new THREE.Sprite(new THREE.SpriteMaterial({ transparent: true, depthTest: false }));
sp.renderOrder = 21;
rig.group.add(sp);
sp.position.y = 1.95;
rig.bubbleSprite = sp;
sp.material.opacity = 0;
}
return rig.bubbleSprite;
}
// ---------------- sync sims ↔ visuals ----------------
export function syncCustomerRigs() {
if (!G.data) return;
const alive = new Set(G.customers.map(c => c.id));
for (const [id, rig] of [...charRigs]) {
if (!alive.has(id)) {
rig.group.parent?.remove(rig.group);
disposeRig(rig);
charRigs.delete(id);
}
}
for (const c of G.customers) {
if (!charRigs.has(c.id)) {
const rig = buildCharacter({ arch: c.archId, skin: SKIN_TONES[Math.floor(Math.random() * SKIN_TONES.length)] });
rig.c = c;
sceneAdd(rig.group);
charRigs.set(c.id, rig);
}
}
}
import { scene } from './engine.js';
function sceneAdd(o) { scene.add(o); }
function disposeRig(rig) {
rig.group.traverse(o => { o.geometry?.dispose(); });
}
export function updateCharacters(dt, elapsed) {
// customers
for (const [id, rig] of charRigs) {
const c = rig.c;
if (!c || !rig.group.parent) continue;
rig.group.position.set(c.pos[0], 0, c.pos[1]);
// facing
if (c.targetFacing !== undefined) {
let d = c.targetFacing - rig.group.rotation.y;
while (d > Math.PI) d -= Math.PI * 2;
while (d < -Math.PI) d += Math.PI * 2;
rig.group.rotation.y += d * Math.min(1, dt * 10);
}
animateRig(rig, dt, elapsed, c.state === 'arriving' || c.pathI < (c.path?.length ?? 0) || c.state === 'leaving');
// emote
if (c.emote && c.emote !== rig.curEmote) {
rig.curEmote = c.emote;
const sp = ensureEmote(rig);
sp.material.map = makeEmojiTexture(c.emote);
sp.material.needsUpdate = true;
}
const esp = rig.emoteSprite;
if (esp) {
const want = c.emoteT > 0 ? 1 : 0;
esp.material.opacity += (want - esp.material.opacity) * dt * 8;
const pop = 1 + Math.sin(elapsed * 6) * 0.06;
esp.scale.setScalar(0.5 * (want ? pop : 1));
}
// bubble
if (c.bubble && c.bubble !== rig.curBubble) {
rig.curBubble = c.bubble;
const sp = ensureBubble(rig);
sp.material.map = makeBubbleTexture(c.bubble);
sp.material.needsUpdate = true;
const tex = sp.material.map;
sp.scale.set(tex.image.width / 190, tex.image.height / 190, 1);
}
const bsp = rig.bubbleSprite;
if (bsp) bsp.material.opacity += ((c.bubbleT > 0 ? 1 : 0) - bsp.material.opacity) * dt * 8;
}
// player
if (playerRig && G.data) {
playerRig.group.position.set(G.data.player.x, 0, G.data.player.z);
animateRig(playerRig, dt, elapsed, playerRig.userData?.moving);
}
}
function animateRig(rig, dt, elapsed, moving) {
rig.phase += dt * (moving ? 11 : 2.2);
const p = rig.parts;
const bob = moving ? Math.abs(Math.sin(rig.phase)) * 0.05 : Math.sin(rig.phase) * 0.012;
p.body.position.y = bob;
p.body.rotation.z = moving ? Math.sin(rig.phase) * 0.045 : 0;
const swing = moving ? Math.sin(rig.phase) * 0.65 : Math.sin(rig.phase * 0.7) * 0.06;
p.arms[0].rotation.x = swing;
p.arms[1].rotation.x = -swing;
// blink occasionally
const blink = (Math.sin(elapsed * 1.7 + rig.phase * 0.13) > 0.985) ? 0.1 : 1;
for (const e of p.eyes) e.scale.y = blink;
p.head.rotation.y = Math.sin(rig.phase * 0.35) * 0.12;
}
// ---------------- player ----------------
export function spawnPlayer() {
if (playerRig) { scene.remove(playerRig.group); disposeRig(playerRig); }
const pl = G.data.player;
playerRig = buildCharacter({
skin: SKIN_TONES[pl.skin % SKIN_TONES.length],
hairColor: HAIR_COLORS[(pl.hair + 2) % HAIR_COLORS.length],
outfit: OUTFIT_COLORS[pl.outfit % OUTFIT_COLORS.length],
hairStyle: pl.hair % 4,
scale: 1,
});
scene.add(playerRig.group);
return playerRig;
}
export function getPlayerPos() { return playerRig ? [playerRig.group.position.x, playerRig.group.position.z] : [0, 3]; }
export function clearCharacterRigs() {
for (const [, rig] of charRigs) { rig.group.parent?.remove(rig.group); disposeRig(rig); }
charRigs.clear();
if (playerRig) { scene.remove(playerRig.group); disposeRig(playerRig); playerRig = null; }
}
+177
View File
@@ -0,0 +1,177 @@
import * as THREE from 'three';
import { EffectComposer } from 'three/examples/jsm/postprocessing/EffectComposer.js';
import { RenderPass } from 'three/examples/jsm/postprocessing/RenderPass.js';
import { UnrealBloomPass } from 'three/examples/jsm/postprocessing/UnrealBloomPass.js';
import { OutputPass } from 'three/examples/jsm/postprocessing/OutputPass.js';
import { clamp, lerp, easeInOut } from '../util.js';
// ============================================================
// RENDER ENGINE — renderer, scene, camera rig, post fx
// ============================================================
export const scene = new THREE.Scene();
export let renderer = null;
export let composer = null;
let bloomPass = null;
export const camera = new THREE.PerspectiveCamera(42, 1, 0.1, 300);
// --- camera orbit rig ---
const rig = {
target: new THREE.Vector3(0, 0.6, 0),
tTarget: new THREE.Vector3(0, 0.6, 0),
radius: 16, tRadius: 16,
azimuth: Math.PI * 0.08, tAzimuth: Math.PI * 0.08,
polar: 0.98, tPolar: 0.98,
vRadius: 0,
};
const FOCUS_STACK = [];
export function initEngine(container) {
renderer = new THREE.WebGLRenderer({ antialias: true, powerPreference: 'high-performance' });
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 1.12;
renderer.outputColorSpace = THREE.SRGBColorSpace;
container.appendChild(renderer.domElement);
composer = new EffectComposer(renderer);
composer.addPass(new RenderPass(scene, camera));
bloomPass = new UnrealBloomPass(new THREE.Vector2(window.innerWidth, window.innerHeight), 0.38, 0.55, 0.86);
composer.addPass(bloomPass);
composer.addPass(new OutputPass());
window.addEventListener('resize', () => {
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth, window.innerHeight);
composer.setSize(window.innerWidth, window.innerHeight);
});
return renderer;
}
export function applyQuality(q) {
if (!renderer) return;
const high = q !== 'low';
renderer.shadowMap.enabled = high;
bloomPass.enabled = high;
renderer.setPixelRatio(high ? Math.min(window.devicePixelRatio, 2) : 1);
scene.traverse(o => { if (o.isMesh && o.material) o.material.needsUpdate = true; });
}
// ---------- camera control ----------
export function orbitRotate(dx, dy) {
rig.tAzimuth -= dx * 0.005;
rig.tPolar = clamp(rig.tPolar - dy * 0.004, 0.32, 1.35);
}
export function orbitZoom(delta) {
rig.tRadius = clamp(rig.tRadius * (1 + delta * 0.0012), 6, 46);
}
export function orbitPan(dx, dz) {
// pan in camera space
const s = rig.radius * 0.0016;
const cos = Math.cos(rig.azimuth), sin = Math.sin(rig.azimuth);
rig.tTarget.x += (-dx * cos - dz * sin) * s * -1;
rig.tTarget.z += (dx * sin - dz * cos) * s * -1;
clampTarget();
}
export function setOrbit(target, radius, azimuth, polar, instant = false) {
rig.tTarget.copy(target);
rig.tRadius = radius;
if (azimuth !== undefined) rig.tAzimuth = azimuth;
if (polar !== undefined) rig.tPolar = polar;
if (instant) {
rig.target.copy(rig.tTarget); rig.radius = rig.tRadius;
rig.azimuth = rig.tAzimuth; rig.polar = rig.tPolar;
}
}
export function pushFocus(name) { if (!FOCUS_STACK.includes(name)) FOCUS_STACK.push(name); }
export function popFocus(name) { const i = FOCUS_STACK.indexOf(name); if (i >= 0) FOCUS_STACK.splice(i, 1); }
function clampTarget() {
const lim = 26;
rig.tTarget.x = clamp(rig.tTarget.x, -lim, lim);
rig.tTarget.z = clamp(rig.tTarget.z, -lim, lim);
}
export function updateCamera(dt) {
const k = 1 - Math.pow(0.0001, dt); // smooth exponential approach
rig.target.lerp(rig.tTarget, k * 0.9);
rig.radius = lerp(rig.radius, rig.tRadius, k * 0.9);
rig.azimuth = lerp(rig.azimuth, rig.tAzimuth, k * 0.85);
rig.polar = lerp(rig.polar, rig.tPolar, k * 0.85);
const sinP = Math.sin(rig.polar), cosP = Math.cos(rig.polar);
camera.position.set(
rig.target.x + rig.radius * sinP * Math.sin(rig.azimuth),
rig.target.y + rig.radius * cosP,
rig.target.z + rig.radius * sinP * Math.cos(rig.azimuth)
);
camera.lookAt(rig.target);
camera.userData.lookTarget = rig.target;
return { polar: rig.polar, azimuth: rig.azimuth };
}
export function renderFrame() {
if (composer) composer.render();
}
// ---------- raycasting helper ----------
const raycaster = new THREE.Raycaster();
const ndc = new THREE.Vector2();
export function pickAt(clientX, clientY, objects, recursive = true) {
ndc.x = (clientX / window.innerWidth) * 2 - 1;
ndc.y = -(clientY / window.innerHeight) * 2 + 1;
raycaster.setFromCamera(ndc, camera);
const hits = raycaster.intersectObjects(objects, recursive);
return hits.length ? hits[0] : null;
}
export function screenRay(clientX, clientY) {
ndc.x = (clientX / window.innerWidth) * 2 - 1;
ndc.y = -(clientY / window.innerHeight) * 2 + 1;
raycaster.setFromCamera(ndc, camera);
return raycaster.ray;
}
// ---------- shared material cache ----------
const matCache = new Map();
export function mat(color, opts = {}) {
const key = `${color}|${opts.rough ?? .8}|${opts.metal ?? 0}|${opts.flat ?? 1}|${opts.emissive ?? 0}|${opts.emissiveIntensity ?? 1}|${opts.transparent ?? 0}|${opts.opacity ?? 1}`;
if (!matCache.has(key)) {
const m = new THREE.MeshStandardMaterial({
color, roughness: opts.rough ?? 0.8, metalness: opts.metal ?? 0,
flatShading: opts.flat ?? true,
emissive: opts.emissive || 0x000000,
emissiveIntensity: opts.emissiveIntensity ?? 1,
transparent: !!opts.transparent, opacity: opts.opacity ?? 1,
});
matCache.set(key, m);
}
return matCache.get(key);
}
export function basicMat(color, opacity = 1) {
const key = `b|${color}|${opacity}`;
if (!matCache.has(key)) {
matCache.set(key, new THREE.MeshBasicMaterial({ color, transparent: opacity < 1, opacity }));
}
return matCache.get(key);
}
// ---------- geometry helpers ----------
export function roundedBox(w, h, d, r = 0.06, seg = 2) {
// lightweight rounded box via BoxGeometry + bevel illusion (cheap): we just use box here
// (RoundedBoxGeometry import kept out to save bundle; visual difference minor at this scale)
return new THREE.BoxGeometry(w, h, d, 1, 1, 1);
}
export function cyl(rt, rb, h, seg = 12) { return new THREE.CylinderGeometry(rt, rb, h, seg); }
export function sph(r, seg = 14) { return new THREE.SphereGeometry(r, seg, Math.max(8, seg / 2)); }
export function disposeObject(obj) {
obj.traverse(o => {
if (o.geometry) o.geometry.dispose();
// materials are cached/shared — don't dispose
});
obj.parent?.remove(obj);
}
+429
View File
@@ -0,0 +1,429 @@
import * as THREE from 'three';
import { scene, mat, cyl, sph } from './engine.js';
import { buildProductMesh } from './products3d.js';
import { FURN_BY_ID } from '../data/furniture.js';
import G from '../sim/state.js';
// ============================================================
// FURNITURE 3D — meshes for every catalog item + shelf stocking
// ============================================================
export const furnGroups = new Map(); // furnitureId -> {group, sig, slots:[]}
export const WOOD = { dark: 0x6b4a2c, mid: 0x8a5a33, light: 0xa9713d };
export function cellToWorld(cx, cz) {
const [W, H] = G.gridSize();
return [cx - W / 2 + 0.5, cz - H / 2 + 0.5];
}
// Rebuild all furniture visuals (on load / expand / layout change)
export function refreshAllFurniture() {
if (!G.data) return;
const seen = new Set();
for (const f of G.data.furniture) {
seen.add(f.id);
let rec = furnGroups.get(f.id);
if (!rec) {
const group = buildFurnMesh(f.type);
scene.add(group);
rec = { group, sig: '' };
furnGroups.set(f.id, rec);
}
placeFurn(rec.group, f);
// shelf stock display
if (['shelf', 'table', 'pedestal'].includes(f.type)) {
const stock = G.data.shelfStock[f.id] || {};
const sig = JSON.stringify(stock);
if (sig !== rec.sig) {
rec.sig = sig;
restockShelfVisual(f, rec);
}
}
}
for (const [id, rec] of [...furnGroups]) {
if (!seen.has(id)) {
scene.remove(rec.group);
disposeDeep(rec.group);
furnGroups.delete(id);
}
}
}
export function getFurnGroup(id) { return furnGroups.get(id)?.group || null; }
function disposeDeep(obj) {
obj.traverse(o => { o.geometry?.dispose(); });
}
function placeFurn(group, f) {
const def = FURN_BY_ID[f.type];
const rot = f.rot % 2;
const w = rot ? def.size[1] : def.size[0];
const d = rot ? def.size[0] : def.size[1];
const [wx, wz] = cellToWorld(f.cx + w / 2 - 0.5, f.cz + d / 2 - 0.5);
group.position.set(wx, 0, wz);
group.rotation.y = -f.rot * Math.PI / 2;
}
// ---------- individual builders ----------
export function buildFurnMesh(type) {
switch (type) {
case 'shelf': return buildShelf();
case 'counter': return buildCounter();
case 'table': return buildTable();
case 'crate': return buildCrate();
case 'rug': return buildRug();
case 'plant': return buildPlant();
case 'lamp': return buildLampFurn();
case 'pedestal': return buildPedestal();
case 'painting': return buildPainting();
case 'banner': return buildBanner();
case 'fountain': return buildMiniFountain();
case 'catbed': return buildCatBed();
default: return new THREE.Group();
}
}
function shadowify(g) { g.traverse(o => { if (o.isMesh) { o.castShadow = true; o.receiveShadow = true; } }); return g; }
function buildShelf() {
const g = new THREE.Group();
const sideM = mat(WOOD.mid, { rough: .85 });
const boardM = mat(WOOD.light, { rough: .85 });
// vertical sides
for (const sgn of [-1, 1]) {
const side = new THREE.Mesh(new THREE.BoxGeometry(0.08, 1.7, 0.42), sideM);
side.position.set(sgn * 0.96, 0.85, 0);
g.add(side);
}
// boards
const levels = [0.12, 0.66, 1.2];
for (const y of levels) {
const board = new THREE.Mesh(new THREE.BoxGeometry(1.92, 0.06, 0.44), boardM);
board.position.y = y;
g.add(board);
}
// top crown
const crown = new THREE.Mesh(new THREE.BoxGeometry(2.04, 0.09, 0.48), mat(WOOD.dark));
crown.position.y = 1.72;
g.add(crown);
// back panel
const back = new THREE.Mesh(new THREE.BoxGeometry(1.92, 1.68, 0.04), mat(0x9a6b3f, { rough: .95 }));
back.position.set(0, 0.9, -0.21);
g.add(back);
// little heart carved on top
const h = new THREE.Mesh(sph(0.05, 8), mat(0xef6461));
h.position.set(0, 1.82, 0);
g.add(h);
return shadowify(g);
}
function shelfSlots() {
// 4 per level × 3 levels, world-local positions
const slots = [];
for (const y of [0.24, 0.78, 1.32]) {
for (let i = 0; i < 4; i++) {
slots.push(new THREE.Vector3(-0.69 + i * 0.46, y, 0));
}
}
return slots;
}
function buildCounter() {
const g = new THREE.Group();
const body = new THREE.Mesh(new THREE.BoxGeometry(1.95, 0.95, 0.75), mat(WOOD.dark, { rough: .85 }));
body.position.y = 0.48;
g.add(body);
const top = new THREE.Mesh(new THREE.BoxGeometry(2.08, 0.09, 0.88), mat(WOOD.light, { rough: .7 }));
top.position.y = 1.0;
g.add(top);
// till / register
const till = new THREE.Mesh(new THREE.BoxGeometry(0.34, 0.3, 0.3), mat(0x4a4038, { rough: .6 }));
till.position.set(-0.6, 1.2, 0);
g.add(till);
const keys = new THREE.Mesh(new THREE.BoxGeometry(0.28, 0.03, 0.22), mat(0xd9c9a8));
keys.position.set(-0.6, 1.36, 0.02);
g.add(keys);
// bell
const bell = new THREE.Mesh(sph(0.06, 10), mat(0xffd166, { metal: .8, rough: .25 }));
bell.scale.y = 0.85;
bell.position.set(0.15, 1.14, 0.15);
g.add(bell);
const btn = new THREE.Mesh(sph(0.02, 6), mat(0x4a4038));
btn.position.set(0.15, 1.23, 0.15);
g.add(btn);
// jar of lollipops for charm
const jar = new THREE.Mesh(cyl(0.09, 0.09, 0.16, 10), new THREE.MeshStandardMaterial({ color: 0xffffff, transparent: true, opacity: .35, roughness: .2 }));
jar.position.set(0.62, 1.13, 0);
g.add(jar);
for (let i = 0; i < 4; i++) {
const pop = new THREE.Mesh(sph(0.045, 8), mat([0xef6461, 0x58c98b, 0x5aa9e6, 0xffd166][i]));
pop.position.set(0.56 + (i % 2) * 0.11, 1.16 + Math.floor(i / 2) * 0.07, (i % 2 ? 0.03 : -0.03));
g.add(pop);
}
// front panel trim
const trim = new THREE.Mesh(new THREE.BoxGeometry(1.98, 0.1, 0.78), mat(WOOD.mid));
trim.position.y = 0.12;
g.add(trim);
return shadowify(g);
}
function buildTable() {
const g = new THREE.Group();
const top = new THREE.Mesh(cyl(0.95, 0.95, 0.09, 18), mat(WOOD.light, { rough: .75 }));
top.position.y = 0.72;
g.add(top);
const cloth = new THREE.Mesh(cyl(0.99, 0.99, 0.03, 18), mat(0xf6ead2, { rough: .95 }));
cloth.position.y = 0.77;
g.add(cloth);
const leg = new THREE.Mesh(cyl(0.12, 0.17, 0.7, 10), mat(WOOD.mid));
leg.position.y = 0.36;
g.add(leg);
const base = new THREE.Mesh(cyl(0.42, 0.46, 0.08, 14), mat(WOOD.dark));
base.position.y = 0.04;
g.add(base);
return shadowify(g);
}
function tableSlots() {
const s = [];
for (let i = 0; i < 6; i++) {
const ang = (i / 6) * Math.PI * 2 + 0.5;
s.push(new THREE.Vector3(Math.cos(ang) * 0.52, 0.83, Math.sin(ang) * 0.52));
}
return s;
}
function buildCrate() {
const g = new THREE.Group();
const box = new THREE.Mesh(new THREE.BoxGeometry(0.8, 0.62, 0.8), mat(WOOD.mid, { rough: .95 }));
box.position.y = 0.31;
g.add(box);
const edge = mat(WOOD.dark, { rough: .95 });
for (const [rx, ry] of [[0, 0], [0, 0.62]]) {
const band = new THREE.Mesh(new THREE.BoxGeometry(0.84, 0.07, 0.84), edge);
band.position.y = ry === 0 ? 0.05 : 0.57;
g.add(band);
}
const cross = new THREE.Mesh(new THREE.BoxGeometry(0.86, 0.1, 0.1), edge);
cross.rotation.x = Math.PI / 4;
cross.position.y = 0.31;
cross.scale.z = 6;
g.add(cross);
return shadowify(g);
}
function buildRug() {
const cv = document.createElement('canvas');
cv.width = cv.height = 128;
const ctx = cv.getContext('2d');
ctx.fillStyle = '#c95f4a'; ctx.fillRect(0, 0, 128, 128);
ctx.strokeStyle = '#f6ead2'; ctx.lineWidth = 6;
ctx.strokeRect(10, 10, 108, 108);
ctx.strokeStyle = '#ffd166'; ctx.lineWidth = 3;
ctx.strokeRect(20, 20, 88, 88);
ctx.fillStyle = '#f6ead2';
for (let i = 0; i < 4; i++) {
ctx.beginPath();
ctx.arc(38 + i * 18, 64, 5, 0, 7);
ctx.fill();
}
const tex = new THREE.CanvasTexture(cv);
tex.colorSpace = THREE.SRGBColorSpace;
const m = new THREE.Mesh(
new THREE.BoxGeometry(2.9, 0.03, 1.9),
new THREE.MeshStandardMaterial({ map: tex, roughness: 1 })
);
m.position.y = 0.02;
m.receiveShadow = true;
return m;
}
function buildPlant() {
const g = new THREE.Group();
const pot = new THREE.Mesh(cyl(0.19, 0.14, 0.3, 10), mat(0xc96f52, { rough: .9 }));
pot.position.y = 0.15;
g.add(pot);
const rim = new THREE.Mesh(cyl(0.21, 0.21, 0.06, 10), mat(0xb85f43));
rim.position.y = 0.3;
g.add(rim);
const stem = new THREE.Mesh(cyl(0.025, 0.03, 0.35, 6), mat(0x4f8a44));
stem.position.y = 0.5;
g.add(stem);
const leafM = mat(0x5da24b, { rough: .95 });
for (let i = 0; i < 5; i++) {
const leaf = new THREE.Mesh(sph(rand(.14, .22), 8), leafM);
leaf.scale.set(1, 0.55, 1);
const ang = (i / 5) * Math.PI * 2;
leaf.position.set(Math.cos(ang) * 0.16, 0.62 + (i % 2) * 0.14, Math.sin(ang) * 0.16);
g.add(leaf);
}
function rand(a = 0.8, b) { return b === undefined ? Math.random() * a : a + Math.random() * (b - a); }
return shadowify(g);
}
let lampLightCount = 0;
function buildLampFurn() {
const g = new THREE.Group();
const pole = new THREE.Mesh(cyl(0.035, 0.05, 1.5, 8), mat(0x4a4038, { metal: .3, rough: .5 }));
pole.position.y = 0.75;
g.add(pole);
const base = new THREE.Mesh(cyl(0.2, 0.24, 0.06, 12), mat(0x4a4038));
base.position.y = 0.03;
g.add(base);
const shade = new THREE.Mesh(cone(0.28, 0.32),
new THREE.MeshStandardMaterial({ color: 0xffd9a0, emissive: 0xffb45e, emissiveIntensity: 0.9, roughness: .8 }));
shade.position.y = 1.62;
g.add(shade);
const bulb = new THREE.Mesh(sph(0.07, 8), mat(0xfff3cf, { emissive: 0xffdf9e, emissiveIntensity: 2.2 }));
bulb.position.y = 1.52;
g.add(bulb);
// real light only for first few lamps (perf)
if (lampLightCount < 4) {
const pl = new THREE.PointLight(0xffc46b, 6, 6.5, 1.8);
pl.position.y = 1.5;
g.add(pl);
lampLightCount++;
}
return shadowify(g);
}
function cone(r, h) { return new THREE.ConeGeometry(r, h, 12); }
function buildPedestal() {
const g = new THREE.Group();
const marble = mat(0xe8e2d5, { rough: .5 });
const base = new THREE.Mesh(new THREE.BoxGeometry(0.55, 0.12, 0.55), marble);
base.position.y = 0.06;
g.add(base);
const column = new THREE.Mesh(cyl(0.16, 0.2, 0.9, 10), marble);
column.position.y = 0.55;
g.add(column);
const cap = new THREE.Mesh(new THREE.BoxGeometry(0.45, 0.08, 0.45), marble);
cap.position.y = 1.03;
g.add(cap);
// glass dome
const dome = new THREE.Mesh(sph(0.26, 14), new THREE.MeshStandardMaterial({ color: 0xcfe8f5, transparent: true, opacity: 0.18, roughness: .1, metalness: .1 }));
dome.scale.y = 1.25;
dome.position.y = 1.32;
g.add(dome);
return shadowify(g);
}
function pedestalSlot() { return new THREE.Vector3(0, 1.1, 0); }
function buildPainting() {
const g = new THREE.Group();
const frame = new THREE.Mesh(new THREE.BoxGeometry(0.95, 0.75, 0.08), mat(0xb98a3d, { rough: .6, metal: .3 }));
frame.position.y = 1.9;
g.add(frame);
// tiny procedural landscape
const cv = document.createElement('canvas'); cv.width = 128; cv.height = 96;
const ctx = cv.getContext('2d');
const grad = ctx.createLinearGradient(0, 0, 0, 96);
grad.addColorStop(0, '#8ecdea'); grad.addColorStop(1, '#ffe3ae');
ctx.fillStyle = grad; ctx.fillRect(0, 0, 128, 96);
ctx.fillStyle = '#7cb95a'; ctx.beginPath(); ctx.moveTo(0, 70); ctx.lineTo(40, 40); ctx.lineTo(80, 74); ctx.lineTo(128, 50); ctx.lineTo(128, 96); ctx.lineTo(0, 96); ctx.fill();
ctx.fillStyle = '#ffd166'; ctx.beginPath(); ctx.arc(96, 26, 10, 0, 7); ctx.fill();
const tex = new THREE.CanvasTexture(cv);
tex.colorSpace = THREE.SRGBColorSpace;
const art = new THREE.Mesh(new THREE.PlaneGeometry(0.8, 0.6), new THREE.MeshBasicMaterial({ map: tex }));
art.position.set(0, 1.9, 0.045);
g.add(art);
g.userData.wallArt = true;
return g;
}
function buildBanner() {
const g = new THREE.Group();
const pole = new THREE.Mesh(cyl(0.035, 0.035, 2.4, 6), mat(0x6b4a2c));
pole.position.y = 1.2;
g.add(pole);
const clothColors = [0xef6461, 0xffd166, 0x5aa9e6];
for (let i = 0; i < 3; i++) {
const flag = new THREE.Mesh(new THREE.ConeGeometry(0.16, 0.36, 4),
new THREE.MeshStandardMaterial({ color: clothColors[i], roughness: .9, side: THREE.DoubleSide }));
flag.rotation.z = Math.PI;
flag.position.set(0.001, 2.1 - i * 0.42, 0);
g.add(flag);
}
return shadowify(g);
}
function buildMiniFountain() {
const g = new THREE.Group();
const stone = mat(0xa8adb5, { rough: .85 });
const basin = new THREE.Mesh(cyl(0.85, 0.95, 0.4, 16), stone);
basin.position.y = 0.2;
g.add(basin);
const water = new THREE.Mesh(cyl(0.76, 0.76, 0.1, 16), mat(0x7fd4ea, { rough: .15 }));
water.position.y = 0.42;
water.name = 'water';
g.add(water);
const pillar = new THREE.Mesh(cyl(0.12, 0.16, 0.6, 10), stone);
pillar.position.y = 0.65;
g.add(pillar);
const bowl = new THREE.Mesh(cyl(0.3, 0.38, 0.12, 12), stone);
bowl.position.y = 0.95;
g.add(bowl);
const water2 = new THREE.Mesh(cyl(0.26, 0.26, 0.06, 12), mat(0x7fd4ea, { rough: .15 }));
water2.position.y = 1.02;
g.add(water2);
return shadowify(g);
}
function buildCatBed() {
const g = new THREE.Group();
const bed = new THREE.Mesh(new THREE.TorusGeometry(0.3, 0.12, 8, 16), mat(0xef8fb5, { rough: 1 }));
bed.rotation.x = Math.PI / 2;
bed.scale.y = 0.6;
bed.position.y = 0.1;
g.add(bed);
const cushion = new THREE.Mesh(cyl(0.3, 0.3, 0.08, 16), mat(0xf6d8e0, { rough: 1 }));
cushion.position.y = 0.06;
g.add(cushion);
// sleeping cat (if adopted)
if (G.data?.flags.cat) {
const body = new THREE.Mesh(sph(0.2, 10), mat(0xe8a25c, { rough: 1 }));
body.scale.set(1.25, 0.75, 1);
body.position.y = 0.18;
g.add(body);
const head = new THREE.Mesh(sph(0.13, 10), mat(0xe8a25c, { rough: 1 }));
head.position.set(0.24, 0.24, 0);
g.add(head);
for (const sgn of [-1, 1]) {
const ear = new THREE.Mesh(cone(0.05, 0.09, 4), mat(0xd98f47));
ear.position.set(0.24 + sgn * 0.07, 0.37, 0);
g.add(ear);
}
const tail = new THREE.Mesh(new THREE.TorusGeometry(0.12, 0.035, 6, 12, 4), mat(0xd98f47));
tail.position.set(-0.22, 0.12, 0.1);
tail.rotation.x = 1.2;
g.add(tail);
}
return shadowify(g);
}
// ---------- shelf stock visualization ----------
function slotsFor(type) {
if (type === 'shelf') return shelfSlots();
if (type === 'table') return tableSlots();
return [pedestalSlot()];
}
function restockShelfVisual(f, rec) {
// remove old product meshes
if (rec.items) for (const it of rec.items) it.removeFromParent();
rec.items = [];
const stock = G.data.shelfStock[f.id] || {};
const slots = slotsFor(f.type);
let slotIdx = 0;
for (const [pid, qty] of Object.entries(stock)) {
const n = Math.min(qty, f.type === 'pedestal' ? 1 : Math.ceil(qty / 3) + 1, slots.length - slotIdx);
for (let i = 0; i < n && slotIdx < slots.length; i++, slotIdx++) {
const m = buildProductMesh(pid, f.type === 'pedestal' ? 1.5 : 1);
const s = slots[slotIdx];
m.position.copy(s).add(new THREE.Vector3((Math.random() - 0.5) * 0.06, 0, (Math.random() - 0.5) * 0.08));
m.rotation.y = Math.random() * Math.PI * 2;
rec.group.add(m);
rec.items.push(m);
}
}
}
+116
View File
@@ -0,0 +1,116 @@
import * as THREE from 'three';
import { scene } from './engine.js';
// ============================================================
// FX — floating gold text, coin bursts, sparkles, hearts
// ============================================================
const active = [];
function makeTextSprite(text, color = '#ffd166') {
const cv = document.createElement('canvas');
cv.width = 256; cv.height = 96;
const ctx = cv.getContext('2d');
ctx.font = '900 52px Nunito, sans-serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.lineWidth = 10;
ctx.strokeStyle = 'rgba(60,35,10,0.9)';
ctx.strokeText(text, 128, 48);
ctx.fillStyle = color;
ctx.fillText(text, 128, 48);
const tex = new THREE.CanvasTexture(cv);
tex.colorSpace = THREE.SRGBColorSpace;
const sp = new THREE.Sprite(new THREE.SpriteMaterial({ map: tex, transparent: true, depthTest: false }));
sp.renderOrder = 30;
sp.scale.set(1.5, 0.56, 1);
return sp;
}
export function floatText(text, x, y, z, color) {
const sp = makeTextSprite(text, color);
sp.position.set(x, y, z);
scene.add(sp);
active.push({ obj: sp, t: 0, dur: 1.3, kind: 'text', vy: 1.1 });
}
const coinGeo = new THREE.CylinderGeometry(0.055, 0.055, 0.018, 12);
const coinMat = new THREE.MeshStandardMaterial({ color: 0xffd166, metalness: .8, roughness: .25, emissive: 0x996b00, emissiveIntensity: .4 });
export function coinBurst(x, y, z, n = 6) {
for (let i = 0; i < n; i++) {
const c = new THREE.Mesh(coinGeo, coinMat);
c.position.set(x + (Math.random() - 0.5) * 0.2, y, z + (Math.random() - 0.5) * 0.2);
c.rotation.set(Math.random() * 3, Math.random() * 3, Math.random() * 3);
scene.add(c);
active.push({
obj: c, t: 0, dur: 0.85, kind: 'coin',
vx: (Math.random() - 0.5) * 1.6, vy: 2.2 + Math.random() * 1.4, vz: (Math.random() - 0.5) * 1.6,
});
}
}
const heartGeo = new THREE.SphereGeometry(0.06, 8, 6);
export function heartPuff(x, y, z, n = 3) {
for (let i = 0; i < n; i++) {
const m = new THREE.Mesh(heartGeo, new THREE.MeshBasicMaterial({ color: 0xef6a92, transparent: true }));
m.scale.set(1, 0.85, 0.6);
m.position.set(x + (Math.random() - 0.5) * 0.3, y + Math.random() * 0.2, z + (Math.random() - 0.5) * 0.2);
scene.add(m);
active.push({ obj: m, t: 0, dur: 1.1, kind: 'heart', vx: (Math.random() - 0.5) * 0.4, vy: 0.9, vz: (Math.random() - 0.5) * 0.4 });
}
}
const sparkMat = new THREE.MeshBasicMaterial({ color: 0xfff3b0, transparent: true, blending: THREE.AdditiveBlending });
export function sparkleBurst(x, y, z, n = 10) {
for (let i = 0; i < n; i++) {
const s = new THREE.Mesh(new THREE.OctahedronGeometry(0.04), sparkMat.clone());
s.position.set(x, y, z);
scene.add(s);
const a = Math.random() * Math.PI * 2;
active.push({
obj: s, t: 0, dur: 0.8, kind: 'spark',
vx: Math.cos(a) * (0.6 + Math.random()), vy: 0.8 + Math.random() * 1.2, vz: Math.sin(a) * (0.6 + Math.random()),
});
}
}
export function puffAt(x, y, z) {
// dust puff for placement / expansion
const cv = document.createElement('canvas'); cv.width = cv.height = 64;
const ctx = cv.getContext('2d');
const g = ctx.createRadialGradient(32, 32, 4, 32, 32, 30);
g.addColorStop(0, 'rgba(255,240,214,0.7)');
g.addColorStop(1, 'rgba(255,240,214,0)');
ctx.fillStyle = g; ctx.fillRect(0, 0, 64, 64);
for (let i = 0; i < 8; i++) {
const sp = new THREE.Sprite(new THREE.SpriteMaterial({ map: new THREE.CanvasTexture(cv), transparent: true, depthWrite: false }));
sp.position.set(x + (Math.random() - .5) * .8, y + Math.random() * .3, z + (Math.random() - .5) * .8);
sp.scale.setScalar(0.4);
scene.add(sp);
active.push({ obj: sp, t: 0, dur: 0.7, kind: 'puff', vx: (Math.random() - .5) * 1.2, vy: 0.7, vz: (Math.random() - .5) * 1.2 });
}
}
export function updateFx(dt) {
for (let i = active.length - 1; i >= 0; i--) {
const a = active[i];
a.t += dt;
const k = a.t / a.dur;
if (k >= 1) {
scene.remove(a.obj);
a.obj.material?.dispose?.();
if (a.kind === 'text' || a.kind === 'puff') a.obj.material.map?.dispose?.();
active.splice(i, 1);
continue;
}
if (a.obj.position) {
a.obj.position.x += (a.vx || 0) * dt;
a.obj.position.y += (a.vy || 0) * dt;
a.obj.position.z += (a.vz || 0) * dt;
}
if (a.kind === 'coin') { a.vy -= 7.5 * dt; a.obj.rotation.x += dt * 9; a.obj.rotation.z += dt * 6; }
if (a.kind === 'text') { a.obj.material.opacity = k < 0.75 ? 1 : 1 - (k - 0.75) / 0.25; }
else a.obj.material.opacity = 1 - k;
if (a.kind === 'spark') a.obj.scale.setScalar(Math.max(0.01, 1 - k));
}
}
+330
View File
@@ -0,0 +1,330 @@
import * as THREE from 'three';
import { mat, cyl, sph } from './engine.js';
import { PRODUCT_BY_ID } from '../data/products.js';
// ============================================================
// PRODUCT MESHES — each kind of good gets a recognizable shape
// ============================================================
const geoCache = new Map();
function cache(key, fn) { if (!geoCache.has(key)) geoCache.set(key, fn()); return geoCache.get(key); }
const glassMats = new Map();
function glassMat(hex, opacity = 0.45) {
const k = `${hex}|${opacity}`;
if (!glassMats.has(k)) {
glassMats.set(k, new THREE.MeshStandardMaterial({
color: 0xffffff, transparent: true, opacity, roughness: 0.15, metalness: 0,
emissive: hex, emissiveIntensity: 0.12,
}));
}
return glassMats.get(k);
}
export function buildProductMesh(pid, scale = 1) {
const p = PRODUCT_BY_ID[pid];
if (!p) return new THREE.Group();
const g = new THREE.Group();
const C = p.display.color;
const glow = p.display.glow;
switch (p.display.kind) {
case 'bread': {
const body = new THREE.Mesh(cache('bread', () => new THREE.SphereGeometry(0.13, 10, 8)), mat(C, { rough: .9 }));
body.scale.set(1.5, 0.85, 1);
g.add(body);
const cut = mat(0x8a5a33, { rough: 1 });
for (let i = -1; i <= 1; i++) {
const line = new THREE.Mesh(new THREE.BoxGeometry(0.02, 0.02, 0.16), cut);
line.position.set(i * 0.09, 0.09, 0);
line.rotation.z = 0.4;
g.add(line);
}
break;
}
case 'apple': {
const a = new THREE.Mesh(sph(0.11, 10), mat(C, { rough: .55 }));
a.scale.y = 0.95;
g.add(a);
const stem = new THREE.Mesh(cyl(0.012, 0.016, 0.07, 5), mat(0x6b4a2c));
stem.position.y = 0.13;
g.add(stem);
break;
}
case 'cheese': {
const wedge = new THREE.Mesh(cache('cheese', () => new THREE.CylinderGeometry(0.14, 0.14, 0.1, 12, 1, false, 0, Math.PI / 2)), mat(C, { rough: .7 }));
wedge.rotation.y = Math.PI / 4;
g.add(wedge);
break;
}
case 'meat': {
const m = new THREE.Mesh(cache('meat', () => new THREE.CapsuleGeometry(0.08, 0.14, 4, 8)), mat(C, { rough: .8 }));
m.rotation.z = Math.PI / 2;
g.add(m);
const bone = new THREE.Mesh(sph(0.035, 6), mat(0xf2ece2));
bone.position.x = 0.15;
g.add(bone);
break;
}
case 'jar': {
const jar = new THREE.Mesh(cyl(0.09, 0.09, 0.16, 10), glassMat(C, glow ? 0.35 : 0.45));
g.add(jar);
const liquid = new THREE.Mesh(cyl(0.075, 0.075, 0.12, 10), mat(C, { rough: .35, emissive: glow ? C : 0x000000, emissiveIntensity: glow ? 0.7 : 0 }));
g.add(liquid);
const lid = new THREE.Mesh(cyl(0.095, 0.095, 0.03, 10), mat(0xc9a227, { metal: .6, rough: .4 }));
lid.position.y = 0.095;
g.add(lid);
break;
}
case 'cake': {
const b1 = new THREE.Mesh(cyl(0.13, 0.13, 0.07, 12), mat(0xf3d9c0, { rough: .9 }));
b1.position.y = 0.035; g.add(b1);
const b2 = new THREE.Mesh(cyl(0.09, 0.09, 0.06, 12), mat(C, { rough: .9 }));
b2.position.y = 0.1; g.add(b2);
const berry = new THREE.Mesh(sph(0.03, 6), mat(0xe0483e));
berry.position.y = 0.15; g.add(berry);
break;
}
case 'pie': {
const base = new THREE.Mesh(cyl(0.14, 0.15, 0.07, 12), mat(C, { rough: .95 }));
g.add(base);
const crust = new THREE.Mesh(new THREE.TorusGeometry(0.13, 0.025, 6, 14), mat(0xc98a3b, { rough: .95 }));
crust.rotation.x = Math.PI / 2;
crust.position.y = 0.06;
g.add(crust);
break;
}
case 'rope': {
const t = new THREE.Mesh(new THREE.TorusGeometry(0.1, 0.042, 6, 14), mat(C, { rough: .95 }));
t.rotation.x = Math.PI / 2;
g.add(t);
const t2 = t.clone(); t2.scale.setScalar(0.62); t2.position.y = 0.05;
g.add(t2);
break;
}
case 'lantern': {
const frame = new THREE.Mesh(new THREE.BoxGeometry(0.13, 0.18, 0.13), mat(0x4a4038, { metal: .4, rough: .5 }));
frame.material.wireframe = false;
g.add(frame);
const core = new THREE.Mesh(sph(0.06, 8), mat(0xfff1c4, { emissive: 0xffc95e, emissiveIntensity: 1.4, rough: .3 }));
core.scale.y = 1.4;
g.add(core);
const ring = new THREE.Mesh(new THREE.TorusGeometry(0.05, 0.012, 5, 10), mat(0x4a4038));
ring.position.y = 0.13;
g.add(ring);
break;
}
case 'candle': {
const stick = new THREE.Mesh(cyl(0.05, 0.055, 0.17, 8), mat(C, { rough: .7, emissive: glow ? C : 0, emissiveIntensity: glow ? 0.5 : 0 }));
g.add(stick);
const flame = new THREE.Mesh(coneGeo(0.035, 0.07), mat(0xffc95e, { emissive: 0xffae34, emissiveIntensity: 2.2, rough: .4 }));
flame.position.y = 0.13;
g.add(flame);
break;
}
case 'bolt': {
const b = new THREE.Mesh(new THREE.BoxGeometry(0.2, 0.09, 0.12), mat(C, { rough: .95 }));
g.add(b);
const band = new THREE.Mesh(new THREE.BoxGeometry(0.045, 0.095, 0.125), mat(0xf6ead2));
band.position.x = 0.02;
g.add(band);
break;
}
case 'tools': {
const handle = new THREE.Mesh(cyl(0.022, 0.026, 0.2, 6), mat(0x9a6b3f));
handle.rotation.z = Math.PI / 2.3;
handle.position.y = 0.02;
g.add(handle);
const head = new THREE.Mesh(new THREE.BoxGeometry(0.12, 0.07, 0.045), mat(C, { metal: .6, rough: .4 }));
head.position.set(-0.08, 0.09, 0);
head.rotation.z = 0.4;
g.add(head);
break;
}
case 'bucket': {
const b = new THREE.Mesh(cyl(0.1, 0.075, 0.15, 10), mat(C, { rough: .95 }));
g.add(b);
const rim = new THREE.Mesh(new THREE.TorusGeometry(0.1, 0.012, 5, 12), mat(0x6b4a2c));
rim.rotation.x = Math.PI / 2;
rim.position.y = 0.075;
g.add(rim);
break;
}
case 'umbrella': {
const canopy = new THREE.Mesh(coneGeo(0.15, 0.1, 10), mat(C, { rough: .8 }));
canopy.position.y = 0.12;
g.add(canopy);
const stick = new THREE.Mesh(cyl(0.012, 0.012, 0.22, 5), mat(0x6b4a2c));
g.add(stick);
break;
}
case 'scarf': {
const s = new THREE.Mesh(new THREE.TorusKnotGeometry(0.07, 0.03, 32, 6), mat(C, { rough: .95 }));
s.scale.set(1.3, 0.7, 1);
s.position.y = 0.06;
g.add(s);
break;
}
case 'flowers': {
const wrapC = new THREE.Mesh(coneGeo(0.08, 0.12, 8), mat(0xd9c9a8, { rough: .95 }));
wrapC.position.y = -0.01;
g.add(wrapC);
for (let i = 0; i < 5; i++) {
const bl = new THREE.Mesh(sph(0.035, 6), mat([0xef8fb5, 0xffd166, 0xef6461, 0xf6f6f6, 0xc39bf0][i], { rough: .8 }));
const ang = (i / 5) * Math.PI * 2;
bl.position.set(Math.cos(ang) * 0.045, 0.1 + (i % 2) * 0.03, Math.sin(ang) * 0.045);
g.add(bl);
const st = new THREE.Mesh(cyl(0.007, 0.007, 0.1, 4), mat(0x58a05c));
st.position.set(bl.position.x * 0.6, 0.04, bl.position.z * 0.6);
st.rotation.z = bl.position.x > 0 ? -0.25 : 0.25;
g.add(st);
}
break;
}
case 'potion': {
const flask = new THREE.Mesh(sph(0.09, 12), glassMat(C, 0.4));
flask.position.y = 0.02;
g.add(flask);
const neck = new THREE.Mesh(cyl(0.035, 0.05, 0.08, 8), glassMat(C, 0.4));
neck.position.y = 0.12;
g.add(neck);
const liquid = new THREE.Mesh(sph(0.07, 10), mat(C, { emissive: C, emissiveIntensity: glow ? 0.9 : 0.35, rough: .3 }));
liquid.scale.y = 0.75;
liquid.position.y = 0;
g.add(liquid);
const cork = new THREE.Mesh(cyl(0.038, 0.038, 0.04, 7), mat(0xb98a56, { rough: .95 }));
cork.position.y = 0.17;
g.add(cork);
break;
}
case 'box': {
const bx = new THREE.Mesh(new THREE.BoxGeometry(0.18, 0.12, 0.12), mat(C, { rough: .9 }));
g.add(bx);
const cross1 = new THREE.Mesh(new THREE.BoxGeometry(0.19, 0.035, 0.005), mat(0xef6461));
cross1.position.z = 0.063;
g.add(cross1);
const cross2 = new THREE.Mesh(new THREE.BoxGeometry(0.035, 0.19, 0.005), mat(0xef6461));
cross2.position.z = 0.063;
g.add(cross2);
break;
}
case 'torch': {
const stick = new THREE.Mesh(cyl(0.02, 0.028, 0.22, 6), mat(0x7c5433));
stick.position.y = -0.02;
g.add(stick);
const wrap = new THREE.Mesh(cyl(0.035, 0.03, 0.06, 6), mat(0x4a4038));
wrap.position.y = 0.09;
g.add(wrap);
const flame = new THREE.Mesh(coneGeo(0.05, 0.11, 8), mat(0xff8c42, { emissive: 0xff7422, emissiveIntensity: 2.4, rough: .4 }));
flame.position.y = 0.17;
g.add(flame);
break;
}
case 'scroll': {
const roll = new THREE.Mesh(cyl(0.045, 0.045, 0.2, 8), mat(C, { rough: .95 }));
roll.rotation.z = Math.PI / 2;
g.add(roll);
const end1 = new THREE.Mesh(sph(0.048, 8), mat(0xd9c9a8));
end1.position.x = 0.1; g.add(end1);
const end2 = end1.clone(); end2.position.x = -0.1; g.add(end2);
break;
}
case 'compass': {
const body = new THREE.Mesh(cyl(0.09, 0.09, 0.05, 14), mat(C, { metal: .75, rough: .35 }));
g.add(body);
const face = new THREE.Mesh(cyl(0.075, 0.075, 0.052, 14), mat(0xf6ead2));
g.add(face);
const needle = new THREE.Mesh(new THREE.BoxGeometry(0.1, 0.008, 0.02), mat(0xef4444, { metal: .5 }));
needle.position.y = 0.03;
g.add(needle);
break;
}
case 'charm': {
const orb = new THREE.Mesh(sph(0.08, 12), mat(C, { rough: .5, emissive: C, emissiveIntensity: 0.25 }));
g.add(orb);
const eye = new THREE.Mesh(sph(0.03, 8), mat(0xf6f6f6));
eye.position.z = 0.065;
eye.scale.z = 0.5;
g.add(eye);
const pupil = new THREE.Mesh(sph(0.015, 6), mat(0x2b3a67));
pupil.position.z = 0.085;
g.add(pupil);
break;
}
case 'crystal': {
const cr = new THREE.Mesh(cache('crystal', () => new THREE.OctahedronGeometry(0.11)), mat(C, { rough: .25, emissive: C, emissiveIntensity: 0.85 }));
cr.scale.y = 1.5;
g.add(cr);
const cr2 = new THREE.Mesh(cache('crystal', () => new THREE.OctahedronGeometry(0.06)), mat(C, { rough: .25, emissive: C, emissiveIntensity: 0.7 }));
cr2.scale.y = 1.4;
cr2.position.set(0.09, -0.03, 0.02);
cr2.rotation.z = 0.4;
g.add(cr2);
break;
}
case 'book': {
const cover = new THREE.Mesh(new THREE.BoxGeometry(0.17, 0.05, 0.22), mat(C, { rough: .7 }));
cover.position.y = 0.03;
g.add(cover);
const pages = new THREE.Mesh(new THREE.BoxGeometry(0.155, 0.035, 0.205), mat(0xf3e9cf));
pages.position.y = 0.032;
g.add(pages);
const rune = new THREE.Mesh(new THREE.TorusGeometry(0.03, 0.008, 5, 12), mat(0xffd166, { emissive: 0xffd166, emissiveIntensity: 0.8 }));
rune.position.set(0, 0.058, 0);
rune.rotation.x = Math.PI / 2;
g.add(rune);
break;
}
case 'scale': {
const sc = new THREE.Mesh(cache('scale', () => new THREE.SphereGeometry(0.11, 8, 6)), mat(C, { rough: .35, metal: .25, emissive: C, emissiveIntensity: 0.4 }));
sc.scale.set(1, 0.5, 1.2);
sc.rotation.x = 0.35;
g.add(sc);
break;
}
case 'ring': {
const band = new THREE.Mesh(new THREE.TorusGeometry(0.07, 0.02, 8, 16), mat(C, { metal: .85, rough: .25 }));
band.rotation.x = Math.PI / 2.6;
band.position.y = 0.05;
g.add(band);
const gem = new THREE.Mesh(new THREE.OctahedronGeometry(0.035), mat(0xff6b8a, { emissive: 0xff6b8a, emissiveIntensity: 1.1, rough: .2 }));
gem.position.y = 0.11;
g.add(gem);
break;
}
case 'bottle': {
const body = new THREE.Mesh(cyl(0.07, 0.07, 0.18, 10), mat(C, { rough: .3, metal: .1 }));
g.add(body);
const neck = new THREE.Mesh(cyl(0.026, 0.04, 0.08, 8), mat(C, { rough: .3 }));
neck.position.y = 0.13;
g.add(neck);
const label = new THREE.Mesh(cyl(0.072, 0.072, 0.06, 10), mat(0xf3e9cf));
label.position.y = -0.01;
g.add(label);
break;
}
case 'goblet': {
const cup = new THREE.Mesh(cache('cup', () => new THREE.CylinderGeometry(0.075, 0.05, 0.09, 12)), mat(C, { metal: .85, rough: .25, emissive: 0x664400, emissiveIntensity: 0.15 }));
cup.position.y = 0.1;
g.add(cup);
const stem = new THREE.Mesh(cyl(0.016, 0.02, 0.08, 8), mat(C, { metal: .85, rough: .25 }));
stem.position.y = 0.03;
g.add(stem);
const foot = new THREE.Mesh(cyl(0.06, 0.06, 0.02, 12), mat(C, { metal: .85, rough: .25 }));
foot.position.y = -0.01;
g.add(foot);
break;
}
default: {
const bx = new THREE.Mesh(new THREE.BoxGeometry(0.14, 0.14, 0.14), mat(C, { rough: .8 }));
g.add(bx);
}
}
g.traverse(o => { if (o.isMesh) { o.castShadow = true; } });
g.scale.setScalar(scale);
return g;
}
function coneGeo(r, h, seg = 8) {
return cache(`cone${r}${h}${seg}`, () => new THREE.ConeGeometry(r, h, seg));
}
+413
View File
@@ -0,0 +1,413 @@
import * as THREE from 'three';
import { scene, mat, cyl, sph, camera } from './engine.js';
import { setChimneyPos, registerGlowLight } from './world.js';
import G from '../sim/state.js';
import { clamp, lerp } from '../util.js';
// ============================================================
// SHOP BUILDING — dollhouse-style shell with smart cutaway,
// timber-framed walls, cozy windows, swinging sign, glowing door
// ============================================================
export const shopRig = {
group: null,
occluders: [], // {mesh, normal, mats}
doorPivot: null,
signPivot: null,
lanterns: [],
level: 0,
};
const WALL_H = 3.05;
export function buildShop(level, shopName) {
if (shopRig.group) { scene.remove(shopRig.group); disposeDeep(shopRig.group); }
const g = new THREE.Group();
shopRig.group = g;
shopRig.occluders = [];
shopRig.level = level;
// grid dims
const W = [10, 12, 14, 15, 17, 19, 21, 23][level - 1] || 10;
const H = [7, 8, 9, 10, 11, 12, 13, 14][level - 1] || 7;
const fw = W, fd = H; // interior footprint
const th = 0.28; // wall thickness
// ---------- FLOOR ----------
{
const tex = plankTexture();
tex.repeat.set(fw / 2.2, fd / 2.2);
const floor = new THREE.Mesh(
new THREE.BoxGeometry(fw + th * 2, 0.24, fd + th * 2),
new THREE.MeshStandardMaterial({ map: tex, roughness: .8 })
);
floor.position.y = -0.12;
floor.receiveShadow = true;
g.add(floor);
// stone foundation skirt
const skirt = new THREE.Mesh(new THREE.BoxGeometry(fw + th * 2 + 0.5, 0.5, fd + th * 2 + 0.5), mat(0x9a938a, { rough: 1 }));
skirt.position.y = -0.42;
skirt.receiveShadow = true;
g.add(skirt);
}
// ---------- WALLS ----------
const plaster = 0xf6ead2, beam = 0x7c5433;
const wallDefs = [
{ id: 'north', pos: [0, 0, -(fd / 2 + th / 2)], size: [fw + th * 2, WALL_H, th], normal: new THREE.Vector3(0, 0, -1) },
{ id: 'west', pos: [-(fw / 2 + th / 2), 0, 0], size: [th, WALL_H, fd], normal: new THREE.Vector3(-1, 0, 0) },
{ id: 'east', pos: [(fw / 2 + th / 2), 0, 0], size: [th, WALL_H, fd], normal: new THREE.Vector3(1, 0, 0) },
];
for (const wd of wallDefs) {
const seg = buildTimberWall(wd.size[0], wd.size[1], wd.size[2], plaster, beam);
seg.position.set(...wd.pos);
g.add(seg);
shopRig.occluders.push({ mesh: seg, normal: wd.normal });
}
// south wall with door gap (two segments + lintel)
const doorW = 2.2, segW = (fw + th * 2 - doorW) / 2;
for (const sgn of [-1, 1]) {
const seg = buildTimberWall(segW, WALL_H, th, plaster, beam);
seg.position.set(sgn * (doorW / 2 + segW / 2), 0, fd / 2 + th / 2);
g.add(seg);
shopRig.occluders.push({ mesh: seg, normal: new THREE.Vector3(0, 0, 1) });
}
{
const lintel = buildTimberWall(doorW, WALL_H - 2.4, th, plaster, beam);
lintel.position.set(0, 2.4 + (WALL_H - 2.4) / 2, fd / 2 + th / 2);
g.add(lintel);
shopRig.occluders.push({ mesh: lintel, normal: new THREE.Vector3(0, 0, 1) });
}
// ---------- DOOR ----------
{
const pivot = new THREE.Group();
pivot.position.set(-doorW / 2 + 0.08, 0, fd / 2 + 0.06);
const doorMat = new THREE.MeshStandardMaterial({ color: 0x8a5a33, roughness: .75 });
const panel = new THREE.Mesh(new THREE.BoxGeometry(doorW - 0.16, 2.36, 0.09), doorMat);
panel.position.set((doorW - 0.16) / 2, 1.18, 0);
panel.castShadow = true;
pivot.add(panel);
// round window in door
const win = new THREE.Mesh(new THREE.CylinderGeometry(0.32, 0.32, 0.1, 14),
new THREE.MeshStandardMaterial({ color: 0xfff2cc, emissive: 0xffdf9e, emissiveIntensity: 0.4, roughness: .3 }));
win.rotation.x = Math.PI / 2;
win.position.set((doorW - 0.16) / 2, 1.62, 0.03);
pivot.add(win);
// handle
const knob = new THREE.Mesh(sph(0.06, 8), mat(0xffd166, { metal: .7, rough: .35 }));
knob.position.set(doorW - 0.45, 1.1, 0.09);
pivot.add(knob);
g.add(pivot);
shopRig.doorPivot = pivot;
}
// ---------- WINDOWS (south face, flanking door) ----------
const winY = 1.55;
for (const sgn of [-1, 1]) {
const wx = sgn * (doorW / 2 + segW / 2);
buildWindow(g, wx, winY, fd / 2 + th / 2, 0, Math.min(segW * 0.55, 1.5));
buildWindow(g, sgn * (fw / 2 - 0.05), winY, 0, Math.PI / 2, 1.4, true); // side windows
}
// ---------- ROOF ----------
{
const over = 0.75;
const rw = fw + th * 2 + over * 2, rd = fd + th * 2 + over * 2;
const roofH = 1.5 + level * 0.12;
const roofC = [0xc95f4a, 0xc95f4a, 0xb5679e, 0x7d6bb5, 0x5f8fc2, 0xd0894a, 0xc95f4a, 0x8a6db0][level - 1];
const roofMats = [];
const shape = new THREE.Shape();
shape.moveTo(-rd / 2, 0); shape.lineTo(0, roofH); shape.lineTo(rd / 2, 0); shape.closePath();
const geo = new THREE.ExtrudeGeometry(shape, { depth: rw, bevelEnabled: false });
geo.translate(0, 0, -rw / 2);
const rm = new THREE.MeshStandardMaterial({ color: roofC, roughness: .85, transparent: true });
roofMats.push(rm);
const roof = new THREE.Mesh(geo, rm);
roof.rotation.y = Math.PI / 2;
roof.position.y = WALL_H;
roof.castShadow = true;
roof.receiveShadow = true;
g.add(roof);
// ridge beam
const ridge = new THREE.Mesh(new THREE.BoxGeometry(rw + 0.3, 0.16, 0.3), new THREE.MeshStandardMaterial({ color: 0x6b4a2c, roughness: .85, transparent: true }));
ridge.position.y = WALL_H + roofH;
roofMats.push(ridge.material);
g.add(ridge);
shopRig.occluders.push({ mesh: roof, normal: new THREE.Vector3(0, 1, 0), mats: roofMats });
shopRig.occluders.push({ mesh: ridge, normal: new THREE.Vector3(0, 1, 0), mats: [ridge.material] });
// chimney
const chH = 1.4 + roofH;
const chim = new THREE.Mesh(new THREE.BoxGeometry(0.65, chH, 0.65), mat(0xb0a494, { rough: 1 }));
chim.position.set(fw / 2 - 1.6, WALL_H + chH / 2 - 0.6, -fd / 4);
chim.castShadow = true;
g.add(chim);
const pot = new THREE.Mesh(cyl(0.34, 0.42, 0.4, 10), mat(0x8a5a33));
pot.position.set(fw / 2 - 1.6, WALL_H + chH - 0.6 + 0.2, -fd / 4);
g.add(pot);
setChimneyPos(new THREE.Vector3(fw / 2 - 1.6, WALL_H + chH - 0.2, -fd / 4));
// upper-floor look for big shops
if (level >= 4) {
const band = new THREE.Mesh(new THREE.BoxGeometry(fw + 0.5, 0.22, fd + 0.5), mat(beam, { rough: .9 }));
band.position.y = WALL_H + 0.1;
g.add(band);
const dormer = new THREE.Mesh(new THREE.BoxGeometry(1.6, 0.9, 0.8), mat(plaster));
dormer.position.set(0, WALL_H + roofH * 0.35, fd / 2 - 0.6);
const dwin = new THREE.Mesh(new THREE.BoxGeometry(0.8, 0.6, 0.06), glowWindowMat());
dwin.position.set(0, WALL_H + roofH * 0.35, fd / 2 - 0.15);
g.add(dormer, dwin);
}
if (level >= 7) {
// festive flags along the ridge
for (let i = -2; i <= 2; i++) {
const flagP = new THREE.Mesh(cyl(0.02, 0.02, 0.7, 5), mat(0x6b4a2c));
flagP.position.set(i * 2.2, WALL_H + roofH + 0.4, 0);
g.add(flagP);
const flag = new THREE.Mesh(new THREE.PlaneGeometry(0.5, 0.34),
new THREE.MeshStandardMaterial({ color: [0xef6461, 0xffd166, 0x58c98b, 0x5aa9e6, 0xef8fb5][(i + 2) % 5], side: THREE.DoubleSide, roughness: .9 }));
flag.position.set(i * 2.2 + 0.26, WALL_H + roofH + 0.72, 0);
g.add(flag);
}
}
}
// ---------- HANGING SIGN ----------
{
const bracket = new THREE.Mesh(new THREE.BoxGeometry(1.5, 0.09, 0.09), mat(0x6b4a2c, { rough: .9 }));
bracket.position.set(-fw / 2 - 0.4, 2.75, fd / 2 + 0.7);
g.add(bracket);
const arm = new THREE.Mesh(new THREE.BoxGeometry(0.09, 0.7, 0.09), mat(0x6b4a2c, { rough: .9 }));
arm.position.set(-fw / 2 - 1.05, 2.75, fd / 2 + 0.7);
g.add(arm);
const sp = new THREE.Group();
sp.position.set(-fw / 2 - 1.05, 2.68, fd / 2 + 0.7);
const board = new THREE.Mesh(new THREE.BoxGeometry(1.7, 1.0, 0.07), mat(0x8a5a33, { rough: .8 }));
board.rotation.x = Math.PI / 2;
board.castShadow = true;
sp.add(board);
const nameTex = signTexture(shopName || 'Tiny Shop');
const face = new THREE.Mesh(new THREE.PlaneGeometry(1.58, 0.88),
new THREE.MeshBasicMaterial({ map: nameTex, transparent: false }));
face.rotation.x = -Math.PI / 2;
face.position.y = -0.045;
sp.add(face);
g.add(sp);
shopRig.signPivot = sp;
}
// ---------- DOOR LANTERNS ----------
for (const sgn of [-1, 1]) {
const lx = sgn * (doorW / 2 + 0.55);
const orb = new THREE.Mesh(sph(0.14, 10),
new THREE.MeshStandardMaterial({ color: 0xfff1c4, emissive: 0xffc95e, emissiveIntensity: 0, roughness: .3 }));
orb.position.set(lx, 2.5, fd / 2 + 0.35);
g.add(orb);
const cage = new THREE.Mesh(new THREE.CylinderGeometry(0.18, 0.18, 0.3, 6, 1, true), mat(0x4a4038, { metal: .4, rough: .5 }));
cage.position.copy(orb.position);
g.add(cage);
const light = new THREE.PointLight(0xffc46b, 0, 7, 2);
light.position.set(lx, 2.45, fd / 2 + 0.5);
g.add(light);
registerGlowLight(light, 7, orb);
}
// flower boxes under the south windows
for (const sgn of [-1, 1]) {
const bx = sgn * (doorW / 2 + segW / 2);
const box = new THREE.Mesh(new THREE.BoxGeometry(1.7, 0.3, 0.34), mat(0x7c5433, { rough: .95 }));
box.position.set(bx, 0.95, fd / 2 + 0.42);
box.castShadow = true;
g.add(box);
for (let i = 0; i < 6; i++) {
const fl = new THREE.Mesh(sph(0.09, 7), mat([0xef8fb5, 0xffd166, 0xef6461, 0xf6f6f6][i % 4], { rough: .8 }));
fl.position.set(bx - 0.7 + i * 0.28, 1.16 + rand() * 0.05, fd / 2 + 0.42 + rand(-0.05, 0.05));
g.add(fl);
}
}
// welcome mat
const matMesh = new THREE.Mesh(new THREE.BoxGeometry(1.7, 0.04, 0.8), mat(0xa9713d, { rough: 1 }));
matMesh.position.set(0, 0.03, fd / 2 + 0.75);
g.add(matMesh);
scene.add(g);
// interior ambience point light (warm center)
const inner = new THREE.PointLight(0xffd9a0, 0, Math.max(fw, fd) * 1.6, 1.6);
inner.position.set(0, 2.6, 0);
g.add(inner);
registerGlowLight(inner, 10, null);
}
function rand() { return Math.random(); }
function glowWindowMat() {
return new THREE.MeshStandardMaterial({ color: 0xfff6d8, emissive: 0xffd98a, emissiveIntensity: 0, roughness: .4 });
}
function buildTimberWall(w, h, d, plasterC, beamC) {
const grp = new THREE.Group();
const pm = new THREE.MeshStandardMaterial({ color: plasterC, roughness: .92, transparent: true });
const bm = new THREE.MeshStandardMaterial({ color: beamC, roughness: .88, transparent: true });
const core2 = new THREE.Mesh(new THREE.BoxGeometry(w, h, d), pm);
core2.castShadow = true; core2.receiveShadow = true;
grp.add(core2);
const beamList = [
[w, 0.14, d * 1.04, 0, h - 0.07, 0],
[w, 0.14, d * 1.04, 0, 0.07, 0],
[0.14, h, d * 1.04, -w / 2 + 0.07, h / 2, 0],
[0.14, h, d * 1.04, w / 2 - 0.07, h / 2, 0],
];
if (w > 2.4) beamList.push([0.14, h, d * 1.04, 0, h / 2, 0]);
if (w > 4.5) {
beamList.push([0.14, h, d * 1.04, -w / 4, h / 2, 0]);
beamList.push([0.14, h, d * 1.04, w / 4, h / 2, 0]);
}
for (const [bx, bh, bd, px, py, pz] of beamList) {
const b = new THREE.Mesh(new THREE.BoxGeometry(bx, bh, bd), bm);
b.position.set(px, py, pz);
b.castShadow = true;
grp.add(b);
}
grp.userData.mats = [pm, bm];
return grp;
}
function buildWindow(parent, x, y, z, rotY, w, side = false) {
const wm = glowWindowMat();
const grp = new THREE.Group();
const glass = new THREE.Mesh(new THREE.BoxGeometry(w, 1.05, 0.06), wm);
grp.add(glass);
const frameM = mat(0xf6ead2, { rough: .9 });
const fr = [
new THREE.Mesh(new THREE.BoxGeometry(w + 0.16, 0.12, 0.12), frameM),
new THREE.Mesh(new THREE.BoxGeometry(w + 0.16, 0.12, 0.12), frameM),
new THREE.Mesh(new THREE.BoxGeometry(0.12, 1.2, 0.12), frameM),
new THREE.Mesh(new THREE.BoxGeometry(0.12, 1.2, 0.12), frameM),
new THREE.Mesh(new THREE.BoxGeometry(w, 0.06, 0.08), frameM),
new THREE.Mesh(new THREE.BoxGeometry(0.06, 1.05, 0.08), frameM),
];
fr[0].position.y = 0.57; fr[1].position.y = -0.57;
fr[2].position.x = -w / 2; fr[3].position.x = w / 2;
fr.forEach(m => grp.add(m));
// shutters
for (const sgn of [-1, 1]) {
const sh = new THREE.Mesh(new THREE.BoxGeometry(w / 2 + 0.06, 1.15, 0.05), mat(0x5f8fc2, { rough: .9 }));
sh.position.set(sgn * (w / 2 + w / 4 + 0.08), 0, -0.01);
sh.userData.shutter = true;
grp.add(sh);
}
grp.position.set(x, y, z);
grp.rotation.y = rotY;
parent.add(grp);
}
function plankTexture() {
const cv = document.createElement('canvas');
cv.width = cv.height = 256;
const ctx = cv.getContext('2d');
ctx.fillStyle = '#a9713d'; ctx.fillRect(0, 0, 256, 256);
const tones = ['#b07a44', '#a26a37', '#b9814c', '#9c6432'];
for (let row = 0; row < 8; row++) {
ctx.fillStyle = tones[row % tones.length];
ctx.fillRect(0, row * 32, 256, 30);
// grain streaks
ctx.fillStyle = 'rgba(90,55,20,0.12)';
for (let i = 0; i < 5; i++) {
const gy = row * 32 + 4 + ((i * 53 + row * 17) % 24);
ctx.fillRect((i * 61 + row * 29) % 200, gy, 60 + (i * 31) % 80, 2);
}
ctx.fillStyle = 'rgba(70,40,12,0.35)';
ctx.fillRect(0, row * 32 + 30, 256, 2);
}
const tex = new THREE.CanvasTexture(cv);
tex.wrapS = tex.wrapT = THREE.RepeatWrapping;
tex.colorSpace = THREE.SRGBColorSpace;
return tex;
}
function signTexture(name) {
const cv = document.createElement('canvas');
cv.width = 512; cv.height = 288;
const ctx = cv.getContext('2d');
ctx.fillStyle = '#8a5a33';
roundRectPath(ctx, 8, 8, 496, 272, 40);
ctx.fill();
ctx.strokeStyle = '#ffd166';
ctx.lineWidth = 10;
roundRectPath(ctx, 26, 26, 460, 236, 30);
ctx.stroke();
ctx.fillStyle = '#fff3dd';
ctx.font = '900 74px Nunito, sans-serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
const words = name.toUpperCase().split(' ');
if (words.length === 1) {
ctx.fillText(words[0], 256, 130);
} else {
ctx.fillText(words[0], 256, 100);
ctx.fillText(words.slice(1).join(' '), 256, 185);
}
ctx.font = '700 30px Nunito, sans-serif';
ctx.fillText('🛎️ est. today 🛎️'.replace('est. today', '~ general store ~'), 256, 245);
const tex = new THREE.CanvasTexture(cv);
tex.colorSpace = THREE.SRGBColorSpace;
return tex;
}
function roundRectPath(ctx, x, y, w, h, r) {
ctx.beginPath();
ctx.arcTo(x + w, y, x + w, y + h, r);
ctx.arcTo(x + w, y + h, x, y + h, r);
ctx.arcTo(x, y + h, x, y, r);
ctx.arcTo(x, y, x + w, y, r);
ctx.closePath();
}
// ---------------- per-frame updates ----------------
export function updateShopVisuals(dt, elapsed) {
if (!shopRig.group) return;
const cam = camera;
// door swing
const wantOpen = G?.data?.shop?.isOpen ?? false;
const p = shopRig.doorPivot;
if (p) {
p.userData.a = lerp(p.userData.a ?? 0, wantOpen ? 1.9 : 0, dt * 4);
p.rotation.y = -p.userData.a;
}
// sign sway
if (shopRig.signPivot) {
shopRig.signPivot.rotation.z = Math.sin(elapsed * 1.3) * 0.06;
}
// dollhouse cutaway: hide occluders facing the camera
const camPos = cam.position;
const target = cam.userData.lookTarget || camPos.clone().setY(0);
const v = new THREE.Vector3();
for (const occ of shopRig.occluders) {
v.subVectors(camPos, target).normalize();
const facing = v.dot(occ.normal);
// if camera sits on the outward side of this surface → fade it out
const goal = facing > 0.25 ? 0 : 1;
const mats = occ.mats || occ.mesh.userData.mats || collectMats(occ.mesh);
occ._op = lerp(occ._op ?? 1, goal, dt * 6);
for (const m of mats) {
m.transparent = true;
m.depthWrite = occ._op > 0.6;
m.opacity = occ._op;
}
occ.mesh.visible = occ._op > 0.02;
}
}
function collectMats(mesh) {
const arr = [];
mesh.traverse?.(o => {
if (o.material && !arr.includes(o.material)) arr.push(o.material);
});
return arr;
}
function disposeDeep(obj) {
obj.traverse(o => { o.geometry?.dispose(); });
}
+615
View File
@@ -0,0 +1,615 @@
import * as THREE from 'three';
import { scene, mat, basicMat, cyl, sph } from './engine.js';
import G from '../sim/state.js';
import { clamp, lerp, rand, randi, chance, emit, on } from '../util.js';
// ============================================================
// WORLD — sky, sun/moon cycle, weather, town backdrop
// ============================================================
export const env = {
daylight: 1, night: 0,
rainTarget: 0, rainAmt: 0,
snowTarget: 0, snowAmt: 0,
cloudTarget: 0.25, cloudAmt: 0.25,
windowGlow: 0, // 0..1 how much windows should glow
festival: false,
};
let sunLight, moonLight, hemiLight, skyDome, stars;
let rainPoints, snowPoints, cloudsGroup, fireflies;
const interiorLamps = []; // {light, base}
export const windowMats = new Set();
let smokeSprites = [], chimneyPos = new THREE.Vector3(0, 7, -3);
let townGroup = null;
// ---------------- palette keyframes over the day ----------------
const SKY_STOPS = [ // [minute, zenith, horizon]
[0, 0x101a33, 0x1a2340],
[360, 0x2c3e66, 0x8a5a6e],
[430, 0x7fb2d9, 0xffd9a3], // dawn
[600, 0x8ecdea, 0xd8ecf7], // morning
[780, 0x9fd4f2, 0xeaf6fb], // noon
[1020, 0x86b8e0, 0xf7cf9b], // afternoon
[1140, 0x5f7bb0, 0xff9e5e], // sunset
[1230, 0x27355e, 0x54406e], // dusk
[1440, 0x101a33, 0x1a2340],
];
const SUN_COLOR = new THREE.Color();
const cA = new THREE.Color(), cB = new THREE.Color();
function skyColorsAt(min) {
const m = ((min % 1440) + 1440) % 1440;
let i = 0;
while (i < SKY_STOPS.length - 1 && SKY_STOPS[i + 1][0] < m) i++;
const a = SKY_STOPS[i], b = SKY_STOPS[Math.min(i + 1, SKY_STOPS.length - 1)];
const t = clamp((m - a[0]) / Math.max(1, b[0] - a[0]), 0, 1);
return [cA.setHex(a[1]).clone().lerp(cB.setHex(b[1]), t), cA.setHex(a[2]).clone().lerp(cB.setHex(b[2]), t)];
}
// ============================================================
export function buildWorld() {
// --- lights ---
hemiLight = new THREE.HemisphereLight(0xbfd9f2, 0x8a9a5c, 0.85);
scene.add(hemiLight);
sunLight = new THREE.DirectionalLight(0xfff2dd, 2.2);
sunLight.castShadow = true;
sunLight.shadow.mapSize.set(2048, 2048);
const sc = sunLight.shadow.camera;
sc.left = -20; sc.right = 20; sc.top = 20; sc.bottom = -20; sc.near = 1; sc.far = 90;
sunLight.shadow.bias = -0.0004;
sunLight.shadow.radius = 4;
scene.add(sunLight);
scene.add(sunLight.target);
moonLight = new THREE.DirectionalLight(0x8fa8ff, 0);
scene.add(moonLight);
// --- sky dome ---
const skyGeo = new THREE.SphereGeometry(160, 24, 16);
const skyMat = new THREE.ShaderMaterial({
side: THREE.BackSide, depthWrite: false,
uniforms: {
top: { value: new THREE.Color(0x8ecdea) },
bottom: { value: new THREE.Color(0xeaf6fb) },
},
vertexShader: `varying vec3 vP; void main(){ vP=position; gl_Position=projectionMatrix*modelViewMatrix*vec4(position,1.0);}`,
fragmentShader: `uniform vec3 top; uniform vec3 bottom; varying vec3 vP;
void main(){ float h=normalize(vP).y*.5+.5; gl_FragColor=vec4(mix(bottom,top,pow(h,.8)),1.0);} `,
});
skyDome = new THREE.Mesh(skyGeo, skyMat);
scene.add(skyDome);
scene.fog = new THREE.Fog(0xd8ecf7, 55, 150);
// --- stars ---
{
const n = 350, pos = new Float32Array(n * 3);
for (let i = 0; i < n; i++) {
const th = rand(0, Math.PI * 2), ph = rand(0.12, Math.PI / 2.4), r = 150;
pos[i * 3] = r * Math.cos(th) * Math.sin(ph);
pos[i * 3 + 1] = r * Math.cos(ph);
pos[i * 3 + 2] = r * Math.sin(th) * Math.sin(ph);
}
const g = new THREE.BufferGeometry();
g.setAttribute('position', new THREE.BufferAttribute(pos, 3));
stars = new THREE.Points(g, new THREE.PointsMaterial({ color: 0xffffff, size: 0.7, transparent: true, opacity: 0, fog: false, depthWrite: false }));
scene.add(stars);
}
buildGround();
buildPlaza();
buildTreesAndFlowers();
buildClouds();
buildPrecip();
buildFireflies();
rebuildTown(0);
buildSmoke();
on('townChanged', () => rebuildTown(G?.data?.town?.prosperity ?? 0));
}
function grassMat() { return mat(0x79b857, { rough: 1 }); }
function buildGround() {
const g = new THREE.CircleGeometry(90, 48);
g.rotateX(-Math.PI / 2);
const ground = new THREE.Mesh(g, mat(0x7cb95a, { rough: 1 }));
ground.position.y = -0.02;
ground.receiveShadow = true;
scene.add(ground);
// gentle hills on the horizon
const hillMat = mat(0x69a84f, { rough: 1 });
const hillMatFar = mat(0x5c9862, { rough: 1 });
for (let i = 0; i < 10; i++) {
const ang = (i / 10) * Math.PI * 2 + rand(-0.2, 0.2);
const dist = rand(62, 82);
const h = new THREE.Mesh(sph(rand(9, 17)), i % 2 ? hillMat : hillMatFar);
h.scale.y = rand(0.28, 0.45);
h.position.set(Math.cos(ang) * dist, -1.5, Math.sin(ang) * dist);
scene.add(h);
}
}
function cobbleTexture() {
const cv = document.createElement('canvas');
cv.width = cv.height = 128;
const ctx = cv.getContext('2d');
ctx.fillStyle = '#b8a894'; ctx.fillRect(0, 0, 128, 128);
const tones = ['#c7b8a4', '#baaa96', '#cdbfaa', '#b3a290'];
for (let y = 0; y < 4; y++) for (let x = 0; x < 4; x++) {
ctx.fillStyle = tones[(x + y * 2) % 4];
const px = x * 32 + (y % 2 ? 16 : 0), py = y * 32;
roundRect(ctx, px + 2, py + 2, 28, 28, 9);
ctx.fill();
}
const tex = new THREE.CanvasTexture(cv);
tex.wrapS = tex.wrapT = THREE.RepeatWrapping;
tex.colorSpace = THREE.SRGBColorSpace;
return tex;
}
function roundRect(ctx, x, y, w, h, r) {
ctx.beginPath();
ctx.moveTo(x + r, y);
ctx.arcTo(x + w, y, x + w, y + h, r);
ctx.arcTo(x + w, y + h, x, y + h, r);
ctx.arcTo(x, y + h, x, y, r);
ctx.arcTo(x, y, x + w, y, r);
ctx.closePath();
}
function buildPlaza() {
const tex = cobbleTexture();
tex.repeat.set(6, 6);
const plaza = new THREE.Mesh(new THREE.CircleGeometry(13, 36), new THREE.MeshStandardMaterial({ map: tex, roughness: .95 }));
plaza.rotateX(-Math.PI / 2);
plaza.position.set(0, 0.005, 14);
plaza.receiveShadow = true;
scene.add(plaza);
// path from shop door to plaza
const pathTex = cobbleTexture();
pathTex.repeat.set(1.5, 5);
const path = new THREE.Mesh(new THREE.PlaneGeometry(3.2, 11), new THREE.MeshStandardMaterial({ map: pathTex, roughness: .95 }));
path.rotateX(-Math.PI / 2);
path.position.set(0, 0.006, 8.5);
path.receiveShadow = true;
scene.add(path);
// fountain
const f = new THREE.Group();
const stone = mat(0xa8adb5, { rough: .9 });
f.add((()=>{ const m = new THREE.Mesh(cyl(2.6, 2.9, 0.7, 20), stone); m.position.set(0, 0.35, 0); m.castShadow = true; m.receiveShadow = true; return m; })());
f.add((()=>{ const m = new THREE.Mesh(cyl(2.3, 2.3, 0.25, 20), mat(0x63b7d4, { rough: .2, metal: .1 })); m.position.set(0, 0.68, 0); return m; })());
f.add((()=>{ const m = new THREE.Mesh(cyl(0.28, 0.42, 1.6, 10), stone); m.position.set(0, 1.2, 0); m.castShadow = true; return m; })());
f.add((()=>{ const m = new THREE.Mesh(cyl(0.9, 1.1, 0.3, 14), stone); m.position.set(0, 2.0, 0); m.castShadow = true; return m; })());
const water = new THREE.Mesh(cyl(0.72, 0.72, 0.12, 12), mat(0x7fd4ea, { rough: .15 }));
water.position.set(0, 2.2, 0);
f.add(water);
f.position.set(0, 0, 14);
scene.add(f);
// lampposts
for (const [lx, lz] of [[-6.5, 9.5], [6.5, 9.5], [-9.5, 16], [9.5, 16]]) buildLamp(lx, lz);
// market stalls
buildStall(-7.5, 17.5, 0.5);
buildStall(7.5, 17.5, -0.4);
}
function buildLamp(x, z) {
const g = new THREE.Group();
const iron = mat(0x4a4038, { rough: .6, metal: .3 });
g.add((()=>{ const m = new THREE.Mesh(cyl(0.07, 0.09, 2.6, 8), iron); m.position.set(0, 1.3, 0); m.castShadow = true; return m; })());
g.add((()=>{ const m = new THREE.Mesh(sph(0.16, 10), iron); m.position.set(0, 2.65, 0); return m; })());
const orb = new THREE.Mesh(sph(0.19, 10), new THREE.MeshStandardMaterial({ color: 0xfff1c4, emissive: 0xffc95e, emissiveIntensity: 0, roughness: .3 }));
orb.position.set(0, 2.62, 0);
g.add(orb);
g.add((()=>{ const m = new THREE.Mesh(new THREE.ConeGeometry(0.3, 0.3, 8), iron); m.position.set(0, 2.92, 0); return m; })());
const light = new THREE.PointLight(0xffc46b, 0, 9, 2);
light.position.set(0, 2.55, 0);
g.add(light);
g.position.set(x, 0, z);
scene.add(g);
interiorLamps.push({ light, base: 14, orb });
}
function stripeTexture(c1, c2) {
const cv = document.createElement('canvas'); cv.width = 64; cv.height = 64;
const ctx = cv.getContext('2d');
for (let i = 0; i < 8; i++) { ctx.fillStyle = i % 2 ? c1 : c2; ctx.fillRect(i * 8, 0, 8, 64); }
const t = new THREE.CanvasTexture(cv);
t.colorSpace = THREE.SRGBColorSpace;
return t;
}
function buildStall(x, z, rot) {
const g = new THREE.Group();
const wood = mat(0x9a6b3f, { rough: .9 });
g.add((()=>{ const m = new THREE.Mesh(new THREE.BoxGeometry(2.2, 0.12, 1.1), wood); m.position.set(0, 0.85, 0); m.castShadow = true; return m; })());
for (const [px, pz] of [[-1, -0.45], [1, -0.45], [-1, 0.45], [1, 0.45]])
g.add((()=>{ const m = new THREE.Mesh(cyl(0.06, 0.06, 1.9, 6), wood); m.position.set(px, 0.95, pz); m.castShadow = true; return m; })());
const awning = new THREE.Mesh(new THREE.PlaneGeometry(2.5, 1.6),
new THREE.MeshStandardMaterial({ map: stripeTexture('#e05f5f', '#fff3df'), roughness: .9, side: THREE.DoubleSide }));
awning.position.set(0, 2.05, 0.1);
awning.rotation.x = -0.35;
awning.castShadow = true;
g.add(awning);
// goods on the table
for (let i = 0; i < 5; i++) {
const b = new THREE.Mesh(sph(rand(.09, .14)), mat(chance(.5) ? 0xe0483e : 0xf0c34e));
b.position.set(rand(-0.9, 0.9), 1.0, rand(-0.3, 0.3));
b.castShadow = true;
g.add(b);
}
g.position.set(x, 0, z);
g.rotation.y = rot;
scene.add(g);
}
function buildTreesAndFlowers() {
const trunkM = mat(0x7c5433, { rough: 1 });
const leafMs = [mat(0x5da24b, { rough: 1 }), mat(0x6cb754, { rough: 1 }), mat(0x4f9440, { rough: 1 })];
const spots = [];
for (let i = 0; i < 16; i++) {
const ang = rand(0, Math.PI * 2);
const dist = rand(17, 38);
const x = Math.cos(ang) * dist, z = Math.sin(ang) * dist + 6;
if (Math.abs(z) < 9 && Math.abs(x) < 12) continue; // keep the shop plot clear
if (z > 8 && Math.hypot(x, z - 14) < 12) continue; // plaza clear
spots.push([x, z]);
}
for (const [x, z] of spots) {
const t = new THREE.Group();
const h = rand(1.4, 2.2);
t.add((()=>{ const m = new THREE.Mesh(cyl(0.14, 0.2, h, 7), trunkM); m.position.set(0, h / 2, 0); m.castShadow = true; return m; })());
const lm = leafMs[randi(0, 2)];
const blobs = randi(2, 3);
for (let i = 0; i < blobs; i++) {
const b = new THREE.Mesh(sph(rand(0.55, 0.95)), lm);
b.position.set(rand(-0.35, 0.35), h + rand(-0.1, 0.5), rand(-0.35, 0.35));
b.scale.y = rand(0.75, 0.95);
b.castShadow = true;
t.add(b);
}
t.position.set(x, 0, z);
scene.add(t);
}
// flowers — instanced
const n = 240;
const geo = new THREE.SphereGeometry(0.055, 6, 5);
const im = new THREE.InstancedMesh(geo, new THREE.MeshStandardMaterial({ roughness: .8, flatShading: true }), n);
const dummy = new THREE.Object3D();
const cols = [0xff8fb5, 0xffd166, 0xef6461, 0xc39bf0, 0xfff3df];
const col = new THREE.Color();
for (let i = 0; i < n; i++) {
const ang = rand(0, Math.PI * 2), dist = rand(10, 45);
dummy.position.set(Math.cos(ang) * dist, 0.05, Math.sin(ang) * dist + 6);
if (dummy.position.z > 7 && Math.hypot(dummy.position.x, dummy.position.z - 14) < 12.5) dummy.position.x += 14;
const s = rand(0.7, 1.4);
dummy.scale.set(s, s, s);
dummy.updateMatrix();
im.setMatrixAt(i, dummy.matrix);
im.setColorAt(i, col.setHex(cols[i % cols.length]));
}
im.instanceMatrix.needsUpdate = true;
scene.add(im);
}
function buildClouds() {
cloudsGroup = new THREE.Group();
const cm = new THREE.MeshStandardMaterial({ color: 0xffffff, roughness: 1, transparent: true, opacity: 0.88, flatShading: true });
for (let i = 0; i < 7; i++) {
const cl = new THREE.Group();
const puffs = randi(3, 5);
for (let j = 0; j < puffs; j++) {
const s = new THREE.Mesh(sph(rand(1.2, 2.6)), cm);
s.position.set(j * rand(1.2, 2) - puffs, rand(-0.4, 0.5), rand(-0.8, 0.8));
s.scale.y = rand(0.5, 0.7);
cl.add(s);
}
cl.position.set(rand(-60, 60), rand(16, 26), rand(-50, 30));
cl.userData.speed = rand(0.25, 0.7);
cloudsGroup.add(cl);
}
scene.add(cloudsGroup);
}
function precipSprite(kind) {
const cv = document.createElement('canvas'); cv.width = 8; cv.height = kind === 'rain' ? 40 : 16;
const ctx = cv.getContext('2d');
if (kind === 'rain') {
const grad = ctx.createLinearGradient(0, 0, 0, 40);
grad.addColorStop(0, 'rgba(190,220,255,0)');
grad.addColorStop(1, 'rgba(190,225,255,0.9)');
ctx.fillStyle = grad;
ctx.fillRect(3, 0, 2.4, 40);
} else {
const grad = ctx.createRadialGradient(8, 8, 0, 8, 8, 8);
grad.addColorStop(0, 'rgba(255,255,255,1)');
grad.addColorStop(1, 'rgba(255,255,255,0)');
ctx.fillStyle = grad;
ctx.fillRect(0, 0, 16, 16);
}
return new THREE.CanvasTexture(cv);
}
function buildPrecip() {
// rain
{
const n = 700, pos = new Float32Array(n * 3);
for (let i = 0; i < n; i++) resetDrop(pos, i, true);
const g = new THREE.BufferGeometry();
g.setAttribute('position', new THREE.BufferAttribute(pos, 3));
rainPoints = new THREE.Points(g, new THREE.PointsMaterial({
map: precipSprite('rain'), size: 0.55, transparent: true, opacity: 0, depthWrite: false, sizeAttenuation: true,
}));
scene.add(rainPoints);
}
// snow
{
const n = 420, pos = new Float32Array(n * 3), phase = new Float32Array(n);
for (let i = 0; i < n; i++) { resetSnow(pos, i, true); phase[i] = rand(0, 6.28); }
const g = new THREE.BufferGeometry();
g.setAttribute('position', new THREE.BufferAttribute(pos, 3));
g.setAttribute('phase', new THREE.BufferAttribute(phase, 1));
snowPoints = new THREE.Points(g, new THREE.PointsMaterial({
map: precipSprite('snow'), size: 0.28, transparent: true, opacity: 0, depthWrite: false,
}));
scene.add(snowPoints);
}
}
function resetDrop(pos, i, scatter) {
pos[i * 3] = rand(-26, 26);
pos[i * 3 + 1] = scatter ? rand(0, 16) : rand(13, 16);
pos[i * 3 + 2] = rand(-20, 24);
}
function resetSnow(pos, i, scatter) {
pos[i * 3] = rand(-24, 24);
pos[i * 3 + 1] = scatter ? rand(0, 14) : rand(12, 14);
pos[i * 3 + 2] = rand(-18, 24);
}
function buildFireflies() {
const n = 42, pos = new Float32Array(n * 3);
for (let i = 0; i < n; i++) {
pos[i * 3] = rand(-16, 16); pos[i * 3 + 1] = rand(0.4, 2.2); pos[i * 3 + 2] = rand(-4, 22);
}
const g = new THREE.BufferGeometry();
g.setAttribute('position', new THREE.BufferAttribute(pos, 3));
fireflies = new THREE.Points(g, new THREE.PointsMaterial({
color: 0xd8ff9a, size: 0.16, transparent: true, opacity: 0, depthWrite: false,
blending: THREE.AdditiveBlending,
}));
scene.add(fireflies);
}
function buildSmoke() {
const cv = document.createElement('canvas'); cv.width = cv.height = 64;
const ctx = cv.getContext('2d');
const grad = ctx.createRadialGradient(32, 32, 4, 32, 32, 30);
grad.addColorStop(0, 'rgba(245,245,245,0.55)');
grad.addColorStop(1, 'rgba(245,245,245,0)');
ctx.fillStyle = grad; ctx.fillRect(0, 0, 64, 64);
const tex = new THREE.CanvasTexture(cv);
for (let i = 0; i < 8; i++) {
const sp = new THREE.Sprite(new THREE.SpriteMaterial({ map: tex, transparent: true, opacity: 0, depthWrite: false }));
sp.userData.t = i / 8;
sp.scale.setScalar(1.2);
scene.add(sp);
smokeSprites.push(sp);
}
}
export function setChimneyPos(v3) { chimneyPos.copy(v3); }
// ---------------- TOWN BACKDROP ----------------
const HOUSE_COLORS = [0xf2d8b8, 0xe8b8a8, 0xcfe0ee, 0xf5ecc9, 0xdcd0f0, 0xf0d0da, 0xcde8d8];
const ROOF_COLORS = [0xc95f4a, 0x8a6db0, 0x5f8fc2, 0xd0894a, 0x5fa87a];
export function rebuildTown(prosperity) {
if (townGroup) { scene.remove(townGroup); disposeDeep(townGroup); }
townGroup = new THREE.Group();
const count = 6 + Math.floor(clamp(prosperity / 20, 0, 1) * 6);
const ringR = 26;
for (let i = 0; i < count; i++) {
const ang = Math.PI * (0.12 + (i / count) * 0.76) ; // arc behind & sides of plaza
const dist = ringR + rand(-2, 3);
const x = Math.cos(ang) * dist;
const z = 16 - Math.sin(ang) * dist - 6;
townGroup.add(buildHouse(x, z, i));
}
// extra far cottages for depth
for (let i = 0; i < 5; i++) {
const ang = rand(-0.4, Math.PI + 0.4);
townGroup.add(buildHouse(Math.cos(ang) * rand(38, 52), 10 - Math.sin(ang) * rand(30, 44), i + 99, 1.3));
}
scene.add(townGroup);
}
function buildHouse(x, z, seed, scale = 1) {
const g = new THREE.Group();
const w = rand(3.4, 5.2) * scale, d = rand(3, 4.4) * scale, h = rand(2.6, 3.8) * scale;
const bodyC = HOUSE_COLORS[seed % HOUSE_COLORS.length];
const roofC = ROOF_COLORS[(seed * 3 + 1) % ROOF_COLORS.length];
const body = new THREE.Mesh(new THREE.BoxGeometry(w, h, d), mat(bodyC, { rough: .95 }));
body.position.y = h / 2;
body.castShadow = true; body.receiveShadow = true;
g.add(body);
const roof = new THREE.Mesh(new THREE.ConeGeometry(Math.max(w, d) * 0.78, h * 0.7, 4), mat(roofC, { rough: .9 }));
roof.position.y = h + h * 0.33;
roof.rotation.y = Math.PI / 4;
roof.scale.z = d / w;
roof.castShadow = true;
g.add(roof);
// door + windows (glow at night)
const door = new THREE.Mesh(new THREE.BoxGeometry(0.7 * scale, 1.2 * scale, 0.08), mat(0x6b4a2c, { rough: .85 }));
door.position.set(rand(-w / 4, w / 4), 0.6 * scale, d / 2 + 0.03);
g.add(door);
const winMat = new THREE.MeshStandardMaterial({ color: 0xfff6d8, emissive: 0xffd98a, emissiveIntensity: 0, roughness: .4 });
windowMats.add(winMat);
for (const wx of [-w / 3, w / 3]) {
const win = new THREE.Mesh(new THREE.BoxGeometry(0.62, 0.62, 0.06), winMat);
win.position.set(wx, h * 0.62, d / 2 + 0.03);
g.add(win);
const frame = new THREE.Mesh(new THREE.BoxGeometry(0.74, 0.74, 0.04), mat(0x7c5a3a));
frame.position.set(wx, h * 0.62, d / 2 + 0.01);
g.add(frame);
}
// chimney
if (chance(0.7)) {
const ch = new THREE.Mesh(new THREE.BoxGeometry(0.4, 0.9, 0.4), mat(0xb0a494, { rough: 1 }));
ch.position.set(w * 0.25, h + h * 0.5, 0);
ch.castShadow = true;
g.add(ch);
}
g.position.set(x, 0, z);
g.rotation.y = Math.atan2(-x, -(z - 12)) ;
return g;
}
function disposeDeep(obj) {
obj.traverse(o => { o.geometry?.dispose(); });
}
// ============================================================
// PER-FRAME ENVIRONMENT UPDATE
// ============================================================
export function updateEnvironment(dt, elapsed) {
const minutes = G?.data ? G.shop.minutes : 600 + Math.sin(elapsed * 0.01) * 40; // menu: pleasant morning
// weather targets
if (G?.data) {
const w = G.shop.weatherId;
env.rainTarget = (w === 'rain') ? 0.75 : (w === 'storm') ? 1 : 0;
env.snowTarget = (w === 'snow') ? 1 : 0;
env.cloudTarget = (w === 'sunny') ? 0.18 : (w === 'cloudy') ? 0.55 : 0.85;
env.festival = G.isFestivalDay();
}
env.rainAmt = lerp(env.rainAmt, env.rainTarget, dt * 0.8);
env.snowAmt = lerp(env.snowAmt, env.snowTarget, dt * 0.8);
env.cloudAmt = lerp(env.cloudAmt, env.cloudTarget, dt * 0.3);
// ---- sun position ----
const m = ((minutes % 1440) + 1440) % 1440;
const dayT = clamp((m - 330) / (1260 - 330), 0, 1); // 5:30 → 21:00
const ang = dayT * Math.PI;
const sunH = Math.sin(ang);
const sunDist = 40;
const lookX = cameraRef.position.x * 0.25, lookZ = cameraRef.position.z * 0.25;
sunLight.position.set(
lookX + Math.cos(ang) * sunDist * 0.9,
Math.max(2, sunH * sunDist * 0.8),
lookZ + 14
);
sunLight.target.position.set(lookX, 0, lookZ);
const dl = clamp(sunH * 1.6, 0, 1); // daylight factor
// colors
const [top, hor] = skyColorsAt(minutes);
const grey = new THREE.Color(0x9aa4ad);
top.lerp(grey, env.cloudAmt * 0.55);
hor.lerp(grey.clone().multiplyScalar(1.1), env.cloudAmt * 0.5);
skyDome.material.uniforms.top.value.copy(top);
skyDome.material.uniforms.bottom.value.copy(hor);
scene.fog.color.copy(hor);
scene.fog.near = lerp(60, 26, env.cloudAmt * 0.6 + env.rainAmt * 0.4);
const warmNoon = new THREE.Color(0xfff3dc), warmLow = new THREE.Color(0xffb469);
SUN_COLOR.copy(warmLow).lerp(warmNoon, clamp(sunH * 1.8, 0, 1));
SUN_COLOR.lerp(new THREE.Color(0xb9c4cc), env.cloudAmt * 0.5);
sunLight.color.copy(SUN_COLOR);
sunLight.intensity = (0.25 + dl * 2.1) * (1 - env.cloudAmt * 0.55);
hemiLight.intensity = 0.25 + dl * 0.7;
hemiLight.color.copy(top).lerp(new THREE.Color(0xffffff), 0.4);
// moon
moonLight.position.set(-sunLight.position.x, Math.max(8, -sunH * 30 + 12), -10);
moonLight.intensity = (1 - dl) * 0.35 * (1 - env.cloudAmt * 0.5);
env.daylight = dl;
env.night = clamp(1 - (dl - 0.05) / 0.25, 0, 1);
// stars & window glow & lamps
stars.material.opacity = env.night * 0.9;
const glowBase = env.night * (G?.data ? 1 : 0.8);
env.windowGlow = clamp(glowBase + (G?.data?.shop.isOpen ? 0.25 : 0), 0, 1);
for (const wm of windowMats) wm.emissiveIntensity = env.windowGlow * 1.6;
const lampOn = env.night > 0.35 ? 1 : 0;
for (const l of interiorLamps) {
const flicker = 1 + Math.sin(elapsed * 7 + (l.orb ? l.orb.position.x : 0)) * 0.05;
l.light.intensity = lerp(l.light.intensity, l.base * lampOn, dt * 3) * flicker;
if (l.orb) l.orb.material.emissiveIntensity = lampOn * 1.8;
}
// clouds drift
for (const cl of cloudsGroup.children) {
cl.position.x += cl.userData.speed * dt;
if (cl.position.x > 70) cl.position.x = -70;
cl.visible = true;
}
cloudsGroup.children.forEach(c => c.scale.setScalar(0.9 + env.cloudAmt * 0.4));
// rain
if (env.rainAmt > 0.02) {
rainPoints.material.opacity = env.rainAmt * 0.7;
const pos = rainPoints.geometry.attributes.position;
for (let i = 0; i < pos.count; i++) {
let y = pos.getY(i) - dt * 21;
if (y < 0) { resetDrop(pos.array, i); y = pos.getY(i); }
pos.setY(i, y);
}
pos.needsUpdate = true;
} else rainPoints.material.opacity = 0;
// snow
if (env.snowAmt > 0.02) {
snowPoints.material.opacity = env.snowAmt * 0.95;
const pos = snowPoints.geometry.attributes.position;
const ph = snowPoints.geometry.attributes.phase;
const t = elapsed;
for (let i = 0; i < pos.count; i++) {
let y = pos.getY(i) - dt * 1.6;
if (y < 0) { resetSnow(pos.array, i); y = pos.getY(i); }
pos.setY(i, y);
pos.setX(i, pos.getX(i) + Math.sin(t * 1.4 + ph.getX(i)) * dt * 0.6);
}
pos.needsUpdate = true;
} else snowPoints.material.opacity = 0;
// fireflies on warm nights
const ffSeason = G?.data ? ['spring', 'summer'].includes(G.season().id) : false;
const ffOn = ffSeason && env.night > 0.5 ? (env.night - 0.5) * 2 : 0;
fireflies.material.opacity = ffOn * 0.85;
if (ffOn > 0.02) {
const pos = fireflies.geometry.attributes.position;
for (let i = 0; i < pos.count; i++) {
pos.setY(i, 1 + Math.sin(elapsed * 1.3 + i * 2.7) * 0.7);
pos.setX(i, pos.getX(i) + Math.cos(elapsed * 0.5 + i) * dt * 0.35);
pos.setZ(i, pos.getZ(i) + Math.sin(elapsed * 0.4 + i * 1.3) * dt * 0.3);
}
pos.needsUpdate = true;
}
// chimney smoke
for (const sp of smokeSprites) {
sp.userData.t += dt * 0.14;
if (sp.userData.t > 1) sp.userData.t -= 1;
const t = sp.userData.t;
sp.position.set(
chimneyPos.x + Math.sin(t * 9 + sp.position.x) * 0.3 * t,
chimneyPos.y + t * 3.4,
chimneyPos.z + Math.cos(t * 7) * 0.2 * t
);
sp.scale.setScalar(0.8 + t * 1.8);
sp.material.opacity = (1 - t) * 0.5 * (0.5 + env.night * 0.5);
}
}
// helpers so the sun follows whatever we're looking at
let cameraRef = { position: { x: 0, z: 20 } };
export function setCameraRef(cam) { cameraRef = cam; }
export function registerGlowLight(light, base, orb) {
interiorLamps.push({ light, base, orb });
}
+644
View File
@@ -0,0 +1,644 @@
import * as THREE from 'three';
import './style.css';
// cozy rounded font — loaded async so it never blocks first paint
Promise.all([
import('@fontsource/nunito/400.css'),
import('@fontsource/nunito/700.css'),
import('@fontsource/nunito/800.css'),
import('@fontsource/nunito/900.css'),
]).catch(() => {});
import { G } from './sim/state.js';
import { updateDay, TIME } from './sim/daycycle.js';
import { spawnTimerTick, updateCustomer, forceGridRebuild, clearCustomers, counterAttended } from './sim/customerAI.js';
import { updateStocker } from './sim/staff.js';
import { updateEvents } from './sim/events.js';
import { FURN_BY_ID, FURNITURE } from './data/furniture.js';
import { PRODUCT_BY_ID } from './data/products.js';
import { initEngine, applyQuality, updateCamera, renderFrame, orbitRotate, orbitZoom, orbitPan, setOrbit, pickAt, screenRay, camera, scene } from './gfx/engine.js';
import { buildWorld, updateEnvironment, setCameraRef } from './gfx/world.js';
import { buildShop, updateShopVisuals, shopRig } from './gfx/shop.js';
import { refreshAllFurniture, getFurnGroup, cellToWorld, buildFurnMesh } from './gfx/furniture3d.js';
import { syncCustomerRigs, updateCharacters, spawnPlayer, clearCharacterRigs } from './gfx/character3d.js';
import { floatText, coinBurst, sparkleBurst, heartPuff, puffAt, updateFx } from './gfx/fx.js';
import { initCoreUi, updateHud, updateTimeControls, showModal, closeModal, hideSelCard, showSelCard, bindSelectionActions } from './ui/ui.js';
import { Panels } from './ui/panels.js';
import { initReport } from './ui/report.js';
import { initMenu, hideMenu, showMenu } from './ui/menu.js';
import { audio } from './audio/audio.js';
import { saveTo, autosave, unlockAchievement, slotInfo } from './save.js';
import { on, emit, el, $, toast } from './util.js';
// ============================================================
// TINY SHOP — main
// ============================================================
let started = false;
let elapsed = 0;
let hudTimer = 0;
let autosaveTimer = 0;
let achTimer = 0;
let achDirty = false;
const container = document.getElementById('game');
initEngine(container);
setCameraRef(camera);
buildWorld();
initCoreUi();
Panels.init();
initReport();
initMenu(onStartGame);
// ---------------- menu backdrop ----------------
function buildMenuScene() {
G.newGame({ name: 'Tiny Shop', difficulty: 'normal' });
G.shop.minutes = 10 * 60 + 30;
buildShop(2, 'Tiny Shop');
refreshAllFurniture();
forceGridRebuild();
// dress the demo shop a bit
const shelves = G.data.furniture.filter(f => f.type === 'shelf');
G.addItems('bread', 8); G.addItems('apple', 8); G.addItems('potion', 5); G.addItems('crystal', 2);
if (shelves[0]) stockShelfQuiet(shelves[0], 'bread', 6);
if (shelves[1]) stockShelfQuiet(shelves[1], 'potion', 4);
refreshAllFurniture();
}
buildMenuScene();
setOrbit(new THREE.Vector3(0, 0.8, 1), 15, Math.PI * 0.12, 1.02, true);
function stockShelfQuiet(furn, pid, qty) {
import('./sim/economy.js').then(({ stockShelf }) => stockShelf(furn, pid, qty));
}
// ============================================================
// GAME START
// ============================================================
async function onStartGame(opts) {
audio.init(); audio.resume();
clearCharacterRigs();
clearCustomers();
if (opts.save) {
G.load(opts.save);
toast(`Welcome back to ${G.data.meta.name}!`, 'good', '📖');
} else {
G.newGame(opts);
}
rebuildShopVisuals();
spawnPlayer();
Panels.close();
hideSelCard();
hideMenu();
document.getElementById('ui').classList.remove('hidden');
const [W, H] = G.gridSize();
setOrbit(new THREE.Vector3(0, 1, H * 0.18), Math.max(W, H) * 1.28 + 4, Math.PI * 0.14, 0.98, true);
audio.setMood('morning');
audio.setVolumes(G.data.settings.music, G.data.settings.sfx);
applyQuality(G.data.settings.quality || 'high');
updateHud();
updateTimeControls();
started = true;
G.mode = 'play';
if (!opts.save) {
setTimeout(() => toast(`Welcome to ${G.data.meta.name}! Buy goods from Suppliers, then OPEN SHOP at 9:00.`, '', '🏪'), 700);
setTimeout(() => toast('Tip: click ❔ anytime for help.', '', '💡'), 4200);
}
}
function rebuildShopVisuals() {
buildShop(G.shop.level, G.data?.meta?.name);
refreshAllFurniture();
forceGridRebuild();
}
// ---------------- event wiring ----------------
on('saleFx', ({ customer, price, satisfaction }) => {
const [x, z] = [customer.pos[0], customer.pos[1]];
coinBurst(x, 1.1, z, satisfaction > 0.7 ? 7 : 4);
floatText(`+${Math.round(price)}g`, x, 1.7, z, satisfaction > 0.66 ? '#ffd166' : '#e8b26a');
if (satisfaction > 0.75) heartPuff(x, 1.5, z, 2);
audio.coin();
achDirty = true;
});
on('doorChime', () => audio.doorbell());
on('theftFx', () => audio.sad());
on('newDay', () => { achDirty = true; });
on('shopExpanded', () => {
rebuildShopVisuals();
const c = cellToWorld(...centerCell());
puffAt(c[0], 0.5, c[1]);
sparkleBurst(c[0], 1.5, c[1], 16);
toast(`The shop expands! Now: ${G.levelInfo().name}`, 'gold', '🏗️');
});
function centerCell() {
const [W, H] = G.gridSize();
return [Math.floor(W / 2), Math.floor(H / 2)];
}
on('stockChanged', () => refreshAllFurniture());
on('qualityChanged', (q) => applyQuality(q));
on('decorateDone', exitDecorate);
on('cancelPlacement', cancelGhost);
on('newDay', () => {
updateHud();
});
G.onGoldChanged = () => updateHud();
// ============================================================
// INPUT
// ============================================================
const input = {
down: false, moved: false, sx: 0, sy: 0, btn: 0,
};
const keys = {};
window.addEventListener('keydown', (e) => {
keys[e.code] = true;
if (ghost && e.code === 'KeyR') rotateGhost();
});
window.addEventListener('keyup', (e) => { keys[e.code] = false; });
rendererDom().addEventListener('pointerdown', (e) => {
if (!started && G.mode !== 'menu') return;
input.down = true; input.moved = false;
input.sx = e.clientX; input.sy = e.clientY; input.btn = e.button;
});
rendererDom().addEventListener('pointermove', (e) => {
if (input.down) {
const dx = e.clientX - input.sx, dy = e.clientY - input.sy;
if (!input.moved && Math.hypot(dx, dy) > 6) input.moved = true;
if (input.moved) {
if (input.btn === 0) orbitRotate(dx, dy);
else orbitPan(dx, dy);
input.sx = e.clientX; input.sy = e.clientY;
}
}
if (ghost) moveGhost(e.clientX, e.clientY);
});
window.addEventListener('pointerup', (e) => {
if (input.down && !input.moved && e.button === 0) handleClick(e.clientX, e.clientY);
input.down = false;
});
rendererDom().addEventListener('wheel', (e) => { orbitZoom(e.deltaY); }, { passive: true });
rendererDom().addEventListener('contextmenu', (e) => { e.preventDefault(); cancelGhost(); });
function rendererDom() { return container.querySelector('canvas'); }
// ---------------- clicks ----------------
bindSelectionActions({
close: hideSelCard,
attendCounter: () => {
const counter = G.data.furniture.find(f => f.type === 'counter');
if (counter) {
const [x, z] = cellToWorld(counter.cx, counter.cz);
G.data.player.tx = x; G.data.player.tz = z + 1.2;
toast('Walking to the counter…', '', '🚶');
}
hideSelCard();
},
pickupFurn: (d) => pickupFurniture(d.id),
sellFurn: (d) => sellFurniture(d.id),
});
function handleClick(cx, cy) {
if (!started || !G.data) return;
// decorate interactions
if (G.mode === 'decorate') {
// try picking up placed furniture
const hitF = hitFurniture(cx, cy);
if (hitF) { pickupFurniture(hitF.userData.furnId); return; }
if (ghost) tryPlaceGhost();
return;
}
// customers
const custHit = hitCustomer(cx, cy);
if (custHit) { showCustomerCard(custHit.userData.custId, cx, cy); return; }
// furniture
const fHit = hitFurniture(cx, cy);
if (fHit) { showFurnitureCard(fHit.userData.furnId, cx, cy); return; }
hideSelCard();
// walk order
const p = floorPoint(cx, cy);
if (p) {
G.data.player.tx = p.x;
G.data.player.tz = p.z;
}
}
function floorPoint(cx, cy) {
const ray = screenRay(cx, cy);
const t = -ray.origin.y / ray.direction.y;
if (t < 0) return null;
const pt = ray.origin.clone().addScaledVector(ray.direction, t);
return pt;
}
function hitCustomer(cx, cy) {
const objs = [];
for (const [, rig] of charRigsMap()) objs.push(rig.group);
const hit = pickAt(cx, cy, objs, true);
let o = hit?.object;
while (o) { if (o.userData.custId) return o; o = o.parent; }
return null;
}
function charRigsMap() {
// imported lazily to avoid circulars
return modChar.charRigs;
}
import * as modChar from './gfx/character3d.js';
function hitFurniture(cx, cy) {
const objs = [];
for (const [, rec] of modFurn.furnGroups) objs.push(rec.group);
if (!objs.length) return null;
const hit = pickAt(cx, cy, objs, true);
let o = hit?.object;
while (o) {
// find which top-level group this belongs to
for (const [id, rec] of modFurn.furnGroups) {
if (rec.group === o) { o.userData.furnId = id; return o; }
}
o = o.parent;
}
return null;
}
import * as modFurn from './gfx/furniture3d.js';
// ---------------- selection cards ----------------
function showCustomerCard(custId, cx, cy) {
const c = G.customers.find(x => x.id === custId);
if (!c) return;
const moodEmoji = c.mood > 0.75 ? '😊' : c.mood > 0.5 ? '🙂' : c.mood > 0.3 ? '😕' : '😠';
const arch = Object.values(ARCH_LOOKUP).find(a => a.id === c.archId);
showSelCard(`
<h4>${arch?.emoji || '🙂'} ${c.name}</h4>
<div class="sc-row"><span>Type</span><b>${arch?.label || c.archId}</b></div>
<div class="sc-row"><span>Budget</span><b>${c.budget}g</b></div>
<div class="sc-row"><span>Mood</span><span class="mood">${moodEmoji} ${Math.round(c.mood * 100)}%</span></div>
<div class="sc-row"><span>Patience</span><span class="mono">${Math.max(0, Math.round(c.patience))}</span></div>
<div class="sc-row"><span>Loyalty</span><span class="mono">${Math.round(c.loyalty)}</span></div>
${c.thief ? '<div class="tag red" style="margin-top:5px">👀 Shifty… watch them!</div>' : ''}
<div class="desc" style="margin-top:6px;font-size:11.5px;color:#8a7360">Likes: ${(c.likes || []).join(', ') || '—'} · Dislikes: ${(c.dislikes || []).join(', ') || '—'}</div>
<div class="sc-actions"><button class="small-btn" data-act="close">Okay!</button></div>
`, cx, cy);
}
import { ARCH_BY_ID as ARCH_LOOKUP } from './data/customers.js';
function showFurnitureCard(fid, cx, cy) {
const f = G.data.furniture.find(x => x.id === fid);
if (!f) return;
const def = FURN_BY_ID[f.type];
const stock = G.data.shelfStock[fid] || {};
const cap = shelfCapOf(f.type);
let stockHtml = '';
if (cap) {
const entries = Object.entries(stock);
const used = entries.reduce((a, [, q]) => a + q, 0);
stockHtml = `<div class="sc-row"><span>Stock</span><b>${used}/${cap}</b></div>`;
for (const [pid, q] of entries.slice(0, 4)) {
stockHtml += `<div class="sc-row"><span>${PRODUCT_BY_ID[pid].emoji} ${PRODUCT_BY_ID[pid].name}</span><b>×${q}</b></div>`;
}
}
const stats = Object.entries(def.stats || {}).map(([k, v]) => `${k} +${v}`).join(', ');
showSelCard(`
<h4>${def.emoji} ${def.label}</h4>
${stats ? `<div class="desc">${stats}</div>` : ''}
${cap ? `<div class="desc">${def.desc}</div>` : ''}
${stockHtml}
<div class="sc-actions">
${f.type === 'counter' ? '<button class="small-btn green" data-act="attendCounter">🧍 Stand here</button>' : ''}
<button class="small-btn blue" data-act="pickupFurn" data-id="${fid}">🪄 Move</button>
<button class="small-btn red" data-act="sellFurn" data-id="${fid}">💰 Sell ${Math.floor(def.cost / 2)}g</button>
</div>
`, cx, cy);
}
function shelfCapOf(type) { return type === 'shelf' ? 12 : type === 'table' ? 6 : type === 'pedestal' ? 1 : 0; }
// ============================================================
// DECORATE MODE
// ============================================================
let ghost = null; // {group, typeId, rot, originData|null}
let decSelected = null;
on('decorateStart', enterDecorate);
function enterDecorate() {
if (!started) return;
G.mode = 'decorate';
hideSelCard();
$('#decorate-bar').classList.remove('hidden');
renderDecCatalog();
setOrbit(new THREE.Vector3(0, 0.5, 0), Math.max(...G.gridSize()) * 1.5 + 3, undefined, 1.05);
}
function exitDecorate() {
cancelGhost(true);
G.mode = 'play';
$('#decorate-bar').classList.add('hidden');
forceGridRebuild();
Panels.refresh();
}
function renderDecCatalog() {
const box = $('#dec-catalog');
box.innerHTML = '';
for (const f of FURNITURE) {
const afford = G.gold >= f.cost;
const b = el(`<button class="dec-item ${decSelected === f.id ? 'sel' : ''}" style="${afford ? '' : 'opacity:.45'}">
<span class="de">${f.emoji}</span><span>${f.cost}g</span></button>`);
b.title = `${f.label}${f.desc}`;
b.onclick = () => {
if (!afford) { toast('Not enough gold!', 'bad', '💸'); return; }
selectGhost(f.id);
};
box.appendChild(b);
}
}
function selectGhost(typeId, originData = null) {
cancelGhost(true);
decSelected = typeId;
const group = buildFurnMesh(typeId);
group.traverse(o => {
if (o.material) {
o.material = Array.isArray(o.material)
? o.material.map(m => m.clone())
: o.material.clone();
}
});
group.traverse(o => {
if (o.material) { o.material.transparent = true; o.material.opacity = 0.62; }
});
scene.add(group);
ghost = { group, typeId, rot: originData?.rot ?? 0, originData };
ghost.group.rotation.y = -ghost.rot * Math.PI / 2;
ghost.group.visible = false;
renderDecCatalog();
document.querySelectorAll('.nav-btn').forEach(b => b.classList.remove('active'));
}
function rotateGhost() { if (ghost) { ghost.rot = (ghost.rot + 1) % 2; ghost.group.rotation.y = -ghost.rot * Math.PI / 2; } }
function moveGhost(cx, cy) {
if (!ghost) return;
const p = floorPoint(cx, cy);
if (!p) { ghost.group.visible = false; return; }
const [gx, gz] = worldToCell(p.x, p.z);
const ok = canPlace(gx, gz, ghost.typeId, ghost.rot);
ghost.group.visible = true;
const [wx, wz] = cellToWorld(gx + footW(ghost.typeId, ghost.rot) / 2 - 0.5, gz + footD(ghost.typeId, ghost.rot) / 2 - 0.5);
ghost.group.position.set(wx, 0, wz);
ghost.group.traverse(o => {
if (o.material && o.material.emissive !== undefined) {
o.material.emissive.setHex(ok ? 0x113311 : 0x551111);
o.material.emissiveIntensity = 0.35;
}
});
ghost.cell = [gx, gz];
ghost.valid = ok;
}
function worldToCell(x, z) {
const [W, H] = G.gridSize();
return [Math.floor(x + W / 2), Math.floor(z + H / 2)];
}
function footW(typeId, rot) { const s = FURN_BY_ID[typeId].size; return rot % 2 ? s[1] : s[0]; }
function footD(typeId, rot) { const s = FURN_BY_ID[typeId].size; return rot % 2 ? s[0] : s[1]; }
function canPlace(gx, gz, typeId, rot) {
const [W, H] = G.gridSize();
const w = footW(typeId, rot), d = footD(typeId, rot);
if (gx < 0 || gz < 0 || gx + w > W || gz + d > H) return false;
// door clearance (front center)
const dx0 = Math.floor(W / 2) - 1;
for (let ox = 0; ox < w; ox++) for (let oz = 0; oz < d; oz++) {
const x = gx + ox, z = gz + oz;
if ((z === H - 1 || z === H - 2) && (x === dx0 || x === dx0 + 1)) return false;
// overlap check
for (const f of G.data.furniture) {
const fw = footW(f.type, f.rot), fd = footD(f.type, f.rot);
if (x >= f.cx && x < f.cx + fw && z >= f.cz && z < f.cz + fd) return false;
}
}
return true;
}
function tryPlaceGhost() {
if (!ghost || !ghost.valid || !ghost.cell) { audio.sad(); return; }
const [gx, gz] = ghost.cell;
const def = FURN_BY_ID[ghost.typeId];
if (ghost.originData) {
// moving existing furniture — restore at new spot
const f = ghost.originData;
f.cx = gx; f.cz = gz; f.rot = ghost.rot;
G.data.furniture.push(f);
ghost.originData = null;
} else {
if (G.gold < def.cost) { toast('Not enough gold!', 'bad', '💸'); return; }
G.addGold(-def.cost);
G.data.today.expenses += def.cost;
G.data.furniture.push({ id: Math.random().toString(36).slice(2, 9), type: ghost.typeId, cx: gx, cz: gz, rot: ghost.rot });
toast(`${def.label} placed! Beauty grows~`, 'good', def.emoji);
}
audio.pop();
const [wx, wz] = cellToWorld(gx, gz);
puffAt(wx, 0.3, wz);
forceGridRebuild();
refreshAllFurniture();
selectGhost(ghost.typeId); // keep tool ready for another placement
}
function pickupFurniture(fid) {
const idx = G.data.furniture.findIndex(f => f.id === fid);
if (idx < 0) return;
const f = G.data.furniture[idx];
G.data.furniture.splice(idx, 1);
forceGridRebuild();
refreshAllFurniture();
selectGhost(f.type, f);
toast('Picked up — click somewhere to place it back!', '', '🪄');
hideSelCard();
}
function sellFurniture(fid) {
const idx = G.data.furniture.findIndex(f => f.id === fid);
if (idx < 0) return;
const f = G.data.furniture[idx];
const def = FURN_BY_ID[f.type];
const refund = Math.floor(def.cost / 2);
// return shelf stock to storage
const stock = G.data.shelfStock[fid];
if (stock) {
for (const [pid, q] of Object.entries(stock)) G.addItems(pid, q);
delete G.data.shelfStock[fid];
}
G.data.furniture.splice(idx, 1);
G.addGold(refund);
toast(`Sold ${def.label} for ${refund}g`, 'good', '💰');
audio.coin();
forceGridRebuild();
refreshAllFurniture();
hideSelCard();
Panels.refresh();
}
function cancelGhost(silent = false) {
if (!ghost) return;
if (ghost.originData) {
// return the picked-up piece home
G.data.furniture.push(ghost.originData);
forceGridRebuild();
refreshAllFurniture();
if (!silent) toast('Put back where it was.', '', '↩️');
}
scene.remove(ghost.group);
ghost.group.traverse(o => { o.geometry?.dispose(); });
ghost = null;
decSelected = null;
renderDecCatalog();
}
// ============================================================
// PLAYER MOVEMENT
// ============================================================
function updatePlayer(dt) {
if (!started || !G.data || !modChar.playerRig) return;
const p = G.data.player;
const rig = modChar.playerRig;
// keyboard movement relative to camera azimuth
let mx = 0, mz = 0;
if (keys.KeyW || keys.ArrowUp) mz -= 1;
if (keys.KeyS || keys.ArrowDown) mz += 1;
if (keys.KeyA || keys.ArrowLeft) mx -= 1;
if (keys.KeyD || keys.ArrowRight) mx += 1;
let moving = false;
if (mx || mz) {
// camera-forward on the ground plane
const lt = camera.userData.lookTarget || { x: 0, z: 0 };
const toCamX = camera.position.x - lt.x;
const toCamZ = camera.position.z - lt.z;
const lenC = Math.hypot(toCamX, toCamZ) || 1;
const fx = -toCamX / lenC, fz = -toCamZ / lenC; // forward (toward look target)
const rx = -fz, rz = fx; // right vector
let dx = fx * -mz + rx * mx;
let dz = fz * -mz + rz * mx;
const len = Math.hypot(dx, dz) || 1;
dx /= len; dz /= len;
const sp = 3.2 * dt;
tryMove(p, dx * sp, dz * sp);
moving = true;
rig.group.rotation.y = Math.atan2(dx, dz);
p.tx = null; // manual control cancels walk order
} else if (p.tx != null) {
const dx = p.tx - p.x, dz = p.tz - p.z;
const len = Math.hypot(dx, dz);
if (len < 0.18) { p.tx = null; }
else {
const sp = 3.2 * dt;
tryMove(p, dx / len * sp, dz / len * sp);
rig.group.rotation.y = Math.atan2(dx, dz);
moving = true;
}
}
rig.userData.moving = moving;
// gentle auto-face toward counter if attending
if (!moving && counterAttended() && Math.random() < 0.01) { /* idle charm */ }
}
function tryMove(p, dx, dz) {
const nx = p.x + dx, nz = p.z + dz;
const { isWalkableWorld } = walkCheck;
if (isWalkableWorld(nx, nz)) { p.x = nx; p.z = nz; return; }
if (isWalkableWorld(nx, p.z)) { p.x = nx; return; }
if (isWalkableWorld(p.x, nz)) { p.z = nz; return; }
}
import * as walkCheck from './sim/pathfinding.js';
// ============================================================
// ACHIEVEMENT CHECKS
// ============================================================
function checkAchievements() {
const st = G.data.stats;
if (st.totalCustomers >= 1) unlockAchievement('first_customer');
if (st.totalCustomers >= 100) unlockAchievement('hundred_customers');
if (G.gold >= 1000) unlockAchievement('thousand_gold');
if (st.totalRevenue >= 1e6) unlockAchievement('millionaire');
if (G.shop.rep >= 44) unlockAchievement('famous_shop');
if (G.shop.rep >= 82) unlockAchievement('legendary_shop');
if (G.data.staff.some(s => s.loyalty >= 90)) unlockAchievement('best_employee');
if (G.data.quests.completed.length >= 8) unlockAchievement('town_hero');
}
// ============================================================
// MAIN LOOP
// ============================================================
let lastT = performance.now();
function loop(t) {
requestAnimationFrame(loop);
const dtReal = Math.min(0.06, (t - lastT) / 1000);
lastT = t;
elapsed += dtReal;
if (started && G.data && G.mode !== 'menu') {
// simulation
updateDay(dtReal);
const speedIdx = G.paused ? 0 : G.timeScale;
const dtMin = dtReal * TIME.minPerSec[speedIdx];
spawnTimerTick(dtMin);
const custSpeed = G.paused ? 0 : dtReal * Math.min(3, Math.max(1, G.timeScale));
for (const c of [...G.customers]) updateCustomer(c, custSpeed);
updateStocker(dtMin);
updateEvents(dtMin);
// player sim position ← rig
if (modChar.playerRig) {
G.data.player.x = modChar.playerRig.group.position.x;
G.data.player.z = modChar.playerRig.group.position.z;
}
updatePlayer(dtReal);
// achievements & autosave cadence
achTimer += dtReal;
if (achTimer > 3) { achTimer = 0; checkAchievements(); }
autosaveTimer += dtReal;
if (autosaveTimer > 60) { autosaveTimer = 0; autosave(); }
if (achDirty) { achDirty = false; checkAchievements(); }
// HUD refresh ~8/s
hudTimer += dtReal;
if (hudTimer > 0.125) { hudTimer = 0; updateHud(); updateTimeControls(); }
}
// visuals
syncCustomerRigs();
updateCharacters(dtReal, elapsed);
updateEnvironment(started ? dtReal : dtReal, elapsed);
updateShopVisuals(dtReal, elapsed);
updateFx(dtReal);
updateCamera(dtReal);
renderFrame();
}
requestAnimationFrame(loop);
// expose for debugging & automated tests
import * as simEconomy from './sim/economy.js';
import * as simAI from './sim/customerAI.js';
import * as simDay from './sim/daycycle.js';
import * as simStaff from './sim/staff.js';
import * as saveApi from './save.js';
window.__TS = { G, emit, on, eco: simEconomy, ai: simAI, day: simDay, staff: simStaff, refreshFurniture: refreshAllFurniture, saveApi };
+60
View File
@@ -0,0 +1,60 @@
import G from './sim/state.js';
// ============================================================
// SAVE SYSTEM — 3 slots + autosave in localStorage
// ============================================================
const KEY = (slot) => `tinyshop_save_${slot}`;
const ACH_KEY = 'tinyshop_achievements';
export const SLOT_IDS = ['slot1', 'slot2', 'slot3', 'autosave'];
export function saveTo(slot) {
if (!G.data) return false;
try {
const d = G.serialize();
localStorage.setItem(KEY(slot), JSON.stringify(d));
return true;
} catch (e) {
console.warn('Save failed', e);
return false;
}
}
export function loadFrom(slot) {
const raw = localStorage.getItem(KEY(slot));
if (!raw) return null;
try { return JSON.parse(raw); } catch { return null; }
}
export function slotInfo(slot) {
const d = loadFrom(slot);
if (!d) return null;
return {
name: d.meta?.name || 'Tiny Shop',
day: d.shop?.day ?? 1,
gold: d.shop?.gold ?? 0,
level: d.shop?.level ?? 1,
rep: Math.round(d.shop?.rep ?? 0),
savedAt: d.meta?.savedAt ? new Date(d.meta.savedAt).toLocaleString() : null,
difficulty: d.difficulty,
};
}
export function deleteSlot(slot) { localStorage.removeItem(KEY(slot)); }
// ---------------- achievements (global across saves) ----------------
export function getAchievements() {
try { return JSON.parse(localStorage.getItem(ACH_KEY)) || {}; } catch { return {}; }
}
export function unlockAchievement(id) {
const a = getAchievements();
if (a[id]) return false;
a[id] = Date.now();
localStorage.setItem(ACH_KEY, JSON.stringify(a));
import('./util.js').then(({ toast }) => toast(`🏆 Achievement unlocked!`, 'gold'));
return true;
}
export function autosave() {
if (!G.data) return;
saveTo('autosave');
}
+501
View File
@@ -0,0 +1,501 @@
import G from './state.js';
import { PRODUCT_BY_ID } from '../data/products.js';
import { ARCH_BY_ID, STORY_NPCS, NAMES_F, NAMES_M, SURNAMES } from '../data/customers.js';
import { purchaseWillingness, sellToCustomer } from './economy.js';
import { findPath, isWalkable, cellWorld, worldCell, doorCells, randomBrowseNear, counterQueueSpot, rebuildGrid } from './pathfinding.js';
import { clamp, rand, randi, chance, uid, pickWeighted, choice, emit, toast } from '../util.js';
import { footfallMult, TIME } from './daycycle.js';
// ============================================================
// CUSTOMER AI
// FSM: arriving -> entering -> browsing -> seeking -> evaluating ->
// queuing -> paying -> leaving (+ special: stealing, complaining)
// ============================================================
const MAX_CUSTOMERS = 14;
export function spawnTimerTick(dtMin) {
// Poisson-ish arrivals during open hours
if (!G.shop.isOpen) return;
if (G.customers.length >= MAX_CUSTOMERS) return;
const served = G.data.today.served;
const dayTarget = 6 + G.shop.rep * 0.55 + G.attractionScore() * 0.35;
const remaining = Math.max(0, dayTarget - served);
const openFrac = clamp((TIME.closeAt - G.shop.minutes) / (TIME.closeAt - TIME.openAt), 0, 1);
if (remaining <= 0 || openFrac <= 0) return;
// rush-hour shaping: lunch + evening bumps
const m = G.shop.minutes;
let shape = 1;
if ((m > 720 && m < 840) || (m > 1020 && m < 1140)) shape = 1.7; // noon & after-work rush
if (m < 600) shape = 0.6;
const ratePerMin = (remaining / (TIME.closeAt - m)) * 1.6 * shape * footfallMult();
if (Math.random() < ratePerMin * dtMin) spawnCustomer();
}
function pickArchetype() {
const d = G.data;
const entries = [];
for (const a of ARCH_BY_ID ? Object.values(ARCH_BY_ID) : []) {
if (a.repMin && d.shop.rep < a.repMin) continue;
let w = a.weight;
// story flavor boosts
if (a.id === 'tourist' && G.isFestivalDay()) w *= 2.5;
if (a.id === 'adventurer' && d.demandMods['tag:adventure']) w *= 2;
if (a.id === 'wealthy') w *= 0.4 + G.luxuryScore() / 12;
if (a.id === 'noble' && !G.shelves().length) w = 0;
if (w > 0) entries.push({ ...a, w });
}
if (!entries.length) return ARCH_BY_ID.regular;
return pickWeighted(entries);
}
export function spawnCustomer() {
const arch = pickArchetype();
const diffPat = { cozy: 1.25, normal: 1, business: 0.9, tycoon: 0.8 }[G.data.difficulty] || 1;
const c = {
id: uid(),
archId: arch.id,
name: chance(0.5) ? choice(NAMES_F) : choice(NAMES_M),
surname: SURNAMES.length && chance(0.6) ? choice(SURNAMES) : '',
budget: Math.round(rand(arch.budget[0], arch.budget[1]) * (0.8 + G.shop.rep / 120)),
tolerance: arch.tolerance,
patienceMax: rand(arch.patience[0], arch.patience[1]) * diffPat,
patience: 0,
mood: 0.55 + rand(-0.15, 0.25),
loyalty: 10 + randi(0, 20),
likes: arch.likes, dislikes: arch.dislikes,
storyId: null,
state: 'arriving',
t: 0, // state timer
path: null, pathI: 0,
pos: spawnOutsidePos(),
facing: 0,
speed: rand(1.5, 2.1),
wants: [], // product ids considered
targetFurn: null,
queueIndex: -1,
bought: [],
emote: null, emoteT: 0,
bubble: null, bubbleT: 0,
animPhase: Math.random() * 10,
stealAttempted: false,
};
// story npc takeover — some customers ARE the named folk
if (chance(0.3)) {
const npc = choice(STORY_NPCS);
c.storyId = npc.id; c.name = npc.name; c.archId = npc.arch;
c.likes = ARCH_BY_ID[npc.arch].likes; c.dislikes = ARCH_BY_ID[npc.arch].dislikes;
c.tolerance = ARCH_BY_ID[npc.arch].tolerance;
c.loyalty = clamp(G.relOf(npc.id), 0, 100);
}
// elder Tuesday medicine ritual
if (c.archId === 'elder' && new Date().getDay() === 2) c.wants = ['potion'];
if (c.storyId === 'alder' && G.shop.day % 7 === 2) { c.wants = ['potion', 'bandage']; c.state = 'arriving'; }
if (c.archId === 'knight') c.knight = true;
if (arch.thief) c.thief = true;
G.customers.push(c);
return c;
}
function spawnOutsidePos() {
// appear on the path south of the door, outside
const [[dx]] = doorCells();
const [wx] = cellWorld(dx, 0);
return [wx + rand(-2.5, 2.5), G.gridSize()[1] / 2 + rand(3, 7)];
}
// ---------- desire selection ----------
export function chooseDesire(c) {
const stocked = [];
for (const f of G.shelves()) {
for (const pid of Object.keys(G.data.shelfStock[f.id] || {})) {
if ((G.data.shelfStock[f.id][pid] || 0) > 0) stocked.push({ f, pid });
}
}
if (!stocked.length) return null;
// score each option by archetype taste
const scored = stocked.map(({ f, pid }) => {
const p = PRODUCT_BY_ID[pid];
let s = 0.4 + G.demandMult(pid) * 0.3;
if (c.likes.includes(p.cat)) s += 0.9;
if (c.dislikes.includes(p.cat)) s -= 1.2;
if (c.likes.includes('medicine') && p.tags.includes('medicine')) s += 0.8;
if (c.rareSeeker && p.rarity >= 3) s += 1.2;
if (c.nobleWantsLuxury) s += p.cat === 'luxury' ? 2 : -0.5;
if (p.tags.includes('luxury')) s += G.luxuryScore() * 0.02 * (c.dislikes.includes('luxury') ? -1 : 1);
s *= rand(0.75, 1.25);
if (G.priceOf(pid) > c.budget) s -= 1.4;
return { f, pid, s };
}).filter(o => o.s > 0);
if (!scored.length) return null;
scored.sort((a, b) => b.s - a.s);
const top = scored.slice(0, Math.min(3, scored.length));
const sel = top[Math.floor(Math.random() * Math.min(2, top.length))];
return sel;
}
// ---------- FSM update ----------
export function updateCustomer(c, dtSec) {
c.t += dtSec;
if (c.emoteT > 0) c.emoteT -= dtSec; else c.emote = null;
if (c.bubbleT > 0) c.bubbleT -= dtSec; else c.bubble = null;
switch (c.state) {
case 'arriving': {
const [dx, dz] = doorCells()[0];
const [tx, tz] = cellWorld(dx, dz);
moveTo(c, tx, tz + 1.2, dtSec);
if (dist(c.pos, [tx, tz + 1.2]) < 0.35) {
enterShop(c);
c.bubble = choice(['Hello!', '*looks around*', 'Ooh~']);
c.bubbleT = 2;
}
break;
}
case 'browsing': {
c.patience -= dtSec * 0.4; // browsing burns little patience
if (c.path && c.pathI < c.path.length) { followPath(c, dtSec); return; }
if (!c.desire) c.desire = chooseDesire(c);
if (!c.desire || c.t > 26) {
// nothing desired → maybe impulse buy or leave grumbling
if (c.desire === null && c.impulseOK !== false && chance(0.35)) {
leave(c, 'Nothing I need…');
} else leave(c, c.desire ? 'Just looking!' : 'Empty shelves…');
return;
}
// walk to the shelf holding the desire
const [fx, fz] = furnFrontCell(c.desire.f);
const path = findPath(...worldCell(c.pos[0], c.pos[1]), fx, fz);
if (!path) { leave(c, 'Cant reach it!'); return; }
setPath(c, path);
c.state = 'seeking';
break;
}
case 'seeking': {
if (c.path && c.pathI < c.path.length) { followPath(c, dtSec); return; }
c.state = 'evaluating';
c.t = 0;
c.emote = '🤔'; c.emoteT = 2.5;
break;
}
case 'evaluating': {
if (c.t > rand(1.2, 2.6)) decidePurchase(c);
break;
}
case 'queuing': {
c.patience -= dtSec;
const spot = counterQueueSpot(Math.max(0, c.queueIndex));
if (dist(c.pos, spot) > 0.25) moveTo(c, spot[0], spot[1], dtSec);
else faceTowards(c, 0, -2);
if (c.patience <= 0) {
releaseQueue(c);
complainAndLeave(c, 'This line is TOO long!');
return;
}
tryCheckout(c);
break;
}
case 'paying': {
if (c.t > 0.9) finishLeave(c, payLine(c));
break;
}
case 'leaving': {
const [dx, dz] = doorCells()[0];
const [tx, tz] = cellWorld(dx, dz);
if (dist(c.pos, [tx, tz]) > 0.45) {
if (!c.leavePath) {
const p = findPath(...worldCell(c.pos[0], c.pos[1]), dx, dz);
c.leavePath = p || [];
c.leaveI = 0;
}
if (c.leaveI < c.leavePath.length) {
const [gx, gz] = cellWorld(c.leavePath[c.leaveI][0], c.leavePath[c.leaveI][1]);
moveDir(c, gx - c.pos[0], gz - c.pos[1], dtSec);
if (dist(c.pos, [gx, gz]) < 0.18) c.leaveI++;
} else {
moveTo(c, tx, tz + 2, dtSec);
}
} else {
removeCustomer(c);
}
break;
}
}
// thief behavior — strike while unattended
if (c.thief && !c.stealAttempted && c.state !== 'leaving' && c.state !== 'paying') {
const attended = counterAttended();
if (!attended && chance(dtSec * 0.22)) attemptTheft(c);
}
}
function payLine(c) {
if (c.bought.length >= 3) return 'My pockets are full!';
if (c.mood > 0.8) return choice(['Lovely shop!', 'See you tomorrow!', 'Wonderful!']);
if (c.mood < 0.35) return 'Hmph.';
return choice(['Thanks!', 'Have a good one!', 'Bye!']);
}
function enterShop(c) {
rebuildGridIfNeeded();
c.state = 'browsing';
c.t = 0;
c.patience = c.patienceMax;
emit('doorChime');
// wander to a random browse point first
const [W, H] = G.gridSize();
const spot = randomBrowseNear(Math.floor(W / 2), Math.floor(H / 2), 3);
if (spot) {
const path = findPath(...worldCell(c.pos[0], c.pos[1]), spot[0], spot[1]);
if (path) setPath(c, path);
}
}
function decidePurchase(c) {
const desire = c.desire;
if (!desire) { leave(c, 'Nothing for me…'); return; }
const stockNow = (G.data.shelfStock[desire.f.id] || {})[desire.pid] || 0;
if (stockNow <= 0) {
// someone took the last one!
complainAndLeave(c, `No more ${PRODUCT_BY_ID[desire.pid].name}?!`);
return;
}
// knight refuses elven goods
if (c.knight) {
const elvenSuppliers = ['alchemist', 'wizard'];
const isElven = PRODUCT_BY_ID[desire.pid].tags.includes('elven');
if (isElven) {
c.emote = '😠'; c.emoteT = 3;
complainAndLeave(c, 'Ill not buy ELVEN wares!');
return;
}
}
const w = purchaseWillingness(c, desire.pid);
if (w > 0.5 || (w > 0.32 && chance(0.6))) {
// BUY → join queue
joinQueue(c, desire);
} else if (w > 0.2) {
c.bubble = 'Thats a little expensive…'; c.bubbleT = 2.6;
G.data.today.complaints++;
c.mood -= 0.12;
c.desire = null; // look for something else
c.state = 'browsing'; c.t = 0;
} else {
complainAndLeave(c, 'Too rich for my blood!');
}
}
function joinQueue(c, desire) {
const queue = G.customers.filter(x => x.state === 'queuing').sort((a, b) => a.queueIndex - b.queueIndex);
let idx = queue.length;
// reuse freed slots
for (let i = 0; i < queue.length; i++) if (queue[i].queueIndex !== i) { idx = i; break; }
c.queueIndex = idx;
c.buyIntent = desire;
c.state = 'queuing';
c.patience = c.patienceMax * 0.9;
const [fx, fz] = furnFrontCell(desire.f);
const path = findPath(...worldCell(c.pos[0], c.pos[1]), ...(spotCell(idx)));
setPath(c, path || []);
if (idx === 0) c.emote = '🙋';
}
function spotCell(idx) {
const counter = G.data.furniture.find(f => f.type === 'counter');
const [W, H] = G.gridSize();
if (!counter) return [Math.floor(W / 2), H - 3];
const x = counter.cx + (idx % 2);
const z = Math.min(H - 2, counter.cz + 2 + Math.floor(idx / 2));
return isWalkable(x, z) ? [x, z] : [Math.max(1, Math.min(W - 2, x)), Math.min(H - 2, z)];
}
function releaseQueue(c) {
// shift everyone behind forward
for (const o of G.customers) if (o.state === 'queuing' && o.queueIndex > c.queueIndex) {
o.queueIndex--;
o.path = null;
}
c.queueIndex = -1;
}
let checkoutCd = {};
function tryCheckout(c) {
if (c.queueIndex !== 0) return;
const cdKey = 'checkout';
if ((checkoutCd[cdKey] ?? 0) > 0) { checkoutCd[cdKey] -= 1 / 60; return; }
const att = counterAttended(); // 'cashier' | 'near' | 'inside' | null
if (!att) {
// nobody home… hope fades
c.patience -= 0.02;
return;
}
const cashierSkill = G.staffByRole('cashier').reduce((m, s) => Math.max(m, s.skill), 30);
const base = att === 'cashier' ? clamp(1.5 - cashierSkill / 140, 0.45, 1.5)
: att === 'near' ? 1.1 : 2.3;
checkoutCd[cdKey] = base;
const intent = c.buyIntent;
const res = sellToCustomer(c, intent.f, intent.pid);
if (res) {
c.bought.push(intent.pid);
c.mood = clamp(c.mood + (res.satisfaction - 0.5) * 0.5, 0.05, 1);
c.emote = res.satisfaction > 0.66 ? '😊' : res.satisfaction > 0.4 ? '🙂' : '😕';
c.emoteT = 2.5;
emit('saleFx', { customer: c, price: res.price, satisfaction: res.satisfaction });
// loyal regulars may buy a second item!
if (c.loyalty > 45 && chance(0.35) && c.bought.length < 3) {
const second = chooseDesire(c);
if (second && (G.data.shelfStock[second.f.id] || {})[second.pid]) {
c.desire = second;
c.state = 'browsing'; c.t = 0;
releaseQueue(c);
return;
}
}
}
releaseQueue(c);
c.state = 'paying';
c.t = 0;
}
export function counterAttended() {
// a cashier on duty always counts…
if (G.staffByRole('cashier').length > 0) return 'cashier';
// …otherwise the owner has to be around
const counter = G.data.furniture.find(f => f.type === 'counter');
if (!counter) return null;
const [px, pz] = cellWorld(counter.cx, counter.cz);
const pdx = G.data.player.x - px, pdz = G.data.player.z - pz;
const d2 = pdx * pdx + pdz * pdz;
if (d2 < 5) return 'near';
const [W, H] = G.gridSize();
const inside = Math.abs(G.data.player.x) < W / 2 + 0.4 && Math.abs(G.data.player.z) < H / 2 + 0.4;
return inside ? 'inside' : null;
}
function attemptTheft(c) {
c.stealAttempted = true;
// guard deterrence
const guards = G.staffByRole('guard');
const guardPower = guards.reduce((m, g) => Math.max(m, g.skill / 100), 0);
if (guards.length && chance(guardPower)) {
c.bubble = 'EEK! A guard!'; c.bubbleT = 2.5;
c.emote = '😱'; c.emoteT = 3;
leave(c, 'Wrong shop, wrong day!');
toast(`${c.name} tried to steal but your guard glared them down!`, 'good', '🛡️');
G.addRep(0.5);
return;
}
// find priciest stocked item
let best = null, bp = -1;
for (const f of G.shelves()) {
for (const pid of Object.keys(G.data.shelfStock[f.id] || {})) {
const v = G.priceOf(pid);
if ((G.data.shelfStock[f.id][pid] || 0) > 0 && v > bp) { bp = v; best = { f, pid }; }
}
}
if (!best) return;
deleteFromShelf(best.f.id, best.pid, 1);
G.data.today.thefts++;
const loss = G.priceOf(best.pid);
G.data.today.theftLoss += loss;
c.hasStolen = true;
c.bubble = '*slinks away*'; c.bubbleT = 2.5;
c.emote = '😈'; c.emoteT = 3;
toast(`${c.name} stole ${PRODUCT_BY_ID[best.pid].name} (-${loss}g)! Hire a guard or watch the counter!`, 'bad', '🕵️');
G.addRep(-0.4);
emit('theftFx', { customer: c });
c.state = 'leaving';
c.leavePath = null;
}
export function deleteFromShelf(fid, pid, qty) {
const stock = G.data.shelfStock[fid];
if (!stock?.[pid]) return;
stock[pid] -= qty;
if (stock[pid] <= 0) delete stock[pid];
emit('stockChanged');
}
function complainAndLeave(c, msg) {
c.bubble = msg; c.bubbleT = 3;
c.emote = '😠'; c.emoteT = 3;
G.data.today.complaints++;
c.mood = clamp(c.mood - 0.25, 0.05, 1);
if (c.storyId) G.bumpRel(c.storyId, -1.5);
G.addRep(-0.15);
leave(c, msg);
}
function leave(c, bubble) {
releaseQueue(c);
c.state = 'leaving';
c.leavePath = null;
if (bubble && !c.bubble) { c.bubble = bubble; c.bubbleT = 2.5; }
// memory: satisfaction shapes future loyalty
const sat = clamp(c.mood, 0, 1);
if (sat > 0.65) c.loyalty = clamp(c.loyalty + 4, 0, 100);
else if (sat < 0.35) c.loyalty = clamp(c.loyalty - 5, 0, 100);
if (c.storyId) G.bumpRel(c.storyId, sat > 0.6 ? 1 : sat < 0.35 ? -1 : 0);
}
function finishLeave(c, bubble) {
if (bubble) { c.bubble = bubble; c.bubbleT = 2.2; }
c.state = 'leaving';
c.leavePath = null;
const sat = clamp(c.mood, 0, 1);
if (sat > 0.65) c.loyalty = clamp(c.loyalty + 4, 0, 100);
else if (sat < 0.35) c.loyalty = clamp(c.loyalty - 5, 0, 100);
if (c.storyId) G.bumpRel(c.storyId, sat > 0.6 ? 1 : sat < 0.35 ? -1 : 0);
}
export function removeCustomer(c) {
const i = G.customers.indexOf(c);
if (i >= 0) G.customers.splice(i, 1);
emit('customerRemoved', c);
}
// ---------- movement helpers ----------
function setPath(c, path) { c.path = path; c.pathI = 0; }
function followPath(c, dtSec) {
if (!c.path || c.pathI >= c.path.length) return;
const [gx, gz] = cellWorld(c.path[c.pathI][0], c.path[c.pathI][1]);
moveDir(c, gx - c.pos[0], gz - c.pos[1], dtSec);
if (dist(c.pos, [gx, gz]) < 0.16) c.pathI++;
}
function moveTo(c, tx, tz, dtSec) {
moveDir(c, tx - c.pos[0], tz - c.pos[1], dtSec);
}
function moveDir(c, dx, dz, dtSec) {
const len = Math.hypot(dx, dz);
if (len < 1e-4) return;
const sp = c.speed * dtSec;
const step = Math.min(sp, len);
c.pos[0] += (dx / len) * step;
c.pos[1] += (dz / len) * step;
c.targetFacing = Math.atan2(dx, dz);
c.animPhase += dtSec * 9;
}
function faceTowards(c, x, z) { c.targetFacing = Math.atan2(x - c.pos[0], z - c.pos[1]); }
export function dist(a, b) { return Math.hypot(a[0] - b[0], a[1] - b[1]); }
// front-of-furniture reachable cell
function furnFrontCell(f) {
const [W, H] = G.gridSize();
const rot = f.rot % 2;
const candidates = rot === 0
? [[f.cx, f.cz + 1], [f.cx + 1, f.cz + 1], [f.cx, f.cz - 1], [f.cx + 1, f.cz - 1]]
: [[f.cx - 1, f.cz], [f.cx - 1, f.cz + 1], [f.cx + 1, f.cz], [f.cx + 1, f.cz + 1]];
for (const [x, z] of candidates) if (isWalkable(x, z)) return [x, z];
return [Math.floor(W / 2), Math.floor(H / 2)];
}
let lastGridFurnCount = -1;
function rebuildGridIfNeeded() {
if (lastGridFurnCount !== G.data.furniture.length) {
rebuildGrid();
lastGridFurnCount = G.data.furniture.length;
}
}
export function forceGridRebuild() { rebuildGrid(); lastGridFurnCount = G.data.furniture.length; }
// ---------- end of day cleanup ----------
export function clearCustomers() {
for (const c of [...G.customers]) removeCustomer(c);
}
+115
View File
@@ -0,0 +1,115 @@
import G from './state.js';
import { WEATHERS, DIFFICULTIES } from '../data/furniture.js';
import { generateOrders } from './economy.js';
import { chance, choice, clamp, emit } from '../util.js';
// ============================================================
// DAY / TIME CYCLE
// minutes run 450 (7:30) → 1290 (21:30). Shop open 9:0019:00.
// speeds: game-minutes per real second at 1x = 1.7
// ============================================================
export const TIME = {
start: 7.5 * 60,
end: 21.5 * 60,
openAt: 9 * 60,
closeAt: 19 * 60,
minPerSec: [0, 1.7, 4.2, 10], // pause, 1x, 2x, 3x
};
export function isShopOpenHour() {
const m = G.shop.minutes;
return m >= TIME.openAt && m < TIME.closeAt && G.shop.isOpen;
}
let closeRequested = false;
export function updateDay(dtRealSec) {
const d = G.data;
const speedIdx = G.paused ? 0 : G.timeScale;
const dtMin = dtRealSec * TIME.minPerSec[speedIdx];
if (dtMin <= 0) return;
G.shop.minutes += dtMin;
// ---- automatic transitions ----
const m = G.shop.minutes;
if (!G.shop.isOpen && m >= TIME.openAt && m < TIME.closeAt) {
// a Manager auto-opens the shop for you
const manager = G.staffByRole('manager')[0];
if (manager && !d.shop.isOpenAnnounced) {
d.shop.isOpenAnnounced = true;
G.shop.isOpen = true;
G.shop.phase = 'open';
import('../util.js').then(({ toast }) => toast(`${manager.name} opened the shop for you! ✨`, 'good', '📋'));
emit('shopOpened');
}
d.shop.phase = m >= TIME.openAt ? 'open' : 'prep';
}
if (!G.shop.isOpen && m < TIME.openAt) d.shop.phase = 'prep';
if (m >= TIME.closeAt && G.shop.isOpen) G.shop.phase = 'evening';
if (m >= TIME.closeAt && G.shop.isOpen && !closeRequested) {
closeRequested = true;
emit('requestClose'); // UI shows report flow
}
}
// called when player ends the day (after report)
export function nextDay() {
const d = G.data;
d.shop.day++;
d.shop.minutes = TIME.start;
d.shop.phase = 'prep';
d.shop.isOpen = false;
d.shop.isOpenAnnounced = false;
closeRequested = false;
d.today = { revenue: 0, expenses: 0, served: 0, complaints: 0, thefts: 0, theftLoss: 0,
satisfactionSum: 0, satisfactionN: 0, unitsSold: {} };
// season every 6 days, weather roll
if ((d.shop.day - 1) % 6 === 0) d.shop.seasonIdx = Math.floor((d.shop.day - 1) / 6) % 4;
rollWeather();
// expire demand modifiers & contracts & discounts
for (const k of Object.keys(d.demandMods)) {
if (d.demandMods[k].untilDay <= d.shop.day) delete d.demandMods[k];
}
for (const s of Object.values(d.suppliers)) {
if (s.contractDays > 0) s.contractDays--;
if (s.discountDays > 0) s.discountDays--;
if (s.contractDays > 0) d.flags.hadContract = true;
}
// rival shop arrives on the difficulty's schedule
const diff = DIFFICULTIES[G.data.difficulty] || DIFFICULTIES.normal;
if (d.shop.day >= (diff.competitorDay || 999) && !d.flags.competitorArrived) {
d.flags.competitorArrived = true;
d.town.competitors.fancy = { name: 'Fancy Emporium', sinceDay: d.shop.day };
import('../util.js').then(({ toast }) =>
toast('🎀 A rival opened across the plaza — the Fancy Emporium!', 'warn', '⚠️'));
}
G.refreshCandidates();
generateOrders();
emit('newDay');
}
export function rollWeather() {
const season = G.season().id;
const r = Math.random();
let w;
if (season === 'winter') w = r < .38 ? 'snow' : r < .68 ? 'cloudy' : r < .92 ? 'sunny' : 'storm';
else if (season === 'autumn') w = r < .35 ? 'rain' : r < .5 ? 'storm' : r < .8 ? 'cloudy' : 'sunny';
else if (season === 'spring') w = r < .28 ? 'rain' : r < .38 ? 'storm' : r < .66 ? 'sunny' : 'cloudy';
else w = r < .62 ? 'sunny' : r < .82 ? 'cloudy' : r < .95 ? 'rain' : 'storm';
G.shop.weatherId = w;
}
export function footfallMult() {
const w = WEATHERS[G.shop.weatherId];
let f = w.footfall;
if (G.isFestivalDay()) f *= 1.8;
f *= 1 + G.attractionScore() * 0.02; // decor pulls people in
f *= 0.55 + G.shop.rep / 90; // reputation pulls people in
if (G.data.flags.competitorArrived) f *= 0.86; // a rival splits the crowd
return f;
}
+237
View File
@@ -0,0 +1,237 @@
import G from './state.js';
import { PRODUCT_BY_ID, PRODUCTS } from '../data/products.js';
import { SUPPLIER_BY_ID } from '../data/suppliers.js';
import { SHOP_LEVELS, DIFFICULTIES } from '../data/furniture.js';
import { rand, chance, clamp, emit, toast, choice } from '../util.js';
// ============================================================
// ECONOMY — buying, selling, pricing, daily accounting
// ============================================================
export function supplierUnitPrice(supId, pid) {
const p = PRODUCT_BY_ID[pid];
const q = SUPPLIER_BY_ID[supId].quality;
const midQ = (q[0] + q[1]) / 2;
return Math.max(1, Math.round(p.buy * G.supplierPriceMult(supId) * (0.85 + midQ * 0.3)));
}
// returns {ok, reason} ; quality rolled per lot
export function buyFromSupplier(supId, pid, qty, opts = {}) {
const s = SUPPLIER_BY_ID[supId];
let unit = supplierUnitPrice(supId, pid);
if (opts.bulk) unit = Math.round(unit * 0.92);
const cost = unit * qty;
if (G.gold < cost) return { ok: false, reason: 'Not enough gold' };
const used = G.usedStorage();
const space = G.storageCap() - used;
if (space < qty) return { ok: false, reason: `Not enough storage (${space} free)` };
// reliability: small chance of a hiccup
const relMod = G.data.flags.caravanSlow === G.shop.day ? -0.25 : 0;
const q = s.quality;
const contractBonus = G.data.suppliers[supId]?.contractDays > 0 ? 0.06 : 0;
const quality = +(rand(q[0], q[1]) + contractBonus).toFixed(2);
G.addGold(-cost);
G.data.today.expenses += cost;
if (chance(Math.max(0.03, s.reliability + relMod - 1))) {
// shipment problem: half arrives, refund
G.addItems(pid, Math.ceil(qty / 2), quality);
G.addGold(Math.round(cost * 0.4));
toast(`Shipment trouble at ${s.name}! Half arrived, partially refunded.`, 'bad', '📦');
G.bumpRel(supId, 2);
} else {
G.addItems(pid, qty, quality);
G.bumpRel(supId, Math.min(3, 0.5 + qty * 0.08));
}
emit('stockChanged');
return { ok: true, cost };
}
// move stock storage -> shelf
export function stockShelf(furn, pid, qty) {
const inv = G.data.inventory[pid];
if (!inv || inv.qty <= 0) return false;
const cap = shelfCapacity(furn);
const stock = G.data.shelfStock[furn.id] ||= {};
const cur = stock[pid] || 0;
const room = cap - Object.values(stock).reduce((a, b) => a + b, 0);
const move = Math.min(qty, inv.qty, room);
if (move <= 0) return false;
inv.qty -= move;
if (inv.qty <= 0) delete G.data.inventory[pid];
stock[pid] = cur + move;
emit('stockChanged');
return true;
}
export function unstockShelf(furn, pid, qty) {
const stock = G.data.shelfStock[furn.id];
if (!stock || !stock[pid]) return false;
const move = Math.min(qty, stock[pid]);
stock[pid] -= move;
if (stock[pid] <= 0) delete stock[pid];
G.addItems(pid, move);
emit('stockChanged');
return true;
}
export function shelfCapacity(furn) {
if (!furn) return 0;
return furn.type === 'table' ? 6 : furn.type === 'pedestal' ? 1 : 12;
}
// ---------- customer purchase evaluation ----------
// Returns willingness 0..1 for this customer to buy pid at current price.
export function purchaseWillingness(cust, pid) {
const p = PRODUCT_BY_ID[pid];
const price = G.priceOf(pid);
const quality = G.data.inventory[pid]?.quality ?? G.shelfQualityOf?.(pid) ?? 1;
const fairValue = p.sell * quality * (0.92 + Math.min(0.5, G.demandMult(pid)) * 0.16);
const ratio = price / fairValue; // ~0.95 = fair
const slope = 2.1 / Math.pow(Math.max(0.6, cust.tolerance), 1.35);
let w = clamp(0.88 - (ratio - 0.95) * slope, 0.03, 1);
// generous patrons stretch further on pricey goods
if (ratio > 1 && cust.tolerance > 1) w += (cust.tolerance - 1) * 0.25;
// demand heat helps
w *= 0.8 + Math.min(0.35, G.demandMult(pid) * 0.15);
// mood & loyalty
w *= 0.9 + cust.mood * 0.25;
w *= 1 + cust.loyalty / 500;
return clamp(w, 0.02, 1);
}
export function sellToCustomer(cust, furn, pid, qty = 1) {
const price = G.priceOf(pid);
const stock = G.data.shelfStock[furn.id];
if (!stock || !stock[pid]) return null;
stock[pid] -= qty;
if (stock[pid] <= 0) delete stock[pid];
const p = PRODUCT_BY_ID[pid];
const w = purchaseWillingness(cust, pid);
const satisfaction = clamp(w + (cust.mood - 0.5) * 0.2, 0.05, 1);
G.recordSale(pid, price, satisfaction);
G.addGold(price);
cust.gold -= price;
// relationship & rep ripples
if (cust.storyId) {
G.bumpRel(cust.storyId, satisfaction > 0.7 ? 2 : 0.5);
if (satisfaction < 0.35) G.bumpRel(cust.storyId, -1);
}
G.addRep(satisfaction > 0.75 ? 0.12 : satisfaction > 0.45 ? 0.04 : -0.06);
emit('stockChanged');
return { price, satisfaction };
}
// ---------- end of day accounting ----------
export function closeDayAccounting() {
const d = G.data;
const diff = DIFFICULTIES[d.difficulty];
const wages = d.staff.reduce((a, s) => a + s.salary, 0);
const rent = Math.round((12 + d.shop.level * d.shop.level * 14) * diff.rentMult);
const tax = Math.round(Math.max(0, d.today.revenue - wages - rent) * diff.taxRate);
const expenses = wages + rent + tax;
d.today.expenses += expenses;
const profit = d.today.revenue - d.today.expenses;
// staff sticky fingers
let stolen = 0;
for (const s of d.staff) {
if (s.traits.includes('sticky') && !s.traits.includes('honest') && chance(0.25)) {
stolen += Math.round(d.today.revenue * 0.03) + rand(2, 8);
}
}
if (stolen > 0) {
G.addGold(-stolen);
profit -= stolen;
d.today.theftLoss += stolen;
}
// reputation drift from today's vibes
const sat = d.today.satisfactionN ? d.today.satisfactionSum / d.today.satisfactionN : 0.7;
let repDelta = (sat - 0.62) * 4;
if (d.today.complaints > 3) repDelta -= (d.today.complaints - 3) * 0.4;
if (d.today.served === 0) repDelta -= 1.5;
if (d.today.thefts > 0) repDelta -= 0.5 * d.today.thefts;
const appliedRep = G.addRep(repDelta);
// history
d.history.revenue.push(Math.round(d.today.revenue));
d.history.profit.push(Math.round(profit));
d.history.customers.push(d.today.served);
d.history.satisfaction.push(+sat.toFixed(2));
for (const k of ['revenue', 'profit', 'customers', 'satisfaction']) {
if (d.history[k].length > 14) d.history[k].shift();
}
d.stats.totalProfit += profit;
d.stats.bestDayRevenue = Math.max(d.stats.bestDayRevenue, d.today.revenue);
d.shop.isOpen = false;
d.shop.phase = 'closed';
emit('dayClosed', { profit });
return {
revenue: d.today.revenue, expenses: d.today.expenses, profit,
wages, rent, tax, stolen, sat, repDelta: +appliedRep.toFixed(1),
bestSeller: bestSellerToday(), worstSeller: worstSellerToday(),
};
}
function bestSellerToday() {
const u = G.data.today.unitsSold;
let best = null, bv = -1;
for (const [pid, n] of Object.entries(u)) {
const v = n * G.priceOf(pid);
if (v > bv) { bv = v; best = pid; }
}
return best ? { pid: best, value: bv, units: u[best] } : null;
}
function worstSellerToday() {
const stocked = new Set();
for (const s of Object.values(G.data.shelfStock)) for (const pid of Object.keys(s)) stocked.add(pid);
const sold = G.data.today.unitsSold;
let worst = null, bw = Infinity;
for (const pid of stocked) {
const n = sold[pid] || 0;
if (n < bw) { bw = n; worst = pid; }
}
return worst ? { pid: worst, units: bw } : null;
}
// ---------- shop expansion ----------
export function expandShop() {
const next = SHOP_LEVELS[G.shop.level]; // level index = level-1, so [level] is next
if (!next) return { ok: false, reason: 'Max level reached!' };
if (G.gold < next.cost) return { ok: false, reason: 'Need more gold' };
if (G.shop.rep < next.repNeed) return { ok: false, reason: `Needs ${next.repNeed} reputation` };
G.addGold(-next.cost);
G.data.today.expenses += next.cost;
G.shop.level++;
emit('shopExpanded');
return { ok: true, name: next.name };
}
// ---------- delivery orders board (unlocks lvl 3) ----------
export function generateOrders() {
const d = G.data;
d.town.orders = d.town.orders.filter(o => !o.done);
while (d.town.orders.length < 3) {
const pool = PRODUCTS.filter(p => p.rarity <= 2 + (d.shop.rep > 30 ? 2 : 1));
const p = pool[Math.floor(rand(0, pool.length))];
const qty = 2 + Math.floor(rand(0, 4));
const pay = Math.round(p.sell * qty * rand(1.25, 1.55));
d.town.orders.push({
id: Math.random().toString(36).slice(2, 8), pid: p.id, qty, pay,
from: choice(['The Sleepy Dragon Tavern', 'Castle Kitchens', 'The Guild Hall', 'Riverside Inn', 'Temple of Dawn']),
done: false,
});
}
}
export function fulfillOrder(order) {
const inv = G.data.inventory[order.pid];
if (!inv || inv.qty < order.qty) return { ok: false, reason: 'Not enough stock' };
G.takeItems(order.pid, order.qty);
G.addGold(order.pay);
G.data.today.expenses += 0;
order.done = true;
G.addRep(0.6);
const isLuxury = ['jewelry', 'goblet', 'silk', 'perfume'].includes(order.pid);
if (isLuxury || order.from.includes('Castle')) G.data.stats.royalDeliveries += order.qty;
emit('stockChanged');
return { ok: true };
}
+82
View File
@@ -0,0 +1,82 @@
import G from './state.js';
import { EVENTS } from '../data/events.js';
import { chance, emit, toast, choice } from '../util.js';
// ============================================================
// EVENT RUNTIME — picks data-driven events and applies effects
// ============================================================
let eventCooldownMin = 0;
export function updateEvents(dtMin) {
const d = G.data;
if (!d) return;
eventCooldownMin -= dtMin;
// expire active demand-mod events
d.events.active = d.events.active.filter(e => e.untilDay > d.shop.day);
if (!G.shop.isOpen || G.customers.length === 0) return;
if (eventCooldownMin > 0) return;
if (d.shop.minutes < 630) return; // let the morning breathe
// ~1 event per day-ish
const alreadyToday = d.events.lastEventDay >= d.shop.day;
const maxActive = 2;
if (alreadyToday && !chance(0.008)) return;
if (d.events.active.length >= maxActive) return;
const pool = EVENTS.filter(e => {
if (e.repMin && d.shop.rep < e.repMin) return false;
if (e.needCompetitor && !Object.keys(d.town.competitors).length && !d.flags.competitorArrived) return false;
if (d.events.active.some(a => a.id === e.id)) return false;
return true;
});
if (!pool.length) return;
const ev = choice(pool);
fireEvent(ev);
}
export function fireEvent(ev) {
const d = G.data;
d.events.lastEventDay = d.shop.day;
eventCooldownMin = 240;
let outcome = null;
if (ev.apply) { ev.apply(G); }
if (ev.toast) toast(ev.toast, 'gold', ev.emoji);
if (ev.choices) {
emit('showEventModal', ev); // UI renders choices; resolve via resolveChoice
} else {
outcome = { text: ev.text };
showBanner(ev);
if (ev.demandTag) { /* handled in apply usually */ }
}
if (ev.lastDays) {
d.events.active.push({ id: ev.id, untilDay: d.shop.day + ev.lastDays });
}
return outcome;
}
export function resolveChoice(ev, choiceIdx) {
const ch = ev.choices[choiceIdx];
const out = ch.resolve ? ch.resolve(G) : { text: 'Done.' };
if (!out) return { text: 'Nothing happens.' };
if (out.gold) G.addGold(out.gold);
if (out.rep) G.addRep(out.rep);
if (out.item) G.addItems(out.item, 1);
if (out.demandTag) G.addDemandTag(out.demandTag, 1.8, out.days || 2);
if (out.relNpc === 'noble') G.data.flags.nobleFriend = true;
if (out.beauty) G.data.flags.catBeauty = out.beauty, G.data.flags.cat = true;
if (out.attract) G.addDemandTag('festival', out.attract / 10 + 1, 1);
showBanner(ev, out.text);
return out;
}
function showBanner(ev, textOverride) {
emit('eventBanner', {
emoji: ev.emoji,
title: textOverride ? ev.title : ev.title,
text: textOverride || ev.text,
});
}
+141
View File
@@ -0,0 +1,141 @@
import G from './state.js';
import { FURN_BY_ID } from '../data/furniture.js';
// ============================================================
// GRID + A* PATHFINDING over the shop floor
// ============================================================
let grid = { w: 0, h: 0, blocked: null };
export function rebuildGrid() {
const [W, H] = G.gridSize();
const blocked = new Uint8Array(W * H);
// walls
for (let x = 0; x < W; x++) { blocked[x] = 1; blocked[(H - 1) * W + x] = 1; }
for (let z = 0; z < H; z++) { blocked[z * W] = 1; blocked[z * W + W - 1] = 1; }
// door gap (front wall = max z side, centered, 2 wide)
const dx0 = Math.floor(W / 2) - 1;
blocked[(H - 1) * W + dx0] = 0;
blocked[(H - 1) * W + dx0 + 1] = 0;
// furniture
for (const f of G.data.furniture) {
const [fw, fd] = FURN_BY_ID[f.type]?.size || [1, 1];
const rot = f.rot % 2;
const w = rot ? fd : fw, d = rot ? fw : fd;
for (let ox = 0; ox < w; ox++) {
for (let oz = 0; oz < d; oz++) {
const x = f.cx + ox, z = f.cz + oz;
if (x >= 0 && x < W && z >= 0 && z < H) blocked[z * W + x] = 1;
}
}
}
grid = { w: W, h: H, blocked };
}
export function isWalkable(cx, cz) {
if (cx < 0 || cz < 0 || cx >= grid.w || cz >= grid.h) return false;
return !grid.blocked[cz * grid.w + cx];
}
// world-space walkability (for the player avatar)
export function isWalkableWorld(x, z) {
const [W, H] = G.gridSize();
const cx = Math.floor(x + W / 2), cz = Math.floor(z + H / 2);
return isWalkable(cx, cz);
}
export function cellWorld(cx, cz) {
const [W, H] = G.gridSize();
return [cx - W / 2 + 0.5, cz - H / 2 + 0.5];
}
export function worldCell(x, z) {
const [W, H] = G.gridSize();
return [Math.floor(x + W / 2), Math.floor(z + H / 2)];
}
export function doorCells() {
const [W, H] = G.gridSize();
const dx0 = Math.floor(W / 2) - 1;
return [[dx0, H - 1], [dx0 + 1, H - 1]];
}
const NEI = [[1, 0], [-1, 0], [0, 1], [0, -1]];
function nearestWalkable(x, z, maxR = 4) {
if (isWalkable(x, z)) return [x, z];
let best = null, bd = Infinity;
for (let r = 1; r <= maxR; r++) {
for (let ox = -r; ox <= r; ox++) for (let oz = -r; oz <= r; oz++) {
if (Math.max(Math.abs(ox), Math.abs(oz)) !== r) continue;
const nx = x + ox, nz = z + oz;
if (isWalkable(nx, nz)) {
const d = Math.abs(ox) + Math.abs(oz);
if (d < bd) { bd = d; best = [nx, nz]; }
}
}
if (best) return best;
}
return best;
}
export function findPath(sx, sz, tx, tz) {
// snap endpoints to walkable cells (customers often start outside)
const sFix = nearestWalkable(sx, sz);
if (!sFix) return null;
[sx, sz] = sFix;
if (!isWalkable(tx, tz)) {
const tFix = nearestWalkable(tx, tz, 3);
if (!tFix) return null;
[tx, tz] = tFix;
}
if (sx === tx && sz === tz) return [];
const key = (x, z) => z * grid.w + x;
const open = [{ x: sx, z: sz, g: 0, f: heur(sx, sz, tx, tz), parent: null }];
const came = new Map();
const gs = new Map([[key(sx, sz), 0]]);
while (open.length) {
let bi = 0;
for (let i = 1; i < open.length; i++) if (open[i].f < open[bi].f) bi = i;
const cur = open.splice(bi, 1)[0];
if (cur.x === tx && cur.z === tz) {
const path = [];
let n = cur;
while (n.parent) { path.push([n.x, n.z]); n = n.parent; }
return path.reverse();
}
for (const [ox, oz] of NEI) {
const nx = cur.x + ox, nz = cur.z + oz;
if (!isWalkable(nx, nz)) continue;
const ng = cur.g + 1;
const k = key(nx, nz);
if (gs.has(k) && gs.get(k) <= ng) continue;
gs.set(k, ng);
came.set(k, cur);
open.push({ x: nx, z: nz, g: ng, f: ng + heur(nx, nz, tx, tz), parent: cur });
}
}
return null;
}
function heur(x, z, tx, tz) { return Math.abs(x - tx) + Math.abs(z - tz); }
// random reachable browsing spot near given cell
export function randomBrowseNear(cx, cz, spread = 3) {
for (let i = 0; i < 12; i++) {
const x = cx + Math.floor((Math.random() * 2 - 1) * spread);
const z = cz + Math.floor((Math.random() * 2 - 1) * spread);
if (isWalkable(x, z)) return [x, z];
}
return null;
}
// pick a free queue slot in front of the counter (index-th person)
export function counterQueueSpot(index) {
const counter = G.data.furniture.find(f => f.type === 'counter');
const [W, H] = G.gridSize();
if (!counter) return cellWorld(Math.floor(W / 2), H - 3);
const list = [];
for (let i = 0; i < 6; i++) {
const x = counter.cx + (i % 2);
const z = Math.min(H - 2, counter.cz + 2 + Math.floor(i / 2));
if (isWalkable(x, z)) list.push([x, z]);
}
const spot = list[Math.min(index, list.length - 1)] || [counter.cx, counter.cz + 2];
return cellWorld(spot[0], spot[1]);
}
+126
View File
@@ -0,0 +1,126 @@
import G from './state.js';
import { STAFF_TRAITS } from '../data/furniture.js';
import { clamp, chance, randi, choice, emit, toast } from '../util.js';
// ============================================================
// STAFF — hiring, daily work effects, loyalty, drama
// ============================================================
export function hireCandidate(candId) {
const d = G.data;
const cap = G.levelInfo().staffCap;
if (d.staff.length >= cap) return { ok: false, reason: `Staff cap is ${cap}. Expand the shop!` };
const idx = d.candidates.findIndex(c => c.id === candId);
if (idx < 0) return { ok: false, reason: 'Candidate gone' };
const c = d.candidates[idx];
d.staff.push({ ...c, hiredDay: d.shop.day });
d.candidates.splice(idx, 1);
toast(`${c.name} joins as your ${c.role}! Say hi! 👋`, 'good', '🤝');
emit('staffChanged');
return { ok: true };
}
export function fireStaff(id) {
const i = G.data.staff.findIndex(s => s.id === id);
if (i < 0) return;
const s = G.data.staff[i];
G.data.staff.splice(i, 1);
G.addRep(-1);
toast(`${s.name} was let go. Word gets around… (-1 reputation)`, 'bad', '📦');
// friends get sad
for (const o of G.data.staff) o.loyalty = clamp(o.loyalty - 6, 5, 100);
emit('staffChanged');
}
export function giveRaise(s) {
s.salary = Math.round(s.salary * 1.15);
s.loyalty = clamp(s.loyalty + 18, 0, 100);
toast(`${s.name} beams — “Best boss ever!” (+loyalty)`, 'good', '🥰');
emit('staffChanged');
}
// called each morning
export function staffMorning() {
const d = G.data;
for (const s of [...d.staff]) {
// loyalty drift toward fairness
const fairPay = 18 + s.skill * 0.42;
if (s.salary < fairPay * 0.85) s.loyalty -= 2;
else s.loyalty += 1;
s.loyalty = clamp(s.loyalty + randi(-1, 1), 0, 100);
// quit?
if (s.loyalty < 22 && chance(0.5)) {
d.staff.splice(d.staff.indexOf(s), 1);
toast(`${s.name} packed up and QUIT! “I deserve better…”`, 'bad', '🚪');
G.addRep(-0.5);
continue;
}
// raise request
if (s.loyalty < 45 && s.skill > 60 && chance(0.3)) {
emit('raiseRequest', s);
}
// grumpy clashes
const otherGrump = d.staff.find(o => o !== s && o.traits.includes('grumpy') && s.traits.includes('grumpy'));
if (otherGrump && chance(0.25)) {
toast(`${s.name} and ${otherGrump.name} argue about shelf organization… again.`, '', '😤');
s.loyalty -= 1; otherGrump.loyalty -= 1;
}
if (s.traits.includes('sticky') && !s.traits.includes('honest') && chance(0.12)) {
const loss = Math.min(G.gold, randi(4, 14));
G.addGold(-loss);
toast(`You catch ${s.name} pocketing ${loss}g from the till!`, 'bad', '🖐️');
}
}
// friendship boosts between friendly folks
const friendlies = d.staff.filter(s => s.traits.includes('friendly'));
if (friendlies.length === d.staff.length && d.staff.length >= 2) {
for (const s of friendlies) s.loyalty = clamp(s.loyalty + 1, 0, 100);
}
}
// speed multiplier a role benefits from
export function traitSpeed(s) {
let m = 1;
for (const t of s.traits) m *= STAFF_TRAITS[t]?.speed ?? 1;
return m;
}
export function traitSatBonus(s) {
let b = 0;
for (const t of s.traits) b += STAFF_TRAITS[t]?.satBonus ?? 0;
return b;
}
// stocker auto-restock tick (during open hours)
let restockTimer = 0;
export function updateStocker(dtMin) {
const stockers = G.staffByRole('stocker');
if (!stockers.length || !G.shop.isOpen) return;
restockTimer += dtMin;
const interval = 30 / traitSpeed(stockers[0]);
if (restockTimer < interval) return;
restockTimer = 0;
// move most-demanded item from storage to any shelf with room
for (const [pid, lot] of Object.entries(G.data.inventory)) {
if (lot.qty <= 0) continue;
for (const f of G.shelves()) {
const cap = f.type === 'table' ? 6 : f.type === 'pedestal' ? 1 : 12;
const stock = G.data.shelfStock[f.id] ||= {};
const used = Object.values(stock).reduce((a, b) => a + b, 0);
if (used >= cap) continue;
const move = Math.min(lot.qty, cap - used);
lot.qty -= move;
stock[pid] = (stock[pid] || 0) + move;
if (lot.qty <= 0) delete G.data.inventory[pid];
emit('stockChanged');
return; // one box at a time — they're chill
}
}
}
// cleaner keeps beauty up (visual sparkle handled by gfx)
export function cleanerBonus() {
const cleaners = G.staffByRole('cleaner');
if (!cleaners.length) return 0;
return Math.round(cleaners.reduce((a, c) => a + c.skill / 40, 0));
}
+231
View File
@@ -0,0 +1,231 @@
import { clamp, uid, choice, randi } from '../util.js';
import { PRODUCTS, PRODUCT_BY_ID } from '../data/products.js';
import { SUPPLIERS, SUPPLIER_BY_ID } from '../data/suppliers.js';
import { SHOP_LEVELS, SEASONS, DIFFICULTIES, FURN_BY_ID } from '../data/furniture.js';
import { STORY_NPCS, NAMES_F, NAMES_M } from '../data/customers.js';
const ALL_NAMES = [...NAMES_F, ...NAMES_M];
const ROLE_SALARY = { cashier: 1, shopkeeper: 1.05, stocker: 0.9, guard: 1, cleaner: 0.8, merchant: 1.1, manager: 1.35 };
// ============================================================
// GLOBAL GAME STATE — single source of truth.
// G.data is fully serializable; runtime stuff lives beside it.
// ============================================================
class Game {
constructor() {
this.data = null;
this.customers = []; // live customer entities (runtime)
this.timeScale = 1;
this.paused = false;
this.mode = 'menu'; // menu | play | decorate
this.onGoldChanged = null; // fn(delta)
this.onSale = null; // fn(pid, unitPrice, satisfaction)
}
newGame(opts) {
const diff = DIFFICULTIES[opts.difficulty || 'normal'];
const suppliers = {};
for (const s of SUPPLIERS) {
suppliers[s.id] = { rel: 10 + randi(0, 8), contractDays: 0, discountDays: 0, unlocked: s.unlockRep <= 0 };
}
const customersDb = {};
for (const n of STORY_NPCS) customersDb[n.id] = 5;
this.data = {
version: 3,
meta: { name: opts.name || 'Tiny Shop', createdAt: Date.now(), savedAt: null },
difficulty: opts.difficulty || 'normal',
shop: {
level: 1, gold: diff.gold, rep: 0,
day: 1, minutes: 7 * 60 + 30, phase: 'prep', isOpen: false,
seasonIdx: 0, weatherId: 'sunny',
},
player: { hair: opts.hair ?? 2, skin: opts.skin ?? 1, outfit: opts.outfit ?? 0, x: 0, z: 3.5 },
inventory: {}, // pid -> {qty, quality}
prices: {}, // pid -> retail price override
shelfStock: {}, // furnitureId -> { pid: qty }
furniture: [], // {id, type, cx, cz, rot}
staff: [],
candidates: [],
suppliers,
customersDb,
history: { revenue: [], profit: [], customers: [], satisfaction: [] },
today: { revenue: 0, expenses: 0, served: 0, complaints: 0, thefts: 0, theftLoss: 0,
satisfactionSum: 0, satisfactionN: 0, unitsSold: {} },
stats: { totalCustomers: 0, totalRevenue: 0, totalProfit: 0, rainDayCustomers: 0, royalDeliveries: 0, bestDayRevenue: 0 },
quests: { activeIdx: 0, completed: [] },
town: { prosperity: 0, competitors: {}, orders: [] },
events: { active: [], lastEventDay: 0 },
demandMods: {}, // key -> {mult, untilDay} key = 'tag:x' | 'p:pid'
flags: {},
settings: { music: 0.6, sfx: 0.8, quality: 'high' },
};
// starter layout: two shelves + counter near door + one plant
const [W, H] = this.gridSize();
const counterCx = Math.floor(W / 2) - 1;
const counterCz = H - 3;
this.data.furniture = [
{ id: uid(), type: 'shelf', cx: Math.floor(W / 2) - 4, cz: 1, rot: 0 },
{ id: uid(), type: 'shelf', cx: Math.floor(W / 2) + 2, cz: 1, rot: 0 },
{ id: uid(), type: 'counter', cx: counterCx, cz: counterCz, rot: 0 },
{ id: uid(), type: 'plant', cx: 1, cz: 1, rot: 0 },
];
// the owner starts right behind the till
this.data.player.x = counterCx - W / 2 + 0.5;
this.data.player.z = Math.min(H - 1.6, counterCz - H / 2 + 1.8);
// starting stock so day one feels alive
this.data.inventory.bread = { qty: 6, quality: 1 };
this.data.inventory.apple = { qty: 6, quality: 1 };
this.data.inventory.potion = { qty: 3, quality: 1 };
this.refreshCandidates();
return this.data;
}
// ---------- helpers ----------
get shop() { return this.data.shop; }
get gold() { return this.data.shop.gold; }
gridSize() { return SHOP_LEVELS[this.data.shop.level - 1].grid; }
levelInfo() { return SHOP_LEVELS[this.data.shop.level - 1]; }
season() { return SEASONS[this.data.shop.seasonIdx % 4]; }
priceOf(pid) { return this.data.prices[pid] ?? PRODUCT_BY_ID[pid].sell; }
addGold(n) {
this.data.shop.gold += n;
if (this.onGoldChanged) this.onGoldChanged(n);
}
addRep(n) {
const before = this.data.shop.rep;
this.data.shop.rep = clamp(this.data.shop.rep + n, 0, 100);
return Math.round((this.data.shop.rep - before) * 10) / 10;
}
storageCap() { return this.levelInfo().storage + this.furnCount('crate') * 20; }
usedStorage() { let u = 0; for (const pid of Object.keys(this.data.inventory)) u += this.data.inventory[pid].qty; return u; }
furnCount(type) { return this.data.furniture.filter(f => f.type === type).length; }
decorCount() { return this.data.furniture.filter(f => !['shelf', 'counter', 'crate'].includes(f.type)).length; }
shelves() { return this.data.furniture.filter(f => ['shelf', 'table', 'pedestal'].includes(f.type)); }
shelfStock(f) { return this.data.shelfStock[f.id] || {}; }
hasCounter() { return this.furnCount('counter') > 0; }
staffByRole(role) { return this.data.staff.filter(s => s.role === role); }
beautyScore() {
let b = 2 + this.data.shop.level;
for (const f of this.data.furniture) b += (FURN_BY_ID[f.type]?.stats.beauty || 0);
if (this.data.flags.cat) b += 5;
return Math.round(b);
}
comfortScore() { let c = 1; for (const f of this.data.furniture) c += (FURN_BY_ID[f.type]?.stats.comfort || 0); return Math.round(c); }
luxuryScore() { let l = 0; for (const f of this.data.furniture) l += (FURN_BY_ID[f.type]?.stats.luxury || 0); return Math.round(l); }
attractionScore() {
let a = 4 + this.data.shop.level * 2;
for (const f of this.data.furniture) a += (FURN_BY_ID[f.type]?.stats.attraction || 0);
a += Math.min(12, Object.keys(this.data.inventory).length); // variety attracts
return Math.round(a);
}
demandMult(pid) {
const p = PRODUCT_BY_ID[pid];
let m = 1;
const mods = this.data.demandMods;
if (mods[`p:${pid}`]) m *= mods[`p:${pid}`].mult;
for (const t of p.tags) if (mods[`tag:${t}`]) m *= mods[`tag:${t}`].mult;
if (this.season().tags.some(t => p.tags.includes(t))) m *= 1.45;
const w = this.data.shop.weatherId;
if ((w === 'rain' || w === 'storm') && p.tags.includes('rain')) m *= 3.2;
if (w === 'snow' && p.tags.includes('cold')) m *= 2.6;
if (this.isFestivalDay() && (p.tags.includes('festival') || p.cat === 'food')) m *= 1.9;
return m;
}
isFestivalDay() { return this.data.shop.day % 7 === 0; }
addDemandTag(tag, mult, days) { this.data.demandMods[`tag:${tag}`] = { mult, untilDay: this.data.shop.day + days }; }
addDemand(pid, mult, days) { this.data.demandMods[`p:${pid}`] = { mult, untilDay: this.data.shop.day + days }; }
addItems(pid, qty, quality = 1) {
const inv = this.data.inventory;
if (!inv[pid]) inv[pid] = { qty: 0, quality };
const totalQty = inv[pid].qty + qty;
inv[pid].quality = (inv[pid].quality * inv[pid].qty + quality * qty) / totalQty;
inv[pid].qty = totalQty;
}
takeItems(pid, qty) {
const inv = this.data.inventory;
if (!inv[pid] || inv[pid].qty <= 0) return false;
inv[pid].qty -= qty;
if (inv[pid].qty <= 0) delete inv[pid];
return true;
}
takeCategory(cat, n) {
let left = n;
for (const pid of Object.keys(this.data.inventory)) {
if (left <= 0) break;
if (PRODUCT_BY_ID[pid].cat !== cat) continue;
const take = Math.min(left, this.data.inventory[pid].qty);
this.takeItems(pid, take); left -= take;
}
return n - left;
}
recordSale(pid, unitPrice, satisfaction) {
const t = this.data.today;
t.revenue += unitPrice;
t.unitsSold[pid] = (t.unitsSold[pid] || 0) + 1;
t.served++;
t.satisfactionSum += satisfaction; t.satisfactionN++;
this.data.stats.totalCustomers++;
this.data.stats.totalRevenue += unitPrice;
if (this.data.shop.weatherId === 'rain') this.data.stats.rainDayCustomers++;
if (this.onSale) this.onSale(pid, unitPrice, satisfaction);
}
avgSatisfactionToday() { return this.data.today.satisfactionN ? this.data.today.satisfactionSum / this.data.today.satisfactionN : 0.75; }
relOf(npcId) { return this.data.customersDb[npcId] ?? 0; }
bumpRel(npcId, amt) { this.data.customersDb[npcId] = clamp((this.data.customersDb[npcId] ?? 0) + amt, 0, 100); }
supplierRel(id) { return this.data.suppliers[id]?.rel ?? 0; }
supplierPriceMult(id) {
const base = SUPPLIER_BY_ID[id].priceMult;
const rel = this.supplierRel(id);
const merchantDisc = this.staffByRole('merchant').reduce((m, s) => m + s.skill / 400, 0);
const sd = this.data.suppliers[id] || {};
const contract = sd.contractDays > 0 ? 0.92 : 1;
const discount = sd.discountDays > 0 ? 0.9 : 1;
return base * (1 - Math.min(0.15, rel / 500)) * (1 - merchantDisc) * contract * discount;
}
refreshCandidates() {
const roles = ['cashier', 'shopkeeper', 'stocker', 'guard', 'cleaner', 'merchant', 'manager'];
this.data.candidates = [];
for (let i = 0; i < 4; i++) this.data.candidates.push(this.makeCandidate(choice(roles)));
}
makeCandidate(role) {
const traitsPool = ['friendly', 'hardworking', 'lazy', 'funny', 'chatty', 'honest', 'sticky', 'grumpy', 'quick'];
const traits = [];
const nT = randi(1, 3);
while (traits.length < nT) { const t = choice(traitsPool); if (!traits.includes(t)) traits.push(t); }
const skill = randi(38, 92);
return {
id: uid(), name: choice(ALL_NAMES), role, skill, traits,
salary: Math.max(8, Math.round((18 + skill * 0.42) * ROLE_SALARY[role])),
loyalty: randi(50, 70),
};
}
// ---------- serialization ----------
serialize() {
const d = JSON.parse(JSON.stringify(this.data));
d.meta.savedAt = Date.now();
return d;
}
load(d) {
this.data = d;
this.customers = [];
this.paused = false;
this.timeScale = 1;
return d;
}
}
export const G = new Game();
export default G;
+397
View File
@@ -0,0 +1,397 @@
/* ============================================================
TINY SHOP — cozy UI theme
============================================================ */
/* Nunito loaded asynchronously in main.js so it never blocks first paint */
:root {
--cream: #fff8ea;
--cream2: #ffefd2;
--paper: #fffdf6;
--wood: #a9713d;
--wood-dark: #7c5227;
--ink: #574233;
--ink-soft: #8a7360;
--accent: #ff9f43;
--accent-deep: #f0831f;
--green: #58c98b;
--green-deep: #2fa06a;
--red: #ef6461;
--blue: #5aa9e6;
--gold: #ffd166;
--panel-r: 18px;
--shadow: 0 10px 30px rgba(87, 55, 20, .25), 0 2px 6px rgba(87, 55, 20, .15);
--font: 'Nunito', 'Segoe UI', system-ui, sans-serif;
}
* { box-sizing: border-box; -webkit-user-select: none; user-select: none; }
html, body { margin: 0; padding: 0; width: 100%; height: 100%; overflow: hidden; }
body { font-family: var(--font); color: var(--ink); background: #20140b; }
.mono { font-variant-numeric: tabular-nums; font-feature-settings: 'tnum'; }
.hidden { display: none !important; }
#game { position: fixed; inset: 0; }
#game canvas { display: block; }
#vignette {
position: fixed; inset: 0; pointer-events: none; z-index: 5;
background:
radial-gradient(ellipse at center, transparent 58%, rgba(38, 20, 5, 0.28) 100%);
mix-blend-mode: multiply;
}
#ui { position: fixed; inset: 0; z-index: 10; pointer-events: none; }
#ui > * { pointer-events: auto; }
/* ---------------- shared chips / buttons ---------------- */
.panel-chip {
background: linear-gradient(180deg, var(--paper), var(--cream));
border: 2px solid rgba(169, 113, 61, .35);
border-radius: 999px;
box-shadow: var(--shadow);
padding: 7px 16px;
display: inline-flex; align-items: center; gap: 8px;
font-weight: 800; font-size: 15px;
}
button { font-family: var(--font); cursor: pointer; }
.icon-btn {
width: 40px; height: 40px; border-radius: 50%;
background: linear-gradient(180deg, var(--paper), var(--cream));
border: 2px solid rgba(169, 113, 61, .35); box-shadow: var(--shadow);
font-size: 18px; display: inline-flex; align-items: center; justify-content: center;
transition: transform .12s ease;
}
.icon-btn:hover { transform: translateY(-2px) scale(1.05); }
.small-btn {
background: linear-gradient(180deg, var(--accent), var(--accent-deep));
color: white; border: none; border-radius: 12px; padding: 8px 14px;
font-weight: 800; font-size: 14px; box-shadow: 0 4px 0 rgba(0,0,0,.15), var(--shadow);
transition: transform .1s ease;
}
.small-btn:hover { transform: translateY(-1px); }
.small-btn:active { transform: translateY(1px); }
.small-btn.green { background: linear-gradient(180deg, #6fd6a0, var(--green-deep)); }
.small-btn.red { background: linear-gradient(180deg, #f98a88, #d94a48); }
.small-btn.blue { background: linear-gradient(180deg, #7cbdf0, #3f83c9); }
.small-btn:disabled { filter: grayscale(.7) brightness(.95); cursor: not-allowed; transform: none; }
/* ---------------- top bar ---------------- */
#topbar {
position: absolute; top: 14px; left: 16px; right: 16px;
display: flex; justify-content: space-between; align-items: flex-start;
gap: 10px; pointer-events: none;
}
#topbar > * { pointer-events: auto; }
.tb-left { gap: 10px; }
.tb-day { font-weight: 900; color: var(--wood-dark); }
.tb-sep { opacity: .35; }
.phase-pill {
font-size: 11px; font-weight: 900; letter-spacing: .08em;
border-radius: 999px; padding: 2px 10px; margin-left: 4px;
}
.phase-pill.open { background: var(--green); color: white; }
.phase-pill.closed { background: #cbb9a5; color: #6b563f; }
.tb-right { display: flex; align-items: center; gap: 8px; }
.stat-chip .ic { font-size: 17px; }
.stat-chip .unit { color: var(--ink-soft); font-weight: 700; }
.rep-tier {
font-size: 11px; font-weight: 900; letter-spacing: .04em;
background: rgba(255, 209, 102, .5); border-radius: 999px; padding: 2px 9px;
color: #7c5a13; text-transform: uppercase;
}
#tb-gold.bump { animation: goldBump .4s ease; }
@keyframes goldBump { 30% { transform: scale(1.25); color: var(--green-deep); } }
/* ---------------- time controls ---------------- */
#timebar {
position: absolute; right: 16px; top: 72px;
display: flex; flex-direction: column; gap: 6px; align-items: flex-end;
}
.tbtn {
width: 44px; height: 38px; border-radius: 12px;
background: linear-gradient(180deg, var(--paper), var(--cream));
border: 2px solid rgba(169, 113, 61, .35); box-shadow: var(--shadow);
font-size: 13px; font-weight: 900; color: var(--ink);
}
.tbtn.active { background: linear-gradient(180deg, var(--accent), var(--accent-deep)); color: white; }
.open-btn {
margin-top: 4px; height: 44px; padding: 0 18px; border-radius: 14px; border: none;
background: linear-gradient(180deg, #6fd6a0, var(--green-deep));
color: white; font-weight: 900; font-size: 15px; letter-spacing: .03em;
box-shadow: 0 5px 0 #1d7a4e, var(--shadow); transition: transform .1s ease, filter .2s;
}
.open-btn.close-mode { background: linear-gradient(180deg, #f9b26f, #e07b2a); box-shadow: 0 5px 0 #a4561c, var(--shadow); }
.open-btn:hover { transform: translateY(-2px); }
/* ---------------- bottom nav ---------------- */
#bottomnav {
position: absolute; bottom: 14px; left: 50%; transform: translateX(-50%);
display: flex; gap: 8px; padding: 8px;
background: rgba(255, 248, 234, .92);
border: 2px solid rgba(169, 113, 61, .3); border-radius: 22px;
box-shadow: var(--shadow); backdrop-filter: blur(6px);
}
.nav-btn {
position: relative; min-width: 74px; padding: 8px 10px 6px;
background: transparent; border: 2px solid transparent; border-radius: 16px;
display: flex; flex-direction: column; align-items: center; gap: 2px;
transition: background .15s, transform .1s;
}
.nav-btn:hover { background: rgba(255, 159, 67, .18); transform: translateY(-2px); }
.nav-btn.active { background: linear-gradient(180deg, var(--accent), var(--accent-deep)); }
.nav-btn.active .nl { color: white; }
.nav-btn.active { box-shadow: inset 0 -3px 0 rgba(0,0,0,.12); }
.ni { font-size: 21px; }
.nl { font-size: 12px; font-weight: 800; color: var(--ink-soft); }
.dot {
position: absolute; top: 4px; right: 8px; width: 10px; height: 10px;
background: var(--red); border-radius: 50%; border: 2px solid white;
}
/* ---------------- side panel ---------------- */
.side-panel {
position: absolute; left: 16px; top: 72px; bottom: 86px; width: 400px;
background: linear-gradient(180deg, var(--paper), var(--cream));
border: 2px solid rgba(169, 113, 61, .35); border-radius: var(--panel-r);
box-shadow: var(--shadow); display: flex; flex-direction: column;
animation: slideIn .25s cubic-bezier(.2,.9,.3,1.2); overflow: hidden;
}
@keyframes slideIn { from { transform: translateX(-40px); opacity: 0; } }
.sp-head {
padding: 14px 18px 10px; display: flex; justify-content: space-between; align-items: center;
border-bottom: 2px dashed rgba(169, 113, 61, .3);
}
.sp-title { font-size: 19px; font-weight: 900; color: var(--wood-dark); }
.sp-close {
width: 32px; height: 32px; border-radius: 50%; border: none; font-weight: 900;
background: rgba(169, 113, 61, .15); color: var(--ink); font-size: 15px;
}
.sp-body { flex: 1; overflow-y: auto; padding: 12px 14px 16px; }
.sp-body::-webkit-scrollbar { width: 9px; }
.sp-body::-webkit-scrollbar-thumb { background: rgba(169,113,61,.35); border-radius: 99px; }
.sp-sub { font-size: 12.5px; font-weight: 800; color: var(--ink-soft); text-transform: uppercase; letter-spacing: .07em; margin: 14px 2px 8px; }
.card {
background: white; border: 2px solid rgba(169,113,61,.22); border-radius: 14px;
padding: 11px 13px; margin-bottom: 10px;
}
.card.row { display: flex; align-items: center; gap: 11px; }
.card .grow { flex: 1; min-width: 0; }
.card h4 { margin: 0 0 2px; font-size: 15px; font-weight: 900; }
.card .desc { font-size: 12.5px; color: var(--ink-soft); line-height: 1.35; }
.tag {
display: inline-block; font-size: 10.5px; font-weight: 900; padding: 2px 8px;
border-radius: 999px; background: rgba(90,169,230,.18); color: #34719f; margin-right: 4px;
}
.tag.orange { background: rgba(255,159,67,.2); color: #b96a17; }
.tag.green { background: rgba(88,201,139,.2); color: #217a4c; }
.tag.red { background: rgba(239,100,97,.16); color: #b23431; }
.tag.purple { background: rgba(167,120,240,.2); color: #6d3fc0; }
.tag.gold { background: rgba(255,209,102,.35); color: #8a6410; }
/* progress bars */
.bar { height: 9px; background: rgba(169,113,61,.18); border-radius: 99px; overflow: hidden; margin-top: 5px; }
.bar > i { display: block; height: 100%; border-radius: 99px; background: linear-gradient(90deg, var(--accent), var(--accent-deep)); transition: width .5s ease; }
.bar.green > i { background: linear-gradient(90deg, #7fe0ac, var(--green-deep)); }
.bar.red > i { background: linear-gradient(90deg, #f98a88, #cf413f); }
.bar.blue > i { background: linear-gradient(90deg, #8ec7f2, #3f83c9); }
/* inputs */
input[type="range"] { accent-color: var(--accent-deep); width: 130px; }
input[type="number"], input[type="text"], select {
font-family: var(--font); font-weight: 700; color: var(--ink);
border: 2px solid rgba(169,113,61,.3); border-radius: 10px; padding: 6px 9px;
background: white; outline: none; font-size: 14px;
}
input[type="number"] { width: 84px; text-align: right; }
/* ---------------- inventory rows ---------------- */
.inv-row { display: grid; grid-template-columns: 34px 1fr auto; gap: 4px 10px; align-items: center; }
.inv-row .pemoji { font-size: 24px; text-align: center; }
.inv-row .pname { font-weight: 900; font-size: 14px; }
.inv-row .pmeta { font-size: 11.5px; color: var(--ink-soft); }
.inv-row .price-edit { display: flex; align-items: center; gap: 5px; }
.demand-arrow { font-size: 13px; margin-left: 4px; }
/* ---------------- decorate bar ---------------- */
#decorate-bar {
position: absolute; bottom: 88px; left: 50%; transform: translateX(-50%);
max-width: min(92vw, 980px);
background: rgba(255, 248, 234, .96); border: 2px solid rgba(169,113,61,.35);
border-radius: 18px; box-shadow: var(--shadow); padding: 10px 14px;
display: flex; align-items: center; gap: 12px;
}
.dec-title { font-weight: 900; color: var(--wood-dark); white-space: nowrap; }
#dec-catalog { display: flex; gap: 6px; overflow-x: auto; max-width: 520px; padding: 2px; }
.dec-item {
min-width: 64px; padding: 6px 8px; border-radius: 12px; border: 2px solid rgba(169,113,61,.25);
background: white; display: flex; flex-direction: column; align-items: center; gap: 1px; font-size: 11px; font-weight: 800;
}
.dec-item:hover { border-color: var(--accent); transform: translateY(-2px); }
.dec-item.sel { border-color: var(--accent-deep); background: #fff3dd; }
.dec-item .de { font-size: 21px; }
.dec-hint { font-size: 11.5px; color: var(--ink-soft); max-width: 210px; line-height: 1.3; }
/* ---------------- selection card ---------------- */
#selcard {
position: absolute; width: 250px;
background: linear-gradient(180deg, var(--paper), var(--cream));
border: 2px solid rgba(169,113,61,.4); border-radius: 16px; box-shadow: var(--shadow);
padding: 12px 14px; animation: popIn .18s cubic-bezier(.2,.9,.3,1.4);
}
@keyframes popIn { from { transform: scale(.85); opacity: 0; } }
#selcard h4 { margin: 0 0 4px; font-size: 16px; }
#selcard .sc-row { display: flex; justify-content: space-between; font-size: 12.5px; margin: 3px 0; }
#selcard .sc-actions { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 9px; }
#selcard .mood { font-size: 12px; font-weight: 800; }
/* ---------------- toasts ---------------- */
#toasts {
position: absolute; top: 76px; left: 50%; transform: translateX(-50%);
display: flex; flex-direction: column; align-items: center; gap: 7px; pointer-events: none !important;
}
.toast {
background: rgba(87, 66, 51, .94); color: #fff3df; font-weight: 800; font-size: 13.5px;
padding: 9px 18px; border-radius: 999px; box-shadow: var(--shadow);
animation: toastIn .3s cubic-bezier(.2,.9,.3,1.3), toastOut .4s ease 3.4s forwards;
display: flex; gap: 8px; align-items: center; max-width: 70vw;
}
.toast.gold { background: linear-gradient(180deg, #ffd166, #f0a93a); color: #5c4200; }
.toast.bad { background: rgba(160, 52, 47, .95); }
.toast.good { background: rgba(37, 122, 78, .95); }
@keyframes toastIn { from { transform: translateY(-14px) scale(.9); opacity: 0; } }
@keyframes toastOut { to { transform: translateY(-10px); opacity: 0; } }
/* ---------------- event banner ---------------- */
#event-banner {
position: absolute; top: 128px; left: 50%; transform: translateX(-50%);
background: linear-gradient(180deg, var(--paper), var(--cream2));
border: 2px solid var(--gold); border-radius: 16px; box-shadow: var(--shadow);
padding: 12px 20px; display: flex; gap: 13px; align-items: center; max-width: 480px;
animation: bannerIn .5s cubic-bezier(.2,.9,.3,1.3); z-index: 20;
}
@keyframes bannerIn { from { transform: translate(-50%, -24px) scale(.9); opacity: 0; } }
.eb-emoji { font-size: 34px; animation: wobble 2s ease-in-out infinite; }
@keyframes wobble { 50% { transform: rotate(8deg) scale(1.12); } }
.eb-title { font-weight: 900; font-size: 15px; color: var(--wood-dark); }
.eb-text { font-size: 12.5px; color: var(--ink-soft); line-height: 1.35; }
/* ---------------- modal ---------------- */
#modal-root {
position: fixed; inset: 0; background: rgba(43, 26, 10, .45);
display: flex; align-items: center; justify-content: center; z-index: 50;
backdrop-filter: blur(3px); animation: fadeIn .25s ease;
}
@keyframes fadeIn { from { opacity: 0; } }
.modal {
background: linear-gradient(180deg, var(--paper), var(--cream));
border-radius: 24px; border: 3px solid rgba(169,113,61,.4);
box-shadow: 0 30px 80px rgba(30,15,0,.5); padding: 26px 30px;
max-width: 660px; width: min(92vw, 660px); max-height: 88vh; overflow-y: auto;
animation: modalPop .35s cubic-bezier(.2,.9,.3,1.35);
}
@keyframes modalPop { from { transform: scale(.85) translateY(30px); opacity: 0; } }
.modal h2 { margin: 0 0 4px; color: var(--wood-dark); font-size: 24px; }
.modal .sub { color: var(--ink-soft); font-size: 13.5px; margin-bottom: 16px; }
.modal-actions { display: flex; gap: 10px; margin-top: 20px; flex-wrap: wrap; }
/* daily report */
.report { text-align: center; }
.report .r-stars { font-size: 30px; letter-spacing: 4px; color: var(--gold); text-shadow: 0 2px 0 rgba(0,0,0,.1); }
.r-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 9px; margin: 16px 0; }
.r-cell { background: white; border-radius: 14px; padding: 10px 8px; border: 2px solid rgba(169,113,61,.18); }
.r-cell .rl { font-size: 11px; font-weight: 900; text-transform: uppercase; letter-spacing: .06em; color: var(--ink-soft); }
.r-cell .rv { font-size: 22px; font-weight: 900; margin-top: 2px; }
.r-cell.wide { grid-column: span 2; }
.r-profit { color: var(--green-deep); }
.r-loss { color: var(--red); }
.r-chart { display: flex; align-items: flex-end; gap: 6px; height: 74px; justify-content: center; margin: 8px 0 2px; }
.r-chart .rc-col { width: 30px; display: flex; flex-direction: column; justify-content: flex-end; align-items: center; gap: 3px; }
.r-chart .rc-bar { width: 100%; border-radius: 6px 6px 3px 3px; background: linear-gradient(180deg, #ffd166, var(--accent-deep)); min-height: 4px; transition: height .8s cubic-bezier(.2,.9,.3,1.2); }
.r-chart .rc-lbl { font-size: 10px; color: var(--ink-soft); font-weight: 800; }
/* choice buttons in events/dialogs */
.choice-btn {
display: block; width: 100%; text-align: left; margin-bottom: 8px;
background: white; border: 2px solid rgba(169,113,61,.3); border-radius: 14px;
padding: 11px 14px; font-size: 14px; font-weight: 800; color: var(--ink);
transition: all .12s ease;
}
.choice-btn:hover { border-color: var(--accent-deep); background: #fff3dd; transform: translateX(4px); }
.choice-btn small { display: block; font-weight: 700; color: var(--ink-soft); font-size: 11.5px; margin-top: 1px; }
/* ---------------- menu screen ---------------- */
#menu { position: fixed; inset: 0; z-index: 100; overflow: hidden; }
#menu-bg { position: absolute; inset: 0; width: 100%; height: 100%; }
.menu-inner {
position: relative; height: 100%; display: flex; flex-direction: column;
align-items: center; justify-content: center; gap: 34px;
background: radial-gradient(ellipse at 50% 110%, rgba(255,214,140,.25), transparent 60%);
}
.logo { text-align: center; animation: floaty 4s ease-in-out infinite; }
@keyframes floaty { 50% { transform: translateY(-9px); } }
.logo-shop {
font-size: clamp(54px, 9vw, 96px); font-weight: 900; letter-spacing: .02em;
color: #fff6e3;
text-shadow: 0 4px 0 #b96a17, 0 9px 0 rgba(90, 45, 10, .55), 0 22px 50px rgba(0,0,0,.5);
}
.logo-sub { color: #ffe9bd; font-weight: 800; font-size: clamp(13px, 1.8vw, 17px); text-shadow: 0 2px 8px rgba(0,0,0,.5); margin-top: 6px; }
.menu-buttons { display: flex; flex-direction: column; gap: 11px; width: 280px; }
.menu-btn {
height: 52px; border-radius: 16px; border: 2px solid rgba(255, 235, 200, .35);
background: rgba(60, 36, 16, .55); color: #ffedd0; font-size: 17px; font-weight: 900;
backdrop-filter: blur(4px); box-shadow: 0 8px 24px rgba(0,0,0,.35);
transition: all .15s ease;
}
.menu-btn:hover { transform: translateY(-2px) scale(1.02); background: rgba(90, 55, 25, .75); }
.menu-btn.primary { background: linear-gradient(180deg, var(--accent), var(--accent-deep)); border-color: rgba(255,255,255,.25); color: white; box-shadow: 0 8px 0 #a4561c, 0 16px 40px rgba(0,0,0,.4); }
.menu-foot { color: rgba(255, 236, 200, .65); font-size: 12.5px; font-weight: 700; }
/* new game wizard */
#wizard-card { max-width: 560px; }
.avatar-row { display: flex; gap: 10px; justify-content: center; margin: 12px 0; }
.swatch { width: 42px; height: 42px; border-radius: 50%; border: 3px solid rgba(169,113,61,.3); cursor: pointer; transition: transform .12s; }
.swatch:hover { transform: scale(1.12); }
.swatch.sel { border-color: var(--accent-deep); transform: scale(1.15); box-shadow: 0 0 0 3px #fff, 0 0 14px rgba(255,159,67,.7); }
.diff-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 8px; }
.diff-btn { border: 2px solid rgba(169,113,61,.3); background: white; border-radius: 12px; padding: 9px 4px; font-weight: 900; font-size: 13px; color: var(--ink); }
.diff-btn small { display: block; font-weight: 700; font-size: 10px; color: var(--ink-soft); }
.diff-btn.sel { border-color: var(--accent-deep); background: #fff3dd; }
/* save slots */
.slot-btn {
display: flex; justify-content: space-between; align-items: center; width: 100%;
background: white; border: 2px solid rgba(169,113,61,.3); border-radius: 14px;
padding: 13px 16px; margin-bottom: 9px; font-family: var(--font);
}
.slot-btn:hover { border-color: var(--accent-deep); background: #fff8ea; }
.slot-btn .sl-name { font-weight: 900; font-size: 15px; color: var(--ink); text-align: left; }
.slot-btn .sl-meta { font-size: 12px; color: var(--ink-soft); font-weight: 700; }
.slot-empty { color: var(--ink-soft); font-weight: 700; }
/* settings sliders */
.set-row { display: flex; align-items: center; justify-content: space-between; padding: 9px 4px; border-bottom: 1px dashed rgba(169,113,61,.25); font-weight: 800; font-size: 14px; }
/* tooltip */
#tooltip {
position: fixed; z-index: 90; pointer-events: none;
background: rgba(60, 38, 18, .95); color: #ffedd0; font-size: 12px; font-weight: 700;
padding: 7px 11px; border-radius: 10px; max-width: 260px; line-height: 1.35;
box-shadow: var(--shadow);
}
/* achievements grid */
.ach-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 9px; }
.ach { display: flex; gap: 10px; align-items: center; background: white; border-radius: 13px; padding: 10px 12px; border: 2px solid rgba(169,113,61,.2); }
.ach.locked { opacity: .45; filter: grayscale(.8); }
.ach .ae { font-size: 25px; }
.ach .at { font-weight: 900; font-size: 13.5px; }
.ach .ad { font-size: 11px; color: var(--ink-soft); }
/* quest card */
.quest-done { opacity: .6; }
.claim-btn { animation: pulse 1.2s infinite; }
@keyframes pulse { 50% { transform: scale(1.07); } }
@media (max-width: 900px) {
.side-panel { width: min(86vw, 360px); }
.nav-btn { min-width: 56px; }
.nl { font-size: 10.5px; }
}
+136
View File
@@ -0,0 +1,136 @@
import { el, $ } from '../util.js';
import { SLOT_IDS, slotInfo, loadFrom, deleteSlot, getAchievements } from '../save.js';
import { DIFFICULTIES } from '../data/furniture.js';
import { ACHIEVEMENTS } from '../data/quests.js';
import { SKIN_TONES, HAIR_COLORS, OUTFIT_COLORS } from '../gfx/character3d.js';
import { showModal } from './ui.js';
import { audio } from '../audio/audio.js';
// ============================================================
// TITLE MENU
// ============================================================
export function initMenu(onStart) {
const menu = $('#menu');
const hasAutosave = !!slotInfo('autosave');
$('#m-continue').style.display = hasAutosave ? '' : 'none';
$('#m-new').onclick = () => { audio.init(); audio.click(); openWizard(onStart); };
$('#m-continue').onclick = () => {
audio.init(); audio.click();
const d = loadFrom('autosave');
if (d) onStart({ save: d });
};
$('#m-load').onclick = () => { audio.init(); audio.click(); openSlots(onStart); };
$('#m-achievements').onclick = () => {
audio.init(); audio.click();
openAchievements();
};
$('#m-settings').onclick = () => {
audio.init();
document.getElementById('btn-settings')?.click();
};
// any first click also wakes audio context
menu.addEventListener('pointerdown', () => { audio.init(); audio.resume(); }, { once: true });
}
export function showMenu() { $('#menu').classList.remove('hidden'); }
export function hideMenu() { $('#menu').classList.add('hidden'); }
function openWizard(onStart) {
const wiz = $('#newgame-wizard');
wiz.classList.remove('hidden');
const state = { name: '', skin: 1, hair: 2, outfit: 0, difficulty: 'normal' };
const swatches = (arr, key) => arr.map((c, i) =>
`<div class="swatch ${state[key] === i ? 'sel' : ''}" data-key="${key}" data-i="${i}" style="background:#${c.toString(16).padStart(6, '0')}"></div>`).join('');
const diffs = Object.entries(DIFFICULTIES).map(([id, d]) =>
`<button class="diff-btn ${state.difficulty === id ? 'sel' : ''}" data-diff="${id}">${d.label}<small>${d.blurb}</small></button>`).join('');
wiz.innerHTML = '';
const m = el(`
<div class="modal" id="wizard-card" style="position:absolute; left:50%; top:50%; transform:translate(-50%,-50%); z-index:10">
<h2>🏪 Found your shop!</h2>
<div class="sub">Name it, pick your look, choose how mean the economy will be.</div>
<input type="text" id="wiz-name" placeholder="e.g. The Gilded Carrot" maxlength="22"
style="width:100%; font-size:17px; padding:11px; text-align:center; margin-bottom:14px">
<div class="sp-sub">Your look</div>
<div class="desc" style="text-align:center;margin-bottom:4px">Skin</div>
<div class="avatar-row">${swatches(SKIN_TONES, 'skin')}</div>
<div class="desc" style="text-align:center;margin-bottom:4px">Hair</div>
<div class="avatar-row">${swatches(HAIR_COLORS, 'hair')}</div>
<div class="desc" style="text-align:center;margin-bottom:4px">Outfit</div>
<div class="avatar-row">${swatches(OUTFIT_COLORS, 'outfit')}</div>
<div class="sp-sub">Difficulty</div>
<div class="diff-grid">${diffs}</div>
<div class="modal-actions">
<button class="small-btn green" id="wiz-start" style="flex:1;height:46px;font-size:16px">🔑 Open for business!</button>
<button class="small-btn red" id="wiz-cancel">Back</button>
</div>
</div>`);
wiz.appendChild(m);
m.querySelector('#wiz-name').focus();
m.addEventListener('click', (e) => {
const sw = e.target.closest('.swatch');
if (sw) {
state[sw.dataset.key] = +sw.dataset.i;
m.querySelectorAll(`.swatch[data-key="${sw.dataset.key}"]`).forEach(x => x.classList.toggle('sel', x === sw));
audio.pop();
}
const db = e.target.closest('.diff-btn');
if (db) {
state.difficulty = db.dataset.diff;
m.querySelectorAll('.diff-btn').forEach(x => x.classList.toggle('sel', x === db));
audio.click();
}
});
m.querySelector('#wiz-cancel').onclick = () => { wiz.classList.add('hidden'); };
m.querySelector('#wiz-start').onclick = () => {
state.name = m.querySelector('#wiz-name').value.trim() || 'Tiny Shop';
wiz.classList.add('hidden');
onStart(state);
};
}
function openSlots(onStart) {
const box = $('#load-slots');
box.classList.remove('hidden');
let html = '<div class="modal" style="position:absolute;left:50%;top:50%;transform:translate(-50%,-50%)"><h2>💾 Load a shop</h2><div class="sub">Pick a save slot.</div>';
let any = false;
for (const slot of SLOT_IDS) {
const info = slotInfo(slot);
if (!info) continue;
any = true;
html += `
<button class="slot-btn" data-slot="${slot}">
<span class="sl-name">${info.name}<br><span class="sl-meta">Day ${info.day} · Level ${info.level} · ⭐${info.rep} · ${info.savedAt || ''}</span></span>
<span class="small-btn blue">Load</span>
</button>`;
}
if (!any) html += '<div class="slot-empty" style="text-align:center;padding:20px">No saves yet — start a new game!</div>';
html += '<div class="modal-actions"><button class="small-btn red" id="slots-close">Back</button></div></div>';
box.innerHTML = html;
box.querySelectorAll('[data-slot]').forEach(b => b.onclick = () => {
const d = loadFrom(b.dataset.slot);
if (d) { box.classList.add('hidden'); onStart({ save: d }); }
});
box.querySelector('#slots-close').onclick = () => box.classList.add('hidden');
}
export function openAchievements() {
const ach = getAchievements();
const grid = ACHIEVEMENTS.map(a => `
<div class="ach ${ach[a.id] ? '' : 'locked'}">
<span class="ae">${a.emoji}</span>
<div><div class="at">${a.name}</div><div class="ad">${a.desc}</div></div>
</div>`).join('');
const root = document.getElementById('menu');
const overlay = el(`<div id="ach-overlay" style="position:absolute;inset:0;background:rgba(30,15,5,.6);display:flex;align-items:center;justify-content:center;z-index:20">
<div class="modal"><h2>🏆 Achievements</h2><div class="sub">${Object.keys(ach).length}/${ACHIEVEMENTS.length} unlocked</div>
<div class="ach-grid">${grid}</div>
<div class="modal-actions"><button class="small-btn red" onclick="this.closest('#ach-overlay').remove()">Close</button></div></div></div>`);
root.appendChild(overlay);
}
+538
View File
@@ -0,0 +1,538 @@
import { el, $, toast, fmtGold } from '../util.js';
import { G } from '../sim/state.js';
import { PRODUCT_BY_ID, CATEGORIES } from '../data/products.js';
import { SUPPLIERS, SUPPLIER_BY_ID, NEGOTIATIONS } from '../data/suppliers.js';
import { FURNITURE, SHOP_LEVELS, REP_TIERS, STAFF_ROLES, STAFF_TRAITS } from '../data/furniture.js';
import { STORY_NPCS, ARCH_BY_ID, relTier } from '../data/customers.js';
import { QUESTS, ACHIEVEMENTS } from '../data/quests.js';
import { buyFromSupplier, supplierUnitPrice, expandShop, fulfillOrder } from '../sim/economy.js';
import { hireCandidate, fireStaff, giveRaise } from '../sim/staff.js';
import { stockShelf, unstockShelf, shelfCapacity } from '../sim/economy.js';
import { getAchievements } from '../save.js';
import { showModal, closeModal } from './ui.js';
import { audio } from '../audio/audio.js';
import { emit, on } from '../util.js';
// ============================================================
// SIDE PANELS — Shop · Stock · Suppliers · Staff · Town · Quests · Ledger · Goals
// ============================================================
export const Panels = {
current: null,
el: null,
init() {
this.el = document.createElement('div');
this.el.className = 'side-panel hidden';
document.getElementById('ui').appendChild(this.el);
on('stockChanged', () => this.refresh());
on('staffChanged', () => this.refresh());
on('newDay', () => this.refresh());
on('shopExpanded', () => this.refresh());
// click outside closes selection… keep panels open until X or same-tab click
},
toggle(id) {
if (this.current === id) return this.close();
this.current = id;
this.el.classList.remove('hidden');
document.querySelectorAll('.nav-btn').forEach(b => b.classList.toggle('active', b.dataset.panel === id));
this.render();
},
close() {
this.current = null;
this.el.classList.add('hidden');
document.querySelectorAll('.nav-btn').forEach(b => b.classList.remove('active'));
},
refresh() { if (this.current) this.render(); },
render() {
const fn = {
shop: renderShopPanel, inventory: renderInventoryPanel, suppliers: renderSuppliersPanel,
staff: renderStaffPanel, town: renderTownPanel, quests: renderQuestsPanel,
analytics: renderAnalyticsPanel, goals: renderGoalsPanel,
}[this.current];
if (!fn || !G.data) return;
const [title] = {
shop: ['🏠 Your Shop'], inventory: ['📦 Stock & Prices'], suppliers: ['🚚 Suppliers'],
staff: ['🧑‍🍳 Staff'], town: ['🏘️ Town of Bramblewick'], quests: ['📜 Quests'],
analytics: ['📈 Ledger'], goals: ['🏆 Goals & Achievements'],
}[this.current];
this.el.innerHTML = `
<div class="sp-head">
<span class="sp-title">${title}</span>
<button class="sp-close">✕</button>
</div>
<div class="sp-body"></div>`;
this.el.querySelector('.sp-close').onclick = () => this.close();
fn(this.el.querySelector('.sp-body'));
},
};
// ---------------------------------------------------------- SHOP
function renderShopPanel(body) {
const lvl = G.levelInfo();
const next = SHOP_LEVELS[G.shop.level];
const scores = `
<div class="card">
<h4>Shop Atmosphere</h4>
<div class="desc">Beauty ${G.beautyScore()} · Comfort ${G.comfortScore()} · Luxury ${G.luxuryScore()} · Attraction ${G.attractionScore()}</div>
<div class="bar green"><i style="width:${Math.min(100, G.beautyScore() * 2)}%"></i></div>
<div class="desc" style="margin-top:4px">${['A humble hovel.','Homely, in a damp sort of way.','Getting cozy!','Charming!','Absolutely delightful ✨'][Math.min(4, Math.floor(G.beautyScore() / 8))]}</div>
</div>`;
body.innerHTML = `
<div class="card row">
<span style="font-size:34px">🏪</span>
<div class="grow">
<h4>${G.data.meta.name}</h4>
<div class="desc">${lvl.name} · Level ${G.shop.level}</div>
</div>
</div>
${scores}
<div class="card">
<h4>Storage</h4>
<div class="desc">${G.usedStorage()} / ${G.storageCap()} units</div>
<div class="bar blue"><i style="width:${(G.usedStorage() / G.storageCap()) * 100}%"></i></div>
</div>
<div class="card">
<h4>Staff capacity</h4>
<div class="desc">${G.data.staff.length}/${lvl.staffCap} employed</div>
</div>
<div class="sp-sub">Renovation</div>
<div class="card">
<button class="small-btn blue" id="btn-decorate" style="width:100%">🪄 Decorate the shop</button>
<div class="desc" style="margin-top:6px">Move furniture, add rugs, plants and lamps. Beauty makes customers stay longer.</div>
</div>
${next ? `
<div class="card" id="expand-card">
<h4>🏗️ Expand → ${next.name}</h4>
<div class="desc">Costs <b>${fmtGold(next.cost)}g</b> · needs ⭐${next.repNeed} reputation (${Math.round(G.shop.rep)}/${next.repNeed})</div>
<div class="desc">Bigger floor · +storage · more staff slots</div>
<button class="small-btn green" id="btn-expand" style="margin-top:8px;width:100%">Expand for ${fmtGold(next.cost)}g</button>
</div>` : '<div class="card"><h4>👑 Max expansion reached!</h4><div class="desc">The Kingdom Trading Company stands mighty.</div></div>'}
<div class="sp-sub">Danger zone</div>
<div class="card">
<button class="small-btn red" id="btn-sell-furn" style="width:100%">Sell a piece of furniture (50% back)</button>
<div class="desc" style="margin-top:5px">Enter decorate mode, then click a placed item.</div>
</div>
`;
body.querySelector('#btn-decorate').onclick = () => { emit('decorateStart'); Panels.close(); };
const ex = body.querySelector('#btn-expand');
if (ex) ex.onclick = () => {
const r = expandShop();
if (!r.ok) { toast(r.reason, 'bad', '🚧'); audio.sad(); }
else { audio.levelup(); }
};
}
// ---------------------------------------------------------- INVENTORY
function renderInventoryPanel(body) {
const inv = Object.entries(G.data.inventory);
const shelves = G.shelves();
let html = '';
html += `<div class="card"><div class="desc"><b>Storage:</b> ${G.usedStorage()}/${G.storageCap()} units · Set your prices here. Fair prices sell more; greedy prices earn more per sale.</div></div>`;
if (!inv.length) {
html += `<div class="card"><h4>Empty shelves, empty heart 🥺</h4><div class="desc">Visit the Suppliers tab to buy goods!</div></div>`;
}
html += `<div class="sp-sub">In storage</div>`;
for (const [pid, lot] of inv) {
const p = PRODUCT_BY_ID[pid];
const price = G.priceOf(pid);
const dm = G.demandMult(pid);
const arrow = dm > 1.3 ? '🔥' : dm > 0.95 ? '➡️' : '💤';
const onShelves = shelves.reduce((a, f) => a + ((G.data.shelfStock[f.id] || {})[pid] || 0), 0);
const shelfOpts = shelves.map((f, i) => `<option value="${f.id}">${f.type === 'table' ? 'Table' : f.type === 'pedestal' ? 'Pedestal' : 'Shelf'} ${i + 1}</option>`).join('');
html += `
<div class="card inv-row" data-pid="${pid}">
<div class="pemoji">${p.emoji}</div>
<div>
<div class="pname">${p.name} <span class="tag orange">${CATEGORIES[p.cat].label}</span></div>
<div class="pmeta">${lot.qty} in storage · ${onShelves} on display · quality ${(lot.quality * 100 | 0)}% · demand ${arrow}</div>
<div style="display:flex;gap:5px;margin-top:5px;align-items:center;flex-wrap:wrap">
<label class="pmeta">Price</label>
<input type="number" min="1" max="9999" value="${price}" data-price="${pid}">
<span class="pmeta">(fair ≈ ${Math.round(p.sell * lot.quality)})</span>
</div>
<div style="display:flex;gap:5px;margin-top:6px;align-items:center">
<select data-shelfsel="${pid}" ${shelves.length ? '' : 'disabled'}>${shelfOpts || '<option>— no shelves —</option>'}</select>
<button class="small-btn" data-stock="${pid}" style="padding:5px 9px">Stock ▸</button>
<button class="small-btn blue" data-unstock="${pid}" style="padding:5px 9px" ${onShelves ? '' : 'disabled'}>◂ Take back</button>
</div>
</div>
</div>`;
}
body.innerHTML = html;
body.querySelectorAll('[data-price]').forEach(inp => {
inp.onchange = () => {
const v = Math.max(1, Math.round(+inp.value || 1));
G.data.prices[inp.dataset.price] = v;
inp.value = v;
audio.click();
};
});
body.querySelectorAll('[data-stock]').forEach(btn => {
btn.onclick = () => {
const pid = btn.dataset.stock;
const fid = body.querySelector(`[data-shelfsel="${pid}"]`).value;
const furn = G.data.furniture.find(f => f.id === fid);
if (!furn) return;
const ok = stockShelf(furn, pid, 99);
if (!ok) toast('No room on that shelf!', 'bad', '📦');
else audio.pop();
};
});
body.querySelectorAll('[data-unstock]').forEach(btn => {
btn.onclick = () => {
const pid = btn.dataset.unstock;
for (const f of G.shelves()) unstockShelf(f, pid, 99);
audio.pop();
};
});
}
// ---------------------------------------------------------- SUPPLIERS
function renderSuppliersPanel(body) {
let html = `<div class="card"><div class="desc">Build relationships for better deals. Elves brew excellent potions but some customers <i>refuse</i> elven goods…</div></div>`;
for (const s of SUPPLIERS) {
const sd = G.data.suppliers[s.id];
if (!sd.unlocked) {
html += `
<div class="card row" style="opacity:.55">
<span style="font-size:30px">🔒</span>
<div class="grow"><h4>${s.name}</h4><div class="desc">Unlocks at ⭐${s.unlockRep} reputation</div></div>
</div>`;
continue;
}
const rel = G.supplierRel(s.id);
const disc = sd.discountDays > 0 ? `<span class="tag green">DISCOUNT 10%</span>` : '';
const contract = sd.contractDays > 0 ? `<span class="tag gold">CONTRACT ${sd.contractDays}d</span>` : '';
let rows = '';
for (const pid of s.catalog) {
const p = PRODUCT_BY_ID[pid];
const unit = supplierUnitPrice(s.id, pid);
rows += `
<div style="display:flex;justify-content:space-between;align-items:center;padding:4px 0;border-bottom:1px dashed rgba(169,113,61,.15)">
<span style="font-size:13px;font-weight:800">${p.emoji} ${p.name}</span>
<span style="display:flex;gap:5px;align-items:center">
<b class="mono" style="font-size:13px">${unit}g</b>
<button class="small-btn" data-buy="${s.id}:${pid}:1" style="padding:3px 8px;font-size:12px">×1</button>
<button class="small-btn" data-buy="${s.id}:${pid}:5" style="padding:3px 8px;font-size:12px">×5</button>
</span>
</div>`;
}
html += `
<div class="card">
<div style="display:flex;gap:10px;align-items:center">
<span style="font-size:30px">${s.emoji}</span>
<div class="grow">
<h4>${s.name} ${disc}${contract}</h4>
<div class="desc">${s.desc}</div>
</div>
<button class="small-btn blue" data-neg="${s.id}" style="padding:6px 10px">🤝 Deal</button>
</div>
<div class="desc" style="margin-top:6px">Relationship</div>
<div class="bar green"><i style="width:${rel}%"></i></div>
<div style="max-height:170px;overflow-y:auto;margin-top:7px">${rows}</div>
</div>`;
}
body.innerHTML = html;
body.querySelectorAll('[data-buy]').forEach(b => b.onclick = () => {
const [sid, pid, qty] = b.dataset.buy.split(':');
const r = buyFromSupplier(sid, pid, +qty);
if (!r.ok) { toast(r.reason, 'bad', '💰'); audio.sad(); }
else { audio.coin(); toast(`Bought ${qty}× ${PRODUCT_BY_ID[pid].name}`, 'good'); }
});
body.querySelectorAll('[data-neg]').forEach(b => b.onclick = () => openNegotiation(b.dataset.neg));
}
function openNegotiation(supId) {
const s = SUPPLIER_BY_ID[supId];
const m = showModal(`
<h2>${s.emoji} Negotiating with ${s.name}</h2>
<div class="sub">Relationship: ${G.supplierRel(supId)}/100 — friendlier folk say yes more often.</div>
<div id="neg-list"></div>
<div class="modal-actions"><button class="small-btn red" id="neg-close">Leave</button></div>
`);
const list = m.querySelector('#neg-list');
for (const n of NEGOTIATIONS) {
const btn = el(`<button class="choice-btn">${n.label}<small>${n.hint}</small></button>`);
btn.onclick = () => doNegotiation(n.id, supId, m);
list.appendChild(btn);
}
m.querySelector('#neg-close').onclick = closeModal;
}
function doNegotiation(action, supId, m) {
const sd = G.data.suppliers[supId];
const s = SUPPLIER_BY_ID[supId];
const def = NEGOTIATIONS.find(n => n.id === action);
const sub = m.querySelector('.sub');
const roll = () => Math.random() < def.baseChance(G.supplierRel(supId));
switch (action) {
case 'discount':
if (roll()) { sd.discountDays = 2; sub.innerHTML = '“Fine, fine. Special price for you, friend.” <b>10% for 2 days!</b>'; G.bumpRel(supId, 1); audio.coin(); }
else { sub.innerHTML = '“Prices are prices.” They refuse, but respect the nerve.'; G.bumpRel(supId, 0.5); audio.sad(); }
break;
case 'bulk': {
// cheapest catalog item ×10
const pid = s.catalog.reduce((a, b) => supplierUnitPrice(supId, a) <= supplierUnitPrice(supId, b) ? a : b);
const r = buyFromSupplier(supId, pid, 10, { bulk: true });
sub.innerHTML = r.ok ? `Bulk deal! <b>+10 ${PRODUCT_BY_ID[pid].name}</b> at 8% off.` : r.reason;
if (r.ok) audio.register(); else audio.sad();
break;
}
case 'upfront':
G.bumpRel(supId, 6);
sub.innerHTML = '“Payment up front? You have my trust.” <b>(+6 relationship)</b>';
audio.pop();
break;
case 'contract':
if (roll()) { sd.contractDays = 3; sub.innerHTML = 'Contract signed! <b>Locked prices & better quality for 3 days.</b>'; G.bumpRel(supId, 3); audio.levelup(); }
else { sub.innerHTML = '“I dont know you well enough yet.”'; audio.sad(); }
break;
case 'gift':
if (G.gold >= 15) {
G.addGold(-15);
G.bumpRel(supId, 12);
sub.innerHTML = 'They adore the gift basket! <b>(+12 relationship)</b>';
audio.pop();
} else { sub.innerHTML = 'You cant afford the gift basket (15g).'; }
break;
}
setTimeout(() => Panels.refresh(), 400);
}
// ---------------------------------------------------------- STAFF
function renderStaffPanel(body) {
const cap = G.levelInfo().staffCap;
let html = `<div class="card"><div class="desc">Wages are paid each evening automatically. Keep loyalty above 40 or people start eyeing the door.</div></div>`;
html += `<div class="sp-sub">Your team (${G.data.staff.length}/${cap})</div>`;
if (!G.data.staff.length) html += `<div class="card"><h4>Just you so far 💪</h4><div class="desc">Hire help below — youll be amazed how much a cashier prevents theft.</div></div>`;
for (const s of G.data.staff) {
const role = STAFF_ROLES[s.role];
const traits = s.traits.map(t => `<span class="tag ${STAFF_TRAITS[t]?.sticky ? 'red' : ''}">${STAFF_TRAITS[t]?.emoji || ''} ${STAFF_TRAITS[t]?.label || t}</span>`).join('');
html += `
<div class="card" data-staff="${s.id}">
<div style="display:flex;gap:10px">
<span style="font-size:28px">${role.emoji}</span>
<div class="grow">
<h4>${s.name} <span class="tag orange">${role.label}</span></h4>
<div class="desc">Skill <b>${s.skill}</b> · Salary <b>${s.salary}g/day</b></div>
<div class="desc">Loyalty</div>
<div class="bar ${s.loyalty > 60 ? 'green' : s.loyalty > 35 ? '' : 'red'}"><i style="width:${s.loyalty}%"></i></div>
<div style="margin-top:5px">${traits}</div>
<div style="display:flex;gap:6px;margin-top:8px">
<button class="small-btn green" data-raise="${s.id}" style="padding:5px 10px;font-size:12px">🥰 Raise +15%</button>
<button class="small-btn red" data-fire="${s.id}" style="padding:5px 10px;font-size:12px">👋 Let go</button>
</div>
</div>
</div>
</div>`;
}
html += `<div class="sp-sub">Candidates (refresh daily)</div>`;
for (const c of G.data.candidates) {
const role = STAFF_ROLES[c.role];
const traits = c.traits.map(t => `<span class="tag">${STAFF_TRAITS[t]?.emoji || ''} ${STAFF_TRAITS[t]?.label || t}</span>`).join('');
html += `
<div class="card row">
<span style="font-size:26px">${role.emoji}</span>
<div class="grow">
<h4>${c.name}</h4>
<div class="desc">wants to be ${role.label} · skill ${c.skill} · ${c.salary}g/day</div>
<div>${traits}</div>
</div>
<button class="small-btn" data-hire="${c.id}">Hire</button>
</div>`;
}
body.innerHTML = html;
body.querySelectorAll('[data-hire]').forEach(b => b.onclick = () => {
const r = hireCandidate(b.dataset.hire);
if (!r.ok) toast(r.reason, 'bad');
});
body.querySelectorAll('[data-raise]').forEach(b => b.onclick = () => {
const s = G.data.staff.find(x => x.id === b.dataset.raise);
if (s) giveRaise(s);
});
body.querySelectorAll('[data-fire]').forEach(b => b.onclick = () => fireStaff(b.dataset.fire));
}
// ---------------------------------------------------------- TOWN
function renderTownPanel(body) {
const prosperity = Math.round(clampN(G.shop.rep * 0.8 + G.data.stats.totalCustomers / 20, 0, 100));
let npcHtml = '';
for (const n of STORY_NPCS) {
const rel = G.relOf(n.id);
const hearts = '❤️'.repeat(Math.max(1, Math.ceil(rel / 20))).slice(0, 5);
npcHtml += `
<div class="card row">
<span style="font-size:26px">${n.emoji}</span>
<div class="grow">
<h4>${n.name} <span class="tag">${relTier(rel)}</span></h4>
<div class="desc">${n.bio}</div>
<div class="bar red" style="max-width:120px"><i style="width:${rel}%"></i></div>
<div style="font-size:11px;margin-top:2px">${hearts}</div>
</div>
</div>`;
}
const comps = [];
if (G.data.flags.competitorArrived) comps.push('🏷️ <b>Cheap Mart</b> — undercuts basic goods. Beat them with quality & service.');
if (G.data.town.competitors.fancy) comps.push('🪞 <b>Fancy Emporium</b> — courts the rich. Out-beautify them!');
if (!comps.length) comps.push(`<div class="desc">No rivals yet… enjoy it while it lasts. (Competitors arrive around day ${DIFF_DAY()})</div>`);
let ordersHtml = '';
for (const o of G.data.town.orders) {
if (o.done) continue;
const p = PRODUCT_BY_ID[o.pid];
const have = (G.data.inventory[o.pid]?.qty || 0) + G.shelves().reduce((a, f) => a + ((G.data.shelfStock[f.id] || {})[o.pid] || 0), 0);
ordersHtml += `
<div class="card row">
<span style="font-size:24px">${p.emoji}</span>
<div class="grow">
<h4>${o.qty}× ${p.name}</h4>
<div class="desc">for ${o.from} · pays <b>${fmtGold(o.pay)}g</b> · you have ${have}</div>
</div>
<button class="small-btn green" data-order="${o.id}" ${have >= o.qty ? '' : 'disabled'}>Deliver</button>
</div>`;
}
body.innerHTML = `
<div class="card">
<h4>Town Prosperity</h4>
<div class="desc">As your business thrives, Bramblewick grows richer — new neighbors move in!</div>
<div class="bar"><i style="width:${prosperity}%"></i></div>
</div>
<div class="sp-sub">Delivery board${G.shop.level >= 3 ? '' : ' (unlocks at shop level 3)'}</div>
${G.shop.level >= 3 ? ordersHtml || '<div class="card"><div class="desc">No orders right now. Check again tomorrow!</div></div>' : ''}
<div class="sp-sub">Rivals</div>
<div class="card">${comps.join('<br>')}</div>
<div class="sp-sub">Townsfolk you know</div>
${npcHtml}
`;
function DIFF_DAY() { return ({ cozy: 999, normal: 12, business: 8, tycoon: 6 })[G.data.difficulty]; }
body.querySelectorAll('[data-order]').forEach(b => b.onclick = () => {
const o = G.data.town.orders.find(x => x.id === b.dataset.order);
const r = fulfillOrder(o);
if (r.ok) { toast(`Delivered to ${o.from}! +${o.pay}g`, 'gold', '🚚'); audio.register(); }
else toast(r.reason, 'bad');
});
}
// ---------------------------------------------------------- QUESTS
function renderQuestsPanel(body) {
const q = QUESTS[G.data.quests.activeIdx];
let html = `<div class="card"><div class="desc">Quests guide your rise from tiny shop to legend. Completed: <b>${G.data.quests.completed.length}/${QUESTS.length}</b></div></div>`;
if (q) {
const done = q.check(G);
html += `
<div class="card ${done ? '' : ''}">
<h4>${q.emoji} ${q.title}</h4>
<div class="desc">${q.desc}</div>
${!done && q.hint ? `<div class="desc" style="color:#b96a17">💡 ${q.hint}</div>` : ''}
${done
? `<button class="small-btn claim-btn green" id="claim-quest" style="margin-top:8px;width:100%">🎁 Claim reward!</button>`
: '<div class="tag" style="margin-top:6px">In progress…</div>'}
</div>`;
} else {
html += `<div class="card"><h4>All quests complete! 🎉</h4><div class="desc">You are a true merchant legend. The rest is glory.</div></div>`;
}
// peek ahead
const upcoming = QUESTS.slice(G.data.quests.activeIdx + 1, G.data.quests.activeIdx + 3);
if (upcoming.length) {
html += `<div class="sp-sub">Coming up</div>`;
for (const uq of upcoming) html += `<div class="card" style="opacity:.55"><h4>${uq.emoji} ???</h4><div class="desc">Keep growing to reveal…</div></div>`;
}
const ach = getAchievements();
const got = Object.keys(ach).length;
html += `<div class="sp-sub">Achievements ${got}/${ACHIEVEMENTS.length}</div><div class="card"><div class="desc">See them all in the Goals tab!</div></div>`;
body.innerHTML = html;
const btn = body.querySelector('#claim-quest');
if (btn) btn.onclick = () => {
const cur = QUESTS[G.data.quests.activeIdx];
const rew = cur.reward || {};
if (rew.gold) G.addGold(rew.gold);
if (rew.rep) G.addRep(rew.rep);
G.data.quests.completed.push(cur.id);
G.data.quests.activeIdx++;
toast(`Quest complete: ${cur.title}! ${rew.gold ? '+' + rew.gold + 'g' : ''}${rew.rep ? ' +' + rew.rep + '⭐' : ''}`, 'gold', cur.emoji);
audio.levelup();
Panels.refresh();
};
}
// ---------------------------------------------------------- ANALYTICS
function renderAnalyticsPanel(body) {
const h = G.data.history;
const days = Math.min(7, h.revenue.length);
const revSlice = h.revenue.slice(-7);
const custSlice = h.customers.slice(-7);
const satSlice = h.satisfaction.slice(-7);
const chart = (arr, cls, label, color) => {
const max = Math.max(...arr, 1);
return `<div class="r-chart">${arr.map((v, i) =>
`<div class="rc-col"><div class="rc-bar ${cls}" style="height:${Math.max(4, (v / max) * 56)}px;background:${color}"></div><div class="rc-lbl">d${h.revenue.length - arr.length + 1 + i}</div></div>`).join('')}</div>
<div class="desc" style="text-align:center">${label}</div>`;
};
const sellers = Object.entries(G.data.today.unitsSold)
.map(([pid, n]) => ({ pid, n, rev: n * G.priceOf(pid) }))
.sort((a, b) => b.rev - a.rev).slice(0, 5);
let sellerHtml = sellers.length
? sellers.map((s, i) => `<div style="display:flex;justify-content:space-between;padding:3px 0;border-bottom:1px dashed rgba(169,113,61,.15)">
<span>${['🥇', '🥈', '🥉', '4.', '5.'][i]} ${PRODUCT_BY_ID[s.pid].emoji} ${PRODUCT_BY_ID[s.pid].name}</span>
<b class="mono">${fmtGold(s.rev)}g</b></div>`).join('')
: '<div class="desc">Nothing sold yet today.</div>';
body.innerHTML = `
<div class="card row">
<div class="grow"><h4>Lifetime</h4><div class="desc">Revenue <b>${fmtGold(G.data.stats.totalRevenue)}g</b> · Profit <b>${fmtGold(G.data.stats.totalProfit)}g</b></div></div>
</div>
<div class="card row">
<div class="grow"><h4>Today</h4>
<div class="desc">Revenue <b>${fmtGold(G.data.today.revenue)}g</b> · Expenses <b>${fmtGold(G.data.today.expenses)}g</b> · Customers <b>${G.data.today.served}</b> · Complaints <b>${G.data.today.complaints}</b></div></div>
</div>
<div class="sp-sub">Revenue · last ${days || 0} days</div>
<div class="card">${revSlice.length ? chart(revSlice, '', 'Daily revenue (gold)', 'linear-gradient(180deg,#ffd166,#f0831f)') : '<div class="desc">Close your first day of trade to unlock charts!</div>'}</div>
<div class="sp-sub">Customers · last ${days || 0} days</div>
<div class="card">${custSlice.length ? chart(custSlice, 'blue', 'Visitors per day', 'linear-gradient(180deg,#8ec7f2,#3f83c9)') : ''}</div>
<div class="sp-sub">Best sellers today</div>
<div class="card">${sellerHtml}</div>
`;
}
// ---------------------------------------------------------- GOALS
function renderGoalsPanel(body) {
const milestones = [
{ emoji: '🏬', name: 'Town Merchant', desc: 'Level 4 shop + 3 employees', done: G.shop.level >= 4 && G.data.staff.length >= 3 },
{ emoji: '🐫', name: 'Trading Company', desc: 'Hold a supplier contract', done: Object.values(G.data.suppliers).some(s => s.contractDays > 0) || G.data.flags.hadContract },
{ emoji: '🏰', name: 'Royal Contract', desc: 'Deliver 25 luxury goods to the castle', done: G.data.stats.royalDeliveries >= 25 },
{ emoji: '✨', name: 'Legendary Shop', desc: 'Reach Legendary reputation (82)', done: G.shop.rep >= 82 },
{ emoji: '💎', name: 'Millionaire', desc: 'Earn 1,000,000 gold lifetime', done: G.data.stats.totalRevenue >= 1e6 },
];
let ms = milestones.map(m => `
<div class="ach ${m.done ? '' : 'locked'}">
<span class="ae">${m.emoji}</span>
<div><div class="at">${m.name}</div><div class="ad">${m.desc}</div></div>
${m.done ? '<span style="margin-left:auto;font-size:18px">✅</span>' : ''}
</div>`).join('');
const ach = getAchievements();
const ag = ACHIEVEMENTS.map(a => `
<div class="ach ${ach[a.id] ? '' : 'locked'}">
<span class="ae">${a.emoji}</span>
<div><div class="at">${a.name}</div><div class="ad">${a.desc}</div></div>
</div>`).join('');
body.innerHTML = `
<div class="sp-sub">Endgame milestones</div>
<div class="ach-grid">${ms}</div>
<div class="sp-sub">Achievements</div>
<div class="ach-grid">${ag}</div>
<div class="sp-sub">After the story…</div>
<div class="card"><h4>🏖️ Sandbox dreams</h4><div class="desc">Once youre legendary, why not chase a perfect 5★ week, adopt every cat, or hoard a million gold? The shop is yours.</div></div>
`;
}
function clampN(v, a, b) { return Math.max(a, Math.min(b, v)); }
+122
View File
@@ -0,0 +1,122 @@
import { el, $, toast } from '../util.js';
import { G } from '../sim/state.js';
import { nextDay } from '../sim/daycycle.js';
import { closeDayAccounting } from '../sim/economy.js';
import { clearCustomers } from '../sim/customerAI.js';
import { showModal, closeModal, updateHud, updateTimeControls } from './ui.js';
import { audio } from '../audio/audio.js';
import { PRODUCT_BY_ID } from '../data/products.js';
import { fmtGold } from '../util.js';
import { emit } from '../util.js';
import { saveTo, unlockAchievement } from '../save.js';
import { on } from '../util.js';
// ============================================================
// END-OF-DAY REPORT — the satisfying part
// ============================================================
let pendingClose = false;
export function initReport() {
on('requestClose', () => {
if (pendingClose) return;
pendingClose = true;
const n = G.customers.length;
let m;
if (n > 0 && G.shop.minutes < 19 * 60 - 5) {
m = showModal(`
<h2>⏰ Closing early?</h2>
<div class="sub">There ${n === 1 ? 'is 1 customer' : `are ${n} customers`} still browsing. Send them home and settle the books?</div>
<div class="modal-actions">
<button class="small-btn green" id="rc-yes">Yes, close up!</button>
<button class="small-btn" id="rc-no">Keep trading</button>
</div>`);
} else {
m = showModal(`
<h2>🌙 Closing time…</h2>
<div class="sub">The lamps dim, the last coins clink. Settle today's books?</div>
<div class="modal-actions">
<button class="small-btn green" id="rc-yes">Open the ledger 📖</button>
</div>`);
}
m.querySelector('#rc-yes').onclick = () => {
closeModal();
doCloseDay();
};
m.querySelector('#rc-no')?.addEventListener('click', () => { pendingClose = false; closeModal(); });
// reset guard when modal dismissed via other means
setTimeout(() => { if (!document.getElementById('rc-yes')) pendingClose = false; }, 100);
});
}
function doCloseDay() {
clearCustomers();
import('../sim/staff.js').then(({ staffMorning }) => {}); // (morning runs at next day)
const sum = closeDayAccounting();
showReport(sum);
}
export function showReport(sum) {
const d = G.data.today;
const stars = Math.round(Math.min(5, Math.max(1, (sum.sat * 4.4 + Math.min(1.2, d.served / 14) * 0.8))));
const starStr = '★'.repeat(stars) + '<span style="opacity:.25">' + '★'.repeat(5 - stars) + '</span>';
// achievements checks
if (d.served >= 15 && stars >= 5) unlockAchievement('perfect_day');
if (G.data.stats.totalProfit > 0 || sum.profit > 0) unlockAchievement('first_profit');
if (G.gold >= 1000) unlockAchievement('thousand_gold');
const m = showModal(`
<div class="report">
<h2 style="margin-bottom:2px">📖 Daily Report</h2>
<div class="sub">Day ${G.shop.day} · “${['A quiet little day.', 'Steady business!', 'A very good day!', 'The town is buzzing!', 'LEGENDARY!'][Math.min(4, Math.floor(stars - 1))]}”</div>
<div class="r-stars">${starStr}</div>
<div class="r-grid">
<div class="r-cell"><div class="rl">Customers</div><div class="rv" data-count="${d.served}">0</div></div>
<div class="r-cell"><div class="rl">Revenue</div><div class="rv" data-count="${Math.round(sum.revenue)}" data-suffix="g">0</div></div>
<div class="r-cell"><div class="rl">Expenses</div><div class="rv" style="color:#c0563f" data-count="${Math.round(sum.expenses)}" data-suffix="g">0</div></div>
<div class="r-cell"><div class="rl">Profit</div><div class="rv ${sum.profit >= 0 ? 'r-profit' : 'r-loss'}" data-count="${Math.round(sum.profit)}" data-suffix="g">0</div></div>
<div class="r-cell wide"><div class="rl">Breakdown</div>
<div class="desc mono">wages ${sum.wages}g · rent ${sum.rent}g${sum.tax ? ` · tax ${sum.tax}g` : ''}${sum.stolen ? ` · 💸 stolen ${Math.round(sum.stolen)}g` : ''}</div></div>
${sum.bestSeller ? `<div class="r-cell"><div class="rl">Best seller</div><div class="rv" style="font-size:17px">${PRODUCT_BY_ID[sum.bestSeller.pid].emoji} ${PRODUCT_BY_ID[sum.bestSeller.pid].name}</div><div class="desc">+${fmtGold(sum.bestSeller.value)}g (${sum.bestSeller.units} sold)</div></div>` : ''}
${sum.worstSeller ? `<div class="r-cell"><div class="rl">Needs love</div><div class="rv" style="font-size:17px">${PRODUCT_BY_ID[sum.worstSeller.pid].emoji} ${PRODUCT_BY_ID[sum.worstSeller.pid].name}</div><div class="desc">${sum.worstSeller.units} sold</div></div>` : ''}
<div class="r-cell wide">
<div class="rl">Last 7 days revenue</div>
<div class="r-chart">${(G.data.history.revenue.slice(-7)).map((v, i, arr) =>
`<div class="rc-col"><div class="rc-bar" style="height:${Math.max(4, v / Math.max(...arr, 1) * 52)}px"></div></div>`).join('')}</div>
</div>
<div class="r-cell wide"><div class="rl">Reputation</div>
<div class="rv" style="font-size:18px">${sum.repDelta >= 0 ? '+' : ''}${sum.repDelta} ⭐</div></div>
</div>
<button class="small-btn green" id="next-day" style="width:100%;height:48px;font-size:16px">🌅 Sleep → Day ${G.shop.day + 1}</button>
</div>
`, 'report');
// animate counters
m.querySelectorAll('[data-count]').forEach(nodeEl => {
const target = +nodeEl.dataset.count;
const suffix = nodeEl.dataset.suffix || '';
const t0 = performance.now(), dur = 900;
const tick = (t) => {
const k = Math.min(1, (t - t0) / dur);
const eased = 1 - Math.pow(1 - k, 3);
nodeEl.textContent = fmtGold(target * eased) + suffix;
if (k < 1) requestAnimationFrame(tick);
};
requestAnimationFrame(tick);
});
audio.register();
if (stars >= 4) setTimeout(() => audio.levelup(), 500);
saveTo('autosave');
m.querySelector('#next-day').onclick = () => {
closeModal();
nextDay();
import('../sim/staff.js').then(({ staffMorning }) => staffMorning());
updateHud();
updateTimeControls();
emit('newDay');
toast(`Day ${G.shop.day}. Fresh stock ideas, fresh faces!`, '', '🌅');
};
}
+239
View File
@@ -0,0 +1,239 @@
import { el, $, toast, fmtClock, fmtGold, clamp } from '../util.js';
import { G } from '../sim/state.js';
import { TIME } from '../sim/daycycle.js';
import { WEATHERS, SHOP_LEVELS, REP_TIERS, DIFFICULTIES } from '../data/furniture.js';
import { SEASONS } from '../data/furniture.js';
import { audio } from '../audio/audio.js';
import { saveTo, loadFrom, SLOT_IDS } from '../save.js';
import { Panels } from './panels.js';
import { emit, on } from '../util.js';
// ============================================================
// CORE UI — topbar, timebar, modals, banners, selection card
// ============================================================
export const UI = {};
let lastGold = null;
export function updateHud() {
if (!G.data) return;
const s = G.shop;
$('#tb-day').textContent = `Day ${s.day}`;
const season = G.season();
$('#tb-season').textContent = `${season.emoji} ${season.name}${G.isFestivalDay() ? ' · Festival!' : ''}`;
const w = WEATHERS[s.weatherId];
$('#tb-weather').textContent = `${w.emoji} ${w.label}`;
$('#tb-clock').textContent = fmtClock(s.minutes);
const phase = $('#tb-phase');
if (s.isOpen && s.minutes >= TIME.closeAt - 120) { phase.textContent = 'CLOSING SOON'; phase.className = 'phase-pill closed'; }
else if (s.isOpen) { phase.textContent = 'OPEN'; phase.className = 'phase-pill open'; }
else { phase.textContent = s.minutes < TIME.openAt ? 'PREPARING' : 'CLOSED'; phase.className = 'phase-pill closed'; }
const goldEl = $('#tb-gold');
goldEl.textContent = fmtGold(s.gold);
if (lastGold !== null && s.gold > lastGold) {
goldEl.classList.remove('bump'); void goldEl.offsetWidth; goldEl.classList.add('bump');
}
lastGold = s.gold;
$('#tb-rep').textContent = Math.round(s.rep);
const tier = [...REP_TIERS].reverse().find(t => s.rep >= t.min);
$('#tb-rep-tier').textContent = tier.name;
}
export function updateTimeControls() {
if (!G.data) return;
const btn = $('#btn-open-close');
const m = G.shop.minutes;
const withinHours = m >= TIME.openAt && m < TIME.closeAt;
btn.disabled = false;
if (!withinHours) {
btn.textContent = m < TIME.openAt ? 'OPENS AT 9:00' : 'SHOP CLOSED';
btn.classList.remove('close-mode');
btn.title = 'Trading hours are 9:00 AM 7:00 PM';
} else {
btn.textContent = G.shop.isOpen ? '⏹ CLOSE UP' : '🔔 OPEN SHOP!';
btn.classList.add('close-mode');
btn.title = '';
}
// speed button highlight
document.querySelectorAll('.tbtn').forEach(b => b.classList.remove('active'));
const id = G.paused ? 'spd-pause' : `spd-${G.timeScale}`;
document.getElementById(id)?.classList.add('active');
}
function bindStaticUi() {
$('#spd-pause').onclick = () => { G.paused = true; audio.click(); updateTimeControls(); };
$('#spd-1').onclick = () => { G.paused = false; G.timeScale = 1; audio.click(); updateTimeControls(); };
$('#spd-2').onclick = () => { G.paused = false; G.timeScale = 2; audio.click(); updateTimeControls(); };
$('#spd-3').onclick = () => { G.paused = false; G.timeScale = 3; audio.click(); updateTimeControls(); };
$('#btn-open-close').onclick = () => {
audio.doorbell();
const s = G.shop;
if (!s.isOpen && s.minutes >= TIME.openAt && s.minutes < TIME.closeAt) {
s.isOpen = true;
s.phase = 'open';
toast('The doors are open! Customers incoming~', 'good', '🔔');
emit('shopOpened');
} else if (s.isOpen) {
emit('requestClose');
} else {
toast('The shop opens at 9:00 AM. Prep your shelves!', '', '⏰');
}
updateTimeControls();
};
$('#btn-settings').onclick = () => openSettingsModal();
$('#btn-help').onclick = () => openHelpModal();
document.querySelectorAll('.nav-btn').forEach(b => {
b.onclick = () => { audio.click(); Panels.toggle(b.dataset.panel); };
});
$('#btn-dec-done').onclick = () => emit('decorateDone');
// keyboard
window.addEventListener('keydown', (e) => {
if (!G.data || G.mode === 'menu') return;
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;
if (e.code === 'Space') { e.preventDefault(); G.paused = !G.paused; updateTimeControls(); }
if (e.key === '1') { G.paused = false; G.timeScale = 1; updateTimeControls(); }
if (e.key === '2') { G.paused = false; G.timeScale = 2; updateTimeControls(); }
if (e.key === '3') { G.paused = false; G.timeScale = 3; updateTimeControls(); }
if (e.key === 'Escape') { Panels.close(); emit('cancelPlacement'); }
});
}
// ---------------- modal helpers ----------------
export function showModal(html, cls = '') {
const root = $('#modal-root');
root.classList.remove('hidden');
root.innerHTML = `<div class="modal ${cls}">${html}</div>`;
audio.paper();
return root.firstElementChild;
}
export function closeModal() {
const root = $('#modal-root');
root.classList.add('hidden');
root.innerHTML = '';
}
export function isModalOpen() { return !$('#modal-root').classList.contains('hidden'); }
function openHelpModal() {
showModal(`
<h2>🛎️ How to run your shop</h2>
<div class="sub">Everything a future retail tycoon needs to know.</div>
<div class="card"><h4>📦 The Loop</h4><div class="desc">Buy goods from <b>Suppliers</b> → stock them onto <b>shelves</b> (Stock tab) → set prices you can live with → <b>OPEN SHOP</b> and watch the townsfolk pour in.</div></div>
<div class="card"><h4>😊 Happy Customers</h4><div class="desc">Fair prices + pretty decor + quick service = reputation. Reputation brings richer, rarer customers.</div></div>
<div class="card"><h4>🧑‍🍳 Staff</h4><div class="desc">Cashiers watch the counter (thieves hate that!), stockers refill shelves, guards deter crime, managers auto-open the store.</div></div>
<div class="card"><h4>🎥 Controls</h4><div class="desc">
Drag — orbit camera · Wheel — zoom · Q/E — rotate · WASD/arrows — walk around · Click — inspect things · Space — pause · 1/2/3 — game speed · In decorate mode: R rotates, Esc cancels.</div></div>
<div class="modal-actions"><button class="small-btn" onclick="this.closest('#modal-root').classList.add('hidden')">Got it!</button></div>
`);
}
function openSettingsModal() {
const st = G.data?.settings || { music: .6, sfx: .8, quality: 'high' };
const m = showModal(`
<h2>⚙️ Settings</h2>
<div class="set-row"><span>🎵 Music</span><input id="set-music" type="range" min="0" max="1" step="0.05" value="${st.music}"></div>
<div class="set-row"><span>🔊 Sound effects</span><input id="set-sfx" type="range" min="0" max="1" step="0.05" value="${st.sfx}"></div>
<div class="set-row"><span>✨ Graphics quality</span>
<select id="set-quality"><option value="high">High</option><option value="low">Performance</option></select></div>
<div class="modal-actions">
${G.data ? '<button class="small-btn green" id="set-save">💾 Save now</button>' : ''}
<button class="small-btn blue" id="set-quit">🏠 Main menu</button>
<button class="small-btn red" id="set-close">Close</button>
</div>
`);
m.querySelector('#set-quality').value = st.quality;
m.querySelector('#set-music').oninput = (e) => { st.music = +e.target.value; audio.setVolumes(st.music, st.sfx); if (G.data) G.data.settings = st; };
m.querySelector('#set-sfx').oninput = (e) => { st.sfx = +e.target.value; audio.setVolumes(st.music, st.sfx); if (G.data) G.data.settings = st; };
m.querySelector('#set-quality').onchange = (e) => { st.quality = e.target.value; if (G.data) G.data.settings = st; emit('qualityChanged', st.quality); };
m.querySelector('#set-close').onclick = closeModal;
m.querySelector('#set-save')?.addEventListener('click', () => { saveTo('slot1'); toast('Game saved! 💾', 'good'); });
m.querySelector('#set-quit').onclick = () => { saveTo('autosave'); location.reload(); };
}
// ---------------- event banner ----------------
let bannerTimer = null;
on('eventBanner', ({ emoji, title, text }) => {
const b = $('#event-banner');
$('#eb-emoji').textContent = emoji;
$('#eb-title').textContent = title;
$('#eb-text').textContent = text.length > 140 ? text.slice(0, 138) + '…' : text;
b.classList.remove('hidden');
clearTimeout(bannerTimer);
bannerTimer = setTimeout(() => b.classList.add('hidden'), 7000);
});
// ---------------- event choice modal ----------------
on('showEventModal', (ev) => {
const m = showModal(`
<h2>${ev.emoji} ${ev.title}</h2>
<div class="sub">${ev.text}</div>
<div id="ev-choices"></div>
`);
const box = m.querySelector('#ev-choices');
import('../sim/events.js').then(({ resolveChoice }) => {
ev.choices.forEach((c, i) => {
const btn = el(`<button class="choice-btn">${c.label}<small>${c.hint || ''}</small></button>`);
btn.onclick = () => {
const out = resolveChoice(ev, i);
m.querySelector('.sub').innerHTML = `${out.text}`;
box.innerHTML = '<button class="small-btn" id="ev-ok">Wonderful!</button>';
m.querySelector('#ev-ok').onclick = closeModal;
audio.pop();
};
box.appendChild(btn);
});
});
});
// ---------------- raise request modal ----------------
on('raiseRequest', (staff) => {
if (isModalOpen()) return;
const m = showModal(`
<h2>🥺 ${staff.name} wants a raise</h2>
<div class="sub">“Boss… I've been working <i>so</i> hard. My skills are worth more than this!”<br>Current salary: <b>${staff.salary}g/day</b> · Skill: ${staff.skill} · Loyalty: ${Math.round(staff.loyalty)}</div>
<div class="modal-actions">
<button class="small-btn green" id="rr-yes">Give +15% raise</button>
<button class="small-btn red" id="rr-no">Not right now</button>
</div>
`);
m.querySelector('#rr-yes').onclick = () => {
import('../sim/staff.js').then(({ giveRaise }) => { giveRaise(staff); closeModal(); });
};
m.querySelector('#rr-no').onclick = () => {
staff.loyalty -= 12;
toast(`${staff.name} sighs dramatically… (-loyalty)`, 'bad', '😤');
closeModal();
};
});
// ---------------- selection card ----------------
const selcard = () => $('#selcard');
export function showSelCard(html, clientX, clientY) {
const c = selcard();
c.innerHTML = html;
c.classList.remove('hidden');
const pad = 14;
let x = clientX + pad, y = clientY + pad;
const r = c.getBoundingClientRect();
if (x + r.width > innerWidth - pad) x = clientX - r.width - pad;
if (y + r.height > innerHeight - pad) y = innerHeight - r.height - pad;
c.style.left = x + 'px';
c.style.top = y + 'px';
}
export function hideSelCard() { selcard().classList.add('hidden'); }
export function bindSelectionActions(handlers) {
selcard().addEventListener('click', (e) => {
const act = e.target.closest('[data-act]');
if (act) handlers[act.dataset.act]?.(act.dataset);
});
}
export function initCoreUi() {
bindStaticUi();
$('#ui').classList.remove('hidden');
}
+66
View File
@@ -0,0 +1,66 @@
// ---------- tiny utils ----------
export const TAU = Math.PI * 2;
export const clamp = (v, a, b) => Math.max(a, Math.min(b, v));
export const lerp = (a, b, t) => a + (b - a) * t;
export const rand = (a = 1, b) => (b === undefined ? Math.random() * a : a + Math.random() * (b - a));
export const randi = (a, b) => Math.floor(rand(a, b + 1));
export const choice = (arr) => arr[Math.floor(Math.random() * arr.length)];
export const chance = (p) => Math.random() < p;
export const uid = () => Math.random().toString(36).slice(2, 9);
export const pickWeighted = (entries) => {
// entries: [{w, ...}]
let sum = 0;
for (const e of entries) sum += e.w;
let r = Math.random() * sum;
for (const e of entries) { r -= e.w; if (r <= 0) return e; }
return entries[entries.length - 1];
};
export const fmtGold = (n) => {
const v = Math.round(n);
if (Math.abs(v) >= 1e6) return (v / 1e6).toFixed(v % 1e6 === 0 ? 0 : 1) + 'M';
if (Math.abs(v) >= 1e4) return (v / 1e3).toFixed(1).replace(/\.0$/, '') + 'k';
return v.toLocaleString('en-US');
};
export const fmtClock = (minutes) => {
const m = ((Math.floor(minutes) % 1440) + 1440) % 1440;
const h24 = Math.floor(m / 60), mm = Math.floor(m % 60);
const ampm = h24 >= 12 ? 'PM' : 'AM';
const h = h24 % 12 === 0 ? 12 : h24 % 12;
return `${h}:${mm.toString().padStart(2, '0')} ${ampm}`;
};
export const easeOutCubic = (t) => 1 - Math.pow(1 - t, 3);
export const easeInOut = (t) => t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2;
export const smoothDamp = (cur, target, vel, smoothTime, dt) => {
// returns [newCur, newVel]
smoothTime = Math.max(0.0001, smoothTime);
const omega = 2 / smoothTime;
const x = omega * dt;
const exp = 1 / (1 + x + 0.48 * x * x + 0.235 * x * x * x);
const change = cur - target;
const temp = (vel + omega * change) * dt;
vel = (vel - omega * temp) * exp;
cur = target + (change + temp) * exp;
return [cur, vel];
};
export function el(html) {
const t = document.createElement('template');
t.innerHTML = html.trim();
return t.content.firstElementChild;
}
export const $ = (sel) => document.querySelector(sel);
export function toast(msg, cls = '', ic = '') {
if (typeof document === 'undefined') return; // node-side sim safety
const box = document.getElementById('toasts');
if (!box) return;
const t = el(`<div class="toast ${cls}">${ic ? `<span>${ic}</span>` : ''}<span>${msg}</span></div>`);
box.appendChild(t);
while (box.children.length > 5) box.firstChild.remove();
setTimeout(() => t.remove(), 4000);
}
// tiny event bus
const listeners = {};
export const on = (ev, fn) => { (listeners[ev] ??= []).push(fn); };
export const emit = (ev, payload) => { (listeners[ev] || []).forEach((fn) => fn(payload)); };
+11
View File
@@ -0,0 +1,11 @@
import { defineConfig } from 'vite';
export default defineConfig({
base: './',
server: { host: '127.0.0.1', port: 5173, strictPort: true },
build: {
target: 'es2022',
chunkSizeWarningLimit: 1600,
assetsInlineLimit: 0
}
});