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

- Procedural 3D world: dollhouse shop, town, day/night, weather, seasons
- Customer AI with personalities (story NPCs, thieves, weekly regulars)
- Economy: suppliers, negotiation, pricing psychology, daily accounting
- Staff with traits/loyalty, 8 expansion levels, furniture & decoration
- Events with choices, quests, achievements, 4 difficulties, rival shop
- Animated daily report, analytics, save/load (3 slots + autosave)
- Procedural music & SFX (WebAudio), zero external assets
- Test harnesses: simtest (node), verify/check/e2e (headless browser)
This commit is contained in:
2026-08-23 06:58:24 +00:00
commit 0e7c07533e
40 changed files with 10130 additions and 0 deletions
+122
View File
@@ -0,0 +1,122 @@
import { el, $, toast } from '../util.js';
import { G } from '../sim/state.js';
import { nextDay } from '../sim/daycycle.js';
import { closeDayAccounting } from '../sim/economy.js';
import { clearCustomers } from '../sim/customerAI.js';
import { showModal, closeModal, updateHud, updateTimeControls } from './ui.js';
import { audio } from '../audio/audio.js';
import { PRODUCT_BY_ID } from '../data/products.js';
import { fmtGold } from '../util.js';
import { emit } from '../util.js';
import { saveTo, unlockAchievement } from '../save.js';
import { on } from '../util.js';
// ============================================================
// END-OF-DAY REPORT — the satisfying part
// ============================================================
let pendingClose = false;
export function initReport() {
on('requestClose', () => {
if (pendingClose) return;
pendingClose = true;
const n = G.customers.length;
let m;
if (n > 0 && G.shop.minutes < 19 * 60 - 5) {
m = showModal(`
<h2>⏰ Closing early?</h2>
<div class="sub">There ${n === 1 ? 'is 1 customer' : `are ${n} customers`} still browsing. Send them home and settle the books?</div>
<div class="modal-actions">
<button class="small-btn green" id="rc-yes">Yes, close up!</button>
<button class="small-btn" id="rc-no">Keep trading</button>
</div>`);
} else {
m = showModal(`
<h2>🌙 Closing time…</h2>
<div class="sub">The lamps dim, the last coins clink. Settle today's books?</div>
<div class="modal-actions">
<button class="small-btn green" id="rc-yes">Open the ledger 📖</button>
</div>`);
}
m.querySelector('#rc-yes').onclick = () => {
closeModal();
doCloseDay();
};
m.querySelector('#rc-no')?.addEventListener('click', () => { pendingClose = false; closeModal(); });
// reset guard when modal dismissed via other means
setTimeout(() => { if (!document.getElementById('rc-yes')) pendingClose = false; }, 100);
});
}
function doCloseDay() {
clearCustomers();
import('../sim/staff.js').then(({ staffMorning }) => {}); // (morning runs at next day)
const sum = closeDayAccounting();
showReport(sum);
}
export function showReport(sum) {
const d = G.data.today;
const stars = Math.round(Math.min(5, Math.max(1, (sum.sat * 4.4 + Math.min(1.2, d.served / 14) * 0.8))));
const starStr = '★'.repeat(stars) + '<span style="opacity:.25">' + '★'.repeat(5 - stars) + '</span>';
// achievements checks
if (d.served >= 15 && stars >= 5) unlockAchievement('perfect_day');
if (G.data.stats.totalProfit > 0 || sum.profit > 0) unlockAchievement('first_profit');
if (G.gold >= 1000) unlockAchievement('thousand_gold');
const m = showModal(`
<div class="report">
<h2 style="margin-bottom:2px">📖 Daily Report</h2>
<div class="sub">Day ${G.shop.day} · “${['A quiet little day.', 'Steady business!', 'A very good day!', 'The town is buzzing!', 'LEGENDARY!'][Math.min(4, Math.floor(stars - 1))]}”</div>
<div class="r-stars">${starStr}</div>
<div class="r-grid">
<div class="r-cell"><div class="rl">Customers</div><div class="rv" data-count="${d.served}">0</div></div>
<div class="r-cell"><div class="rl">Revenue</div><div class="rv" data-count="${Math.round(sum.revenue)}" data-suffix="g">0</div></div>
<div class="r-cell"><div class="rl">Expenses</div><div class="rv" style="color:#c0563f" data-count="${Math.round(sum.expenses)}" data-suffix="g">0</div></div>
<div class="r-cell"><div class="rl">Profit</div><div class="rv ${sum.profit >= 0 ? 'r-profit' : 'r-loss'}" data-count="${Math.round(sum.profit)}" data-suffix="g">0</div></div>
<div class="r-cell wide"><div class="rl">Breakdown</div>
<div class="desc mono">wages ${sum.wages}g · rent ${sum.rent}g${sum.tax ? ` · tax ${sum.tax}g` : ''}${sum.stolen ? ` · 💸 stolen ${Math.round(sum.stolen)}g` : ''}</div></div>
${sum.bestSeller ? `<div class="r-cell"><div class="rl">Best seller</div><div class="rv" style="font-size:17px">${PRODUCT_BY_ID[sum.bestSeller.pid].emoji} ${PRODUCT_BY_ID[sum.bestSeller.pid].name}</div><div class="desc">+${fmtGold(sum.bestSeller.value)}g (${sum.bestSeller.units} sold)</div></div>` : ''}
${sum.worstSeller ? `<div class="r-cell"><div class="rl">Needs love</div><div class="rv" style="font-size:17px">${PRODUCT_BY_ID[sum.worstSeller.pid].emoji} ${PRODUCT_BY_ID[sum.worstSeller.pid].name}</div><div class="desc">${sum.worstSeller.units} sold</div></div>` : ''}
<div class="r-cell wide">
<div class="rl">Last 7 days revenue</div>
<div class="r-chart">${(G.data.history.revenue.slice(-7)).map((v, i, arr) =>
`<div class="rc-col"><div class="rc-bar" style="height:${Math.max(4, v / Math.max(...arr, 1) * 52)}px"></div></div>`).join('')}</div>
</div>
<div class="r-cell wide"><div class="rl">Reputation</div>
<div class="rv" style="font-size:18px">${sum.repDelta >= 0 ? '+' : ''}${sum.repDelta} ⭐</div></div>
</div>
<button class="small-btn green" id="next-day" style="width:100%;height:48px;font-size:16px">🌅 Sleep → Day ${G.shop.day + 1}</button>
</div>
`, 'report');
// animate counters
m.querySelectorAll('[data-count]').forEach(nodeEl => {
const target = +nodeEl.dataset.count;
const suffix = nodeEl.dataset.suffix || '';
const t0 = performance.now(), dur = 900;
const tick = (t) => {
const k = Math.min(1, (t - t0) / dur);
const eased = 1 - Math.pow(1 - k, 3);
nodeEl.textContent = fmtGold(target * eased) + suffix;
if (k < 1) requestAnimationFrame(tick);
};
requestAnimationFrame(tick);
});
audio.register();
if (stars >= 4) setTimeout(() => audio.levelup(), 500);
saveTo('autosave');
m.querySelector('#next-day').onclick = () => {
closeModal();
nextDay();
import('../sim/staff.js').then(({ staffMorning }) => staffMorning());
updateHud();
updateTimeControls();
emit('newDay');
toast(`Day ${G.shop.day}. Fresh stock ideas, fresh faces!`, '', '🌅');
};
}