LifeTown Online — browser multiplayer life-sim MVP

- Server-authoritative Node/WS server: sessions, zones (town + instanced homes + public venue interiors), NPC routines, economy, relationships, persistence
- TypeScript + Three.js client: character creator, Maple Court district, enterable venues (café, city hall, gym, store, arcade), build mode with 58-item furniture catalog, needs/mood systems, phone UI, day/night + weather, procedural audio
- Verified by two-client e2e protocol suite and headless-browser walkthrough
This commit is contained in:
deepseek
2026-08-23 07:01:24 +00:00
commit 93b018e42b
35 changed files with 7785 additions and 0 deletions
+666
View File
@@ -0,0 +1,666 @@
// LifeTown Online — main orchestrator: screens, game loop, interactions.
import './style.css';
import * as THREE from 'three';
import { Engine } from './gfx/engine';
import { World } from './world';
import { Controller } from './controller';
import { WeatherFX } from './gfx/weatherfx';
import { S, bus, Appearance } from './state';
import { net } from './net';
import { audio } from './audio';
import { NeedsSys } from './systems';
import { initHud, showPrompt, hidePrompt } from './ui/hud';
import { initChat } from './ui/chat';
import { Creator } from './ui/creator';
import { initPhone, togglePhone, refreshMapIfOpen } from './ui/phone';
import { BuildUI, openHouseMarket } from './ui/buildui';
import { openSocialWheel, closeWheel, runWorkOverlay, sleepSequence, fishingGame, cookingGame } from './ui/dialogs';
import { openDialog, toast, $, el } from './ui/common';
import { POIS, JOBS, HOUSES, CATALOG_BY_ID, DOORS, VENUES } from '../../shared/data.mjs';
import { categoryActions } from './gfx/furniture';
// ---------------- singletons ----------------
const canvas = document.getElementById('gl') as HTMLCanvasElement;
const engine = new Engine(canvas);
const world = new World(engine);
let controller: Controller;
let weatherfx = new WeatherFX();
engine.fxGroup.add(weatherfx.group);
let buildui: BuildUI;
let booted = false;
// ---------------- helpers ----------------
function defaultAppearance(): Appearance {
return {
bodyType: 'average', height: 0.97, skin: '#eab98f', faceShape: 'round',
hair: 'short', hairColor: '#4a2f1d', eyes: 'round', eyeColor: '#3a5a40',
brows: 'natural', nose: 'button', mouth: 'smile',
outfit: { top: 'tee', bottom: 'jeans', dress: null, shoes: 'sneakers', accessory: 'none', topColor: '#4f8fd9', bottomColor: '#33415c' },
personality: ['Friendly'], voice: 'warm', age: 25,
};
}
function myAppearance(): any {
const a = S.profile?.appearance;
return a && Object.keys(a).length ? { ...defaultAppearance(), ...a, ...(a.outfit ? {} : { outfit: defaultAppearance().outfit }) } : defaultAppearance();
}
function flatAppearance(app: any) {
// flatten outfit for Avatar builder
return {
...app,
topColor: app.topColor ?? app.outfit?.topColor,
bottomColor: app.bottomColor ?? app.outfit?.bottomColor,
shoes: app.shoes ?? app.outfit?.shoes,
};
}
// ---------------- boot / screens ----------------
const savedToken = localStorage.getItem('lt.token');
const savedName = localStorage.getItem('lt.name');
if (savedName) {
($('#inp-name') as HTMLInputElement).value = savedName;
($('#title-hint') as HTMLElement).textContent = `Welcome back, ${savedName}! Your life continues where you left off.`;
}
$('#btn-play').addEventListener('click', () => {
const name = ($('#inp-name') as HTMLInputElement).value.trim();
if (name.length < 2) return void (($('#title-hint') as HTMLElement).textContent = 'Pick a name with at least 2 letters.');
S.name = name;
$('#loading').classList.remove('hidden');
$('#screen-title').classList.add('hidden');
net.connect({ name, token: savedToken || undefined });
});
$('#btn-newchar').addEventListener('click', () => {
$('#screen-title').classList.add('hidden');
$('#screen-creator').classList.remove('hidden');
new Creator((res) => {
$('#screen-creator').classList.add('hidden');
$('#loading').classList.remove('hidden');
localStorage.removeItem('lt.token'); // new identity
S.name = res.name;
net.connect({ name: res.name, appearance: res.appearance });
});
});
bus.on('srv:error', (m: any) => {
$('#loading').classList.add('hidden');
if (!S.connected) {
$('#screen-title').classList.remove('hidden');
($('#title-hint') as HTMLElement).textContent = m.message || 'Could not join.';
} else {
toast('Hmm…', m.message || 'That action is not available right now.', '⚠️', 'coral');
}
audio.err();
});
bus.on('net:down', () => {
if (booted) toast('Connection lost', 'Reconnecting to LifeTown…', '📶', 'coral');
});
// ---------------- game start ----------------
bus.on('game:ready', async () => {
$('#loading').classList.add('hidden');
if (booted) {
// soft resync after a reconnect
S.zone = 'town';
await enterZoneVisual('town');
toast('Back online', 'You are in Maple Court.', '📶');
return;
}
booted = true;
// debug/testing handle (harmless in production)
(window as any).__lt = {
S, world, controller, net,
teleport: (x: number, z: number) => { const p = world.me?.avatar.group.position; if (p) p.set(x, 0, z); },
poiAct,
};
$('#hud').classList.remove('hidden');
initHud();
initChat();
initPhone(canWorkHere);
controller = new Controller(engine, null, null,
() => world.me?.avatar.group.position ?? new THREE.Vector3(),
scanInteraction);
buildui = new BuildUI(() => world.interior, engine.camera, canvas);
world.onFocus((pos) => controller.socialFocus(pos));
await enterZoneVisual(S.zone === 'town' ? 'town' : S.zone);
world.spawnMe(flatAppearance(myAppearance()));
setupActionHandlers();
setupTouch();
requestAnimationFrame(loop);
setInterval(() => {
net.send({ t: 'needs_sync', needs: S.needs, moodlets: S.moodlets });
}, 12000);
setInterval(refreshMapIfOpen, 4000);
toast(`Welcome to LifeTown, ${S.name}! 🌆`, 'Walk with WASD · tap E to interact · make friends!', '👋', 'gold');
});
async function enterZoneVisual(zoneId: string, homeOpts?: { tier?: string; layout?: any[] }) {
const isHome = zoneId.startsWith('home:');
const spawn = await world.enterZone(
zoneId,
isHome ? (homeOpts?.tier || S.profile?.houseTier || 'studio') : undefined,
isHome ? (homeOpts?.layout || S.profile?.homeLayout || []) : undefined
);
controller.town = world.town as any;
controller.setInterior(world.interior);
if (world.me) {
world.me.avatar.group.position.set(spawn.x, 0, spawn.z);
}
updateFurnitureAuras();
hidePrompt();
return spawn;
}
bus.on('srv:zoneOk', async (m: any) => {
S.zone = m.zone;
const visiting = m.zone.startsWith('home:') && m.own === false;
await enterZoneVisual(m.zone, visiting ? { tier: m.tier, layout: m.layout } : undefined);
audio.door();
if (m.zone === 'town') {
toast('Maple Court', 'You are back on the street.', '🌆');
} else if (m.zone.startsWith('venue:')) {
const v = VENUES[m.zone];
if (v) toast(v.name, 'Walk to a glowing spot and press E. The front mat leads outside.', v.name.includes('Café') ? '☕' : v.name.includes('Gym') ? '🏋️' : v.name.includes('Hall') ? '🏛️' : v.name.includes('Mart') ? '🛒' : '🕹️');
} else if (visiting) {
toast(`Visiting ${m.ownerName || m.zone.slice(5)} 🏡`, 'Mind your manners — their couch, their rules!', '🛋️');
} else {
toast('Home sweet home 🏠', 'Tap 🔨 Build to decorate. Use furniture by walking up to it.', '🛋️');
}
});
function updateFurnitureAuras() {
const auras: Record<string, number> = { energyRate: 1, hygieneRate: 1, funRate: 1, comfortRate: 1, cookSpeed: 1, productivity: 1, hungerRate: 1 };
let comfortBonus = 0, itemCount = 0;
for (const f of world.interior?.furniture || []) {
const def = CATALOG_BY_ID[f.item.itemId];
if (!def) continue;
itemCount++;
for (const [k, v] of Object.entries(def.fx)) {
if (typeof v === 'number' && k in auras) auras[k] *= v;
}
}
comfortBonus = Math.min(0.5, itemCount * 0.01);
auras.comfortRate *= 1 + comfortBonus;
NeedsSys.auras = auras;
}
world.interiorChangedHook = () => updateFurnitureAuras();
// ---------------- action bar ----------------
function setupActionHandlers() {
document.querySelectorAll('.abtn').forEach(b => b.addEventListener('click', () => {
const act = (b as HTMLElement).dataset.act;
audio.click();
if (act === 'interact') controller.tryInteract();
else if (act === 'emote') doDance();
else if (act === 'phone') togglePhone();
else if (act === 'map') { togglePhone(true); document.querySelector('.papp-btn[data-app="map"]')?.dispatchEvent(new Event('click')); }
else if (act === 'bag') { ($('#inventory') as HTMLElement).classList.toggle('hidden'); buildui.renderInventory(); }
else if (act === 'home') openHomeMenu();
else if (act === 'build') toggleBuildMode();
}));
bus.on('act:dance', () => doDance());
$('#inv-close')?.addEventListener('click', () => ($('#inventory') as HTMLElement).classList.add('hidden'));
bus.on('build:exit', () => toggleBuildMode(false));
// clicking an actor opens the social wheel (short tap only, not camera drags)
canvas.addEventListener('pointerup', ((e: PointerEvent) => {
const downAt = (canvas as any)._pd as number | undefined;
const downX = (canvas as any)._pdx ?? e.clientX;
const moved = Math.abs(e.clientX - downX);
if (downAt && performance.now() - downAt < 240 && moved < 6 && !S.buildMode) {
tryActorPick(e.clientX, e.clientY);
}
(canvas as any)._pd = undefined;
}));
canvas.addEventListener('pointerdown', (e) => { (canvas as any)._pd = performance.now(); (canvas as any)._pdx = e.clientX; });
function tryActorPick(cx: number, cy: number) {
if (!world.me) return;
const raycaster = new THREE.Raycaster();
raycaster.setFromCamera(new THREE.Vector2((cx / window.innerWidth) * 2 - 1, -(cy / window.innerHeight) * 2 + 1), engine.camera);
const groups = [...world.actors.values()].filter(a => a.id !== 'me').map(a => a.avatar.group);
const hits = raycaster.intersectObjects(groups, true);
if (!hits.length) return;
let obj: THREE.Object3D | null = hits[0].object;
while (obj && ![...world.actors.values()].some(a => a.avatar.group === obj)) obj = obj.parent;
const actor = [...world.actors.values()].find(a => a.avatar.group === obj);
if (!actor) return;
if (world.me!.avatar.group.position.distanceTo(actor.avatar.group.position) > 5.5)
return toast('Too far away', `Walk closer to ${actor.name} to chat.`, '🚶', 'coral');
openSocialWheel({ type: actor.isNpc ? 'npc' : 'player', id: actor.id, name: actor.name });
}
function doDance() {
net.send({ t: 'emote', id: 'dance' });
world.emote('me', 'dance');
NeedsSys.apply({ fun: +10 });
}
function openHomeMenu() {
openDialog('🏠 Home & Travel', (body) => {
const mk = (label: string, desc: string, cb: () => void, primary = false) => {
const b = el('button', primary ? 'btn btn-primary' : 'btn btn-ghost', `${label}`);
b.style.cssText = 'width:100%;margin-bottom:8px';
b.addEventListener('click', () => { document.getElementById('dialog')!.classList.add('hidden'); cb(); });
body.appendChild(b);
body.appendChild(el('div', 'dim', desc)).style.marginBottom = '10px';
};
mk('🌆 Go to Maple Court (town)', 'Public streets, shops, café, gym, park & pond.', () => net.send({ t: 'zone', id: 'town' }), true);
mk('🏠 Teleport to your home', 'Decorate, sleep, cook, shower.', () => net.send({ t: 'zone', id: `home:${S.name.toLowerCase()}` }));
mk('🏡 Property market', 'Upgrade your home as you level up.', () => openHouseMarket());
mk('📨 Invite a friend over', 'They must be online.', () => {
const input = el('input') as HTMLInputElement;
input.placeholder = 'Friend name…';
const btn = el('button', 'btn btn-accent', 'Invite');
btn.style.marginTop = '6px';
btn.addEventListener('click', () => net.send({ t: 'invite', to: input.value.trim() }));
const wrap = el('div'); wrap.appendChild(input); wrap.appendChild(btn);
body.appendChild(wrap);
});
});
}
}
function toggleBuildMode(force?: boolean) {
const want = force ?? !S.buildMode;
const ownHome = S.zone === `home:${S.name.toLowerCase()}`;
if (want && S.zone === 'town') return toast('Go home first', 'Build mode works inside your home.', '🏠', 'coral');
if (want && !ownHome) return toast('Not your place', 'You can only redecorate your own home.', '🚫', 'coral');
S.buildMode = want;
$('#buildbar').classList.toggle('hidden', !want);
(document.querySelector('[data-act="build"]') as HTMLElement)?.classList.toggle('active', want);
world.interior?.setBuildMode(want);
controller.mode = want ? 'build' : 'live';
if (want) {
buildui.setTool('place');
toast('Build Mode 🔨', 'Buy furniture in the Shop, place it, rotate ⟳, sell 🗑.', '🔨');
} else {
buildui.clearGhost();
}
audio.click();
}
// ---------------- touch controls ----------------
function setupTouch() {
if (!('ontouchstart' in window)) return;
$('#touch-ui').classList.remove('hidden');
const base = $('#joy-base'), knob = $('#joy-knob');
let jid: number | null = null;
const setKnob = (dx: number, dy: number) => {
knob.style.transform = `translate(calc(-50% + ${dx}px), calc(-50% + ${dy}px))`;
};
base.addEventListener('pointerdown', (e) => { jid = e.pointerId; base.setPointerCapture(jid); move(e); });
base.addEventListener('pointermove', (e) => { if (e.pointerId === jid) move(e); });
const end = (e: PointerEvent) => {
if (e.pointerId !== jid) return;
jid = null; controller.input.x = 0; controller.input.z = 0; setKnob(0, 0);
};
base.addEventListener('pointerup', end); base.addEventListener('pointercancel', end);
function move(e: PointerEvent) {
const r = base.getBoundingClientRect();
let dx = e.clientX - (r.left + r.width / 2);
let dy = e.clientY - (r.top + r.height / 2);
const max = r.width / 2;
const len = Math.hypot(dx, dy);
if (len > max) { dx = dx / len * max; dy = dy / len * max; }
setKnob(dx, dy);
controller.input.x = dx / max;
controller.input.z = dy / max;
controller.input.run = len / max > 0.92;
}
($('#tb-interact') as HTMLElement).addEventListener('click', () => controller.tryInteract());
($('#tb-jump-run') as HTMLElement).addEventListener('click', () => {
controller.input.run = !controller.input.run;
($('#tb-jump-run') as HTMLElement).style.background = controller.input.run ? 'linear-gradient(135deg,#ff7e5f,#ffcf6b)' : '';
});
}
// ---------------- interaction scanning ----------------
let currentPrompt: (() => void) | null = null;
function scanInteraction(): { label: string; cb: () => void } | null {
// always rescan right before acting so the action matches current position
updatePromptScan();
if (!currentPrompt) {
// fall back to social target
const near = world.nearestActor(true, 4.2);
if (near) return { label: `Talk to ${near.name}`, cb: () => openSocialWheel({ type: near.isNpc ? 'npc' : 'player', id: near.id, name: near.name }) };
return null;
}
return { label: '', cb: currentPrompt };
}
function updatePromptScan() {
currentPrompt = null;
const mePos = world.me?.avatar.group.position;
if (!mePos) return;
// ---- interiors (home or venue): exit mat > venue spots > furniture
if (S.zone !== 'town' && world.interior) {
// exit mat sits at the front of every interior room
const exitZ = world.interior.size[1] / 2 - 0.7;
if (Math.hypot(mePos.x - 0, mePos.z - exitZ) < 1.6) {
const isVenue = S.zone.startsWith('venue:');
showPrompt(isVenue ? 'Step outside' : 'Leave home');
currentPrompt = () => { net.send({ t: 'zone', id: 'town' }); };
return;
}
// venue activity spots
if (S.zone.startsWith('venue:')) {
const v = VENUES[S.zone.slice(6)];
let bestSpot: { label: string; act: string; d: number } | null = null;
for (const sp of v?.spots || []) {
const d = Math.hypot(mePos.x - sp.x, mePos.z - sp.z);
if (d < 1.8 && (!bestSpot || d < bestSpot.d)) bestSpot = { label: sp.label, act: sp.act, d };
}
if (bestSpot) {
showPrompt(bestSpot.label);
currentPrompt = () => poiAct(bestSpot!.act);
return;
}
}
let best: { label: string; cb: () => void } | null = null;
let bd = 2.0;
for (const f of world.interior.furniture) {
const d = mePos.distanceTo(f.group.position);
const acts = categoryActions(f.cat);
if (acts.length && d < bd) {
bd = d;
best = { label: acts[0].label, cb: () => furnitureAct(f.item.itemId, f.cat, f.group.position) };
}
}
if (best) {
currentPrompt = best.cb;
showPrompt(best.label);
return;
}
} else if (S.zone === 'town') {
// building doors first — they take priority over outdoor POI rings
for (const door of DOORS) {
if (Math.hypot(mePos.x - door.x, mePos.z - door.z) < 2.1) {
showPrompt(door.label, door.zone === 'home:self' ? 'E' : 'E');
currentPrompt = () => {
audio.door();
net.send({ t: 'zone', id: door.zone === 'home:self' ? `home:${S.name.toLowerCase()}` : door.zone });
};
return;
}
}
let best: { poi: typeof POIS[0]; d: number } | null = null;
for (const poi of POIS) {
const d = Math.hypot(mePos.x - poi.x, mePos.z - poi.z);
if (d < poi.radius * 1.35 && (!best || d < best.d)) best = { poi, d };
}
if (best) {
currentPrompt = () => poiAct(best!.poi.id);
showPrompt(best.poi.name);
return;
}
}
const near = world.nearestActor(true, 4.2);
if (near) { showPrompt(`${near.name} — talk`); currentPrompt = () => openSocialWheel({ type: near.isNpc ? 'npc' : 'player', id: near.id, name: near.name }); return; }
hidePrompt();
}
function poiAct(id: string) {
switch (id) {
case 'cafe':
case 'foodtruck': {
if (S.money < 12) return toast('Not enough coins', 'A meal costs 12 ◉.', '💸', 'coral');
S.money -= 12; if (S.profile) S.profile.money = S.money;
net.send({ t: 'move', x: world.me!.avatar.group.position.x, z: world.me!.avatar.group.position.z, ry: 0, anim: 'eat' });
world.emote('me', 'eat');
setTimeout(() => {
NeedsSys.apply({ hunger: +45, fun: +6 });
bus.emit('eco:money');
toast('Yum! 🍽️', 'Hunger restored.', '🍽️');
}, 2600);
break;
}
case 'cityhall': openCityHall(); break;
case 'property': (buildui as any).openHouseMarket(); break;
case 'exercise':
case 'gym': {
world.emote('me', 'dance');
NeedsSys.apply({ energy: -14, fun: +10, happiness: +4 });
toast('Great workout! 💪', 'Energy used, endorphins gained.', '🏋️');
break;
}
case 'arcade': {
world.emote('me', 'talk');
NeedsSys.apply({ fun: +18, energy: -5 });
S.money = Math.max(0, S.money - 4); if (S.profile) S.profile.money = S.money;
bus.emit('eco:money');
audio.achievement();
toast('New high score! 🕹️', 'The Porcupine salutes you.', '🕹️');
break;
}
case 'store': (buildui as any).openCatalog(); break;
case 'pond': fishingGame((ok, reward) => {
NeedsSys.apply({ fun: ok ? +16 : +4 });
S.money += reward; if (S.profile) S.profile.money = S.money;
bus.emit('eco:money');
toast(ok ? `Caught fish! +${reward} ◉ 🐟` : 'The fish won this time…', ok ? 'Sold at the market stall.' : 'Try again!', ok ? '🎣' : '💧');
}); break;
case 'stage': {
net.send({ t: 'emote', id: 'dance' });
world.emote('me', 'dance');
NeedsSys.apply({ fun: +14, social: +6, energy: -6 });
break;
}
case 'fountain': {
world.emote('me', 'sit');
NeedsSys.apply({ comfort: +8, fun: +4 });
toast('Peaceful… ⛲', 'You rest by the Copper Fountain.', '⛲');
break;
}
default: {
world.emote('me', 'sit');
NeedsSys.apply({ comfort: +10, energy: +4 });
toast('Sitting down 🪑', 'Take a breather. Move to stand up.', '🪑');
}
}
}
function furnitureAct(itemId: string, cat: string, pos: THREE.Vector3) {
void pos;
switch (cat) {
case 'bed': {
NeedsSys.sleeping = true;
world.emote('me', 'sleep');
sleepSequence('Good morning! ☀️ You feel refreshed.', 4, () => {
NeedsSys.sleeping = false;
NeedsSys.apply({ energy: +70, hygiene: -18, hunger: -22 });
world.me?.avatar.setAnim('idle');
net.send({ t: 'move', x: world.me!.avatar.group.position.x, z: world.me!.avatar.group.position.z, ry: world.me!.avatar.group.rotation.y, anim: 'idle' });
});
break;
}
case 'shower': {
world.me?.avatar.setAnim('talk');
setTimeout(() => NeedsSys.apply({ hygiene: +60, comfort: +10, fun: +4 }), 2200);
toast('Fresh & clean 🚿', '', '🚿');
break;
}
case 'toilet': NeedsSys.apply({ hygiene: +12 }); toast('', '', '🚽'); break;
case 'sink': NeedsSys.apply({ hygiene: +20 }); break;
case 'fridge': {
NeedsSys.apply({ hunger: +26 });
world.emote('me', 'eat');
toast('Snack time 🍎', '+26 hunger', '🍎');
break;
}
case 'stove': {
if (S.money < 10) return toast('Need ingredients', 'Cooking costs 10 ◉.', '💸', 'coral');
cookingGame((good) => {
S.money -= 10; bus.emit('eco:money');
NeedsSys.apply({
hunger: good ? +58 : +30,
fun: good ? +12 : +3,
happiness: good ? 6 : -2,
});
world.emote('me', 'eat');
toast(good ? 'Chef quality! 🍜' : 'Edible, at least…', good ? '+58 hunger' : '+30 hunger', good ? '🍳' : '🥴');
});
break;
}
case 'tv': case 'pc': case 'elec': case 'shelf': case 'sofa': {
const gain = cat === 'sofa' ? 8 : 14;
world.emote('me', cat === 'sofa' ? 'sit' : 'talk');
NeedsSys.apply({ fun: +gain, comfort: +6, energy: +2 });
toast('Good times ✨', `+${gain} fun`, '🎉');
break;
}
default: {
const def = CATALOG_BY_ID[itemId];
toast(def?.name || 'Furniture', 'Looks lovely in here!', '✨');
}
}
}
function canWorkHere(): boolean {
const p = S.profile;
if (!p?.job) return false;
const job = JOBS.find(j => j.id === p.job!.id)!;
const mePos = world.me?.avatar.group.position;
if (!mePos) return false;
const poi = POIS.find(x => x.id === job.poi);
const anchor = poi ?? { x: 0, z: -26 };
return Math.hypot(mePos.x - anchor.x, mePos.z - anchor.z) < 8;
}
function openCityHall() {
openDialog('🏛️ LifeTown City Hall', (body, close) => {
const p = S.profile;
body.appendChild(el('div', '', `<b>Career desk</b><br><span class="dim">${p?.job ? 'You can work shifts at your workplace (see Phone → Jobs).' : 'Choose your calling below — you can change careers anytime.'}</span>`)).style.marginBottom = '12px';
for (const j of JOBS.slice(0, 12)) {
const active = p?.job?.id === j.id;
const row = el('div', 'stat-row', `<span><b>${j.title}</b> <span class="dim">· ${j.ranks[0]}</span></span>
<span style="display:flex;gap:8px;align-items:center"><span class="dim">${j.basePay} ◉</span>${active ? '<span class="tx-in">Current ✓</span>' : '<button class="btn btn-accent" style="padding:6px 13px;font-size:.78rem">Apply</button>'}</span>`);
if (!active) (row.querySelector('button') as HTMLElement)?.addEventListener('click', () => {
net.send({ t: 'job_select', jobId: j.id });
close();
});
body.appendChild(row);
}
const marketBtn = el('button', 'btn btn-ghost', '🏡 Browse Property Market');
marketBtn.style.cssText = 'width:100%;margin-top:12px';
marketBtn.addEventListener('click', openHouseMarket);
body.appendChild(marketBtn);
});
}
// ---------------- server events ----------------
bus.on('srv:workResult', (m: any) => {
audio.cash();
toast(`Shift complete! +${m.pay}`, `XP +${m.xp}${m.promoted ? ' · PROMOTED! 📈' : ''}`, m.promoted ? '📈' : '💰', m.promoted ? 'gold' : '');
if (m.promoted) audio.levelup();
NeedsSys.apply({ energy: -12, fun: -4, happiness: m.promoted ? 12 : 2 });
NeedsSys.addMoodlet({ id: 'worked', label: 'Productive', emoji: '💼', happiness: 6, expires: Date.now() + 180000 });
});
bus.on('srv:socialFx', () => {
// relationship-driven moodlets
});
bus.on('rel:update', () => {
const rels = Object.values(S.profile?.relationships || {});
const partner = rels.some(r => r.partner);
if (partner) NeedsSys.addMoodlet({ id: 'romance', label: 'Romance', emoji: '💞', happiness: 10 });
else NeedsSys.removeMoodlet('Romance');
});
window.addEventListener('lt:lightning', () => {
const f = document.createElement('div');
f.style.cssText = 'position:absolute;inset:0;background:#eaf2ff;z-index:15;opacity:.85;transition:opacity .3s;pointer-events:none';
document.getElementById('app')!.appendChild(f);
requestAnimationFrame(() => { f.style.opacity = '0'; setTimeout(() => f.remove(), 350); });
setTimeout(() => audio.thunder(), 200 + Math.random() * 800);
});
// ---------------- main loop ----------------
const clock = new THREE.Clock();
let promptScanT = 0;
let miniEmitT = 0;
let lastEventDayMin = -1;
function loop() {
requestAnimationFrame(loop);
const dt = Math.min(clock.getDelta(), 0.066);
// local clock interpolation between server beats
S.time.minutes += (dt * S.timeScale) / 60;
if (S.time.minutes >= 1440) { S.time.minutes -= 1440; S.time.day++; }
const minutes = S.time.minutes;
engine.setTimeOfDay(minutes, S.weather);
world.town?.update(dt, minutes, S.weather);
// actors + camera
const me = world.me;
if (me) {
// anim override expiry back to idle for local player
controller.update(dt, me.avatar);
}
world.update(dt);
// fx
const cam = engine.camera.position;
weatherfx.update(dt, S.weather, cam.x, cam.z);
audio.setRain(S.weather === 'storm' ? 1 : S.weather === 'rain' ? .6 : 0);
audio.tick();
// music by context
const hour = minutes / 60;
if (S.zone === 'town') audio.setTrack(hour >= 21 || hour < 6 ? 'night' : 'town');
else audio.setTrack('home');
// prompts & minimap throttles
promptScanT -= dt;
if (promptScanT <= 0 && !S.buildMode) { promptScanT = 0.18; updatePromptScan(); }
miniEmitT -= dt;
if (miniEmitT <= 0 && world.me) {
miniEmitT = 0.3;
const ps: any[] = [];
for (const a of world.actors.values()) {
if (a.isNpc && S.zone !== 'town') continue;
ps.push({ x: a.avatar.group.position.x, z: a.avatar.group.position.z, me: a.id === 'me' });
}
bus.emit('mini:positions', ps);
(window as any).__myPosForMap = { x: world.me!.avatar.group.position.x, z: world.me!.avatar.group.position.z };
}
// recurring event banner
const dayMin = S.time.day * 1440 + Math.floor(S.time.minutes);
if (lastEventDayMin !== dayMin) {
lastEventDayMin = dayMin;
const hm = Math.floor(S.time.minutes);
if (hm === 1200) showEventBanner('🎶 Plaza Dance Night — meet at the stage!');
else if (hm === 540) showEventBanner('🍎 Morning Market opened at MapleMart!');
}
engine.render();
}
function showEventBanner(text: string) {
if (document.getElementById('event-banner')) return;
const b = document.createElement('div');
b.id = 'event-banner';
b.textContent = text;
document.getElementById('hud')!.appendChild(b);
audio.levelup();
setTimeout(() => b.remove(), 30000);
}
// keep chat Enter focus behaviour
window.addEventListener('keydown', (e) => {
if (e.code === 'Enter' && !S.chatOpen && booted && !(e.target instanceof HTMLInputElement)) {
($('#chat-input') as HTMLElement).focus();
}
if (e.code === 'Escape') { closeWheel(); togglePhone(false); }
});