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:
@@ -0,0 +1,6 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
.vite/
|
||||||
|
*.log
|
||||||
|
.DS_Store
|
||||||
|
shots/
|
||||||
@@ -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
@@ -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 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>
|
||||||
Generated
+2368
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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();
|
||||||
@@ -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();
|
||||||
@@ -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);
|
||||||
@@ -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);
|
||||||
@@ -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;
|
||||||
|
// I–V–vi–IV 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();
|
||||||
@@ -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)];
|
||||||
@@ -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 it’s adorable.',
|
||||||
|
choices: [
|
||||||
|
{ label: 'Let it sleep — it’s 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: 'Knight’s 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 don’t 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 Bard’s 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. They’re 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: 'Theron’s 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!',
|
||||||
|
},
|
||||||
|
];
|
||||||
@@ -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 },
|
||||||
|
};
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
@@ -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: 'You’re 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: 'Kingdom’s Favorite', desc: 'Reach Legendary reputation' },
|
||||||
|
];
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import { PRODUCTS } from './products.js';
|
||||||
|
|
||||||
|
// ---------------- SUPPLIERS ----------------
|
||||||
|
export const SUPPLIERS = [
|
||||||
|
{
|
||||||
|
id: 'farmer', name: 'Old Hodge’s 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. Mira’s 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: 'Brunhild’s 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: 'Theron’s 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: 'Elara’s 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 },
|
||||||
|
];
|
||||||
@@ -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; }
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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(); });
|
||||||
|
}
|
||||||
@@ -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
@@ -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
@@ -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');
|
||||||
|
}
|
||||||
@@ -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, 'Can’t 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, 'I’ll 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 = 'That’s 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);
|
||||||
|
}
|
||||||
@@ -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:00–19: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;
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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]);
|
||||||
|
}
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
@@ -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
@@ -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
@@ -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);
|
||||||
|
}
|
||||||
@@ -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 don’t 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 can’t 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 — you’ll 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 you’re 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)); }
|
||||||
@@ -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
@@ -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
@@ -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)); };
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user