// 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 = { 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', '', `Career desk
${p?.job ? 'You can work shifts at your workplace (see Phone → Jobs).' : 'Choose your calling below — you can change careers anytime.'}`)).style.marginBottom = '12px'; for (const j of JOBS.slice(0, 12)) { const active = p?.job?.id === j.id; const row = el('div', 'stat-row', `${j.title} · ${j.ranks[0]} ${j.basePay} ◉${active ? 'Current ✓' : ''}`); 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); } });