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:
@@ -0,0 +1,197 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover" />
|
||||
<meta name="theme-color" content="#141826" />
|
||||
<title>LifeTown Online</title>
|
||||
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'%3E%3Ccircle cx='50' cy='50' r='46' fill='%23ff7e5f'/%3E%3Cpath d='M28 62V44l22-18 22 18v18a4 4 0 0 1-4 4H32a4 4 0 0 1-4-4z' fill='%23fff'/%3E%3C/svg%3E" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="app">
|
||||
<canvas id="gl"></canvas>
|
||||
|
||||
<!-- ===== Title / login screen ===== -->
|
||||
<div id="screen-title" class="screen">
|
||||
<div class="title-sky"></div>
|
||||
<div class="title-card glass">
|
||||
<div class="wordmark">Life<span>Town</span></div>
|
||||
<div class="tagline">your town · your people · your story</div>
|
||||
<div class="field-row">
|
||||
<input id="inp-name" maxlength="20" placeholder="Choose your name…" autocomplete="off" spellcheck="false"/>
|
||||
</div>
|
||||
<button id="btn-play" class="btn btn-primary btn-big">Enter LifeTown</button>
|
||||
<button id="btn-newchar" class="btn btn-ghost">Create New Character</button>
|
||||
<div class="hint" id="title-hint"></div>
|
||||
</div>
|
||||
<div class="title-foot">An original multiplayer life simulation · v0.1 MVP</div>
|
||||
</div>
|
||||
|
||||
<!-- ===== Character creator ===== -->
|
||||
<div id="screen-creator" class="screen hidden">
|
||||
<div class="creator-layout">
|
||||
<div id="creator-preview"></div>
|
||||
<div class="creator-panel glass">
|
||||
<div class="wordmark small">Design your resident</div>
|
||||
<div class="creator-scroll">
|
||||
<section>
|
||||
<label>Name</label>
|
||||
<input id="cr-name" maxlength="20" placeholder="Your name" autocomplete="off"/>
|
||||
<label>Age · <span id="cr-age-val">25</span></label>
|
||||
<input type="range" id="cr-age" min="18" max="70" value="25"/>
|
||||
</section>
|
||||
<section>
|
||||
<label>Body</label>
|
||||
<div class="chips" data-cr="bodyType"></div>
|
||||
<label>Height</label>
|
||||
<input type="range" id="cr-height" min="85" max="112" value="97"/>
|
||||
</section>
|
||||
<section><label>Skin tone</label><div class="swatches" data-cr="skin"></div></section>
|
||||
<section>
|
||||
<label>Hair style</label><div class="chips" data-cr="hair"></div>
|
||||
<label>Hair colour</label><div class="swatches" data-cr="hairColor"></div>
|
||||
</section>
|
||||
<section><label>Eyes</label><div class="chips" data-cr="eyes"></div>
|
||||
<label>Eye colour</label><div class="swatches" data-cr="eyeColor"></div></section>
|
||||
<section>
|
||||
<label>Brows</label><div class="chips" data-cr="brows"></div>
|
||||
<label>Nose</label><div class="chips" data-cr="nose"></div>
|
||||
<label>Mouth</label><div class="chips" data-cr="mouth"></div>
|
||||
</section>
|
||||
<section>
|
||||
<label>Outfit top</label><div class="chips" data-cr="outfitTop"></div>
|
||||
<label>Top colour</label><div class="swatches" data-cr="topColor"></div>
|
||||
<label>Bottom</label><div class="chips" data-cr="outfitBottom"></div>
|
||||
<label>Bottom colour</label><div class="swatches" data-cr="bottomColor"></div>
|
||||
<label>Shoes</label><div class="chips" data-cr="shoes"></div>
|
||||
</section>
|
||||
<section><label>Accessory</label><div class="chips" data-cr="accessory"></div></section>
|
||||
<section>
|
||||
<label>Personality <span class="dim">(pick up to 3)</span></label>
|
||||
<div class="chips multi" data-cr="personality"></div>
|
||||
</section>
|
||||
<section>
|
||||
<label>Voice</label><div class="chips" data-cr="voice"></div>
|
||||
</section>
|
||||
</div>
|
||||
<button id="btn-startlife" class="btn btn-primary btn-big">Start My Life ✨</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ===== In-game HUD ===== -->
|
||||
<div id="hud" class="hidden">
|
||||
<!-- top bar -->
|
||||
<div id="topbar">
|
||||
<div class="pill money-pill" title="TownCoins"><span class="coin">◉</span><span id="tb-money">0</span></div>
|
||||
<div class="pill clock-pill"><span id="tb-clock">07:30</span> · <span id="tb-day">Day 1</span></div>
|
||||
<div class="pill weather-pill"><span id="tb-weather">☀️</span><span id="tb-zone">Maple Court</span></div>
|
||||
<div class="pill online-pill" title="Residents online"><span>👥</span><span id="tb-online">1</span></div>
|
||||
<div class="top-actions">
|
||||
<button class="iconbtn" id="btn-audio" title="Sound">🔊</button>
|
||||
<button class="iconbtn" id="btn-fullscreen" title="Fullscreen">⛶</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- notifications -->
|
||||
<div id="toasts"></div>
|
||||
|
||||
<!-- right side: quests / context -->
|
||||
<div id="context-panel" class="glass">
|
||||
<div class="cp-title">✦ Now</div>
|
||||
<div id="cp-content"></div>
|
||||
</div>
|
||||
|
||||
<!-- bottom bar -->
|
||||
<div id="bottombar">
|
||||
<div id="need-rings"></div>
|
||||
<div id="mood-chip" class="glass"><span id="mood-emoji">🙂</span><span id="mood-label">Okay</span></div>
|
||||
<div id="action-bar">
|
||||
<button class="abtn" data-act="interact">👋<span>Act</span></button>
|
||||
<button class="abtn" data-act="emote">💃<span>Dance</span></button>
|
||||
<button class="abtn" data-act="phone">📱<span>Phone</span></button>
|
||||
<button class="abtn" data-act="map">🗺️<span>Map</span></button>
|
||||
<button class="abtn" data-act="bag">🎒<span>Bag</span></button>
|
||||
<button class="abtn" data-act="home">🏠<span>Home</span></button>
|
||||
<button class="abtn" data-act="build">🔨<span>Build</span></button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- interaction prompt -->
|
||||
<div id="prompt" class="hidden"><kbd id="prompt-key">E</kbd><span id="prompt-text"></span></div>
|
||||
|
||||
<!-- speech bubble layer anchor -->
|
||||
<div id="bubble-layer"></div>
|
||||
|
||||
<!-- chat -->
|
||||
<div id="chat-panel">
|
||||
<div id="chat-log"></div>
|
||||
<div class="chat-input-row">
|
||||
<input id="chat-input" placeholder="Say something… (Enter)" maxlength="240" autocomplete="off"/>
|
||||
<select id="chat-channel"><option value="say">Say</option><option value="global">Global</option></select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- mobile controls -->
|
||||
<div id="touch-ui" class="hidden">
|
||||
<div id="joy-base"><div id="joy-knob"></div></div>
|
||||
<div id="touch-buttons">
|
||||
<button id="tb-interact">✋</button>
|
||||
<button id="tb-jump-run">🏃</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- interaction wheel -->
|
||||
<div id="wheel" class="hidden glass"></div>
|
||||
|
||||
<!-- phone -->
|
||||
<div id="phone" class="hidden">
|
||||
<div class="phone-frame">
|
||||
<div class="phone-top"><span id="phone-clock">07:30</span><span>LTE ◉◉◉</span></div>
|
||||
<div id="phone-screen"></div>
|
||||
<div class="phone-dock" id="phone-dock"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- build mode UI -->
|
||||
<div id="buildbar" class="hidden">
|
||||
<div class="bb-title">🔨 Build Mode</div>
|
||||
<div class="bb-tools">
|
||||
<button class="tool active" data-tool="place">🛋 Place</button>
|
||||
<button class="tool" data-tool="move">✥ Move</button>
|
||||
<button class="tool" data-tool="rotate">⟳ Rotate</button>
|
||||
<button class="tool" data-tool="duplicate">⧉ Copy</button>
|
||||
<button class="tool" data-tool="delete">🗑 Sell</button>
|
||||
</div>
|
||||
<button id="btn-shop" class="btn btn-accent">🛒 Shop Furniture</button>
|
||||
<button id="btn-exit-build" class="btn btn-ghost">✓ Live Mode</button>
|
||||
</div>
|
||||
|
||||
<!-- catalog modal -->
|
||||
<div id="catalog" class="modal hidden"><div class="modal-card glass">
|
||||
<div class="modal-head"><span>🛒 MapleMart Catalogue</span><button class="iconbtn" id="catalog-close">✕</button></div>
|
||||
<div class="cat-tabs" id="cat-tabs"></div>
|
||||
<div id="cat-grid" class="cat-grid"></div>
|
||||
</div></div>
|
||||
|
||||
<!-- inventory modal -->
|
||||
<div id="inventory" class="modal hidden"><div class="modal-card glass">
|
||||
<div class="modal-head"><span>🎒 Inventory</span><button class="iconbtn" id="inv-close">✕</button></div>
|
||||
<div id="inv-grid" class="cat-grid"></div>
|
||||
</div></div>
|
||||
|
||||
<!-- generic dialog -->
|
||||
<div id="dialog" class="modal hidden"><div class="modal-card glass small">
|
||||
<div class="modal-head"><span id="dialog-title"></span><button class="iconbtn" id="dialog-close">✕</button></div>
|
||||
<div id="dialog-body"></div>
|
||||
</div></div>
|
||||
|
||||
<!-- minimap -->
|
||||
<canvas id="minimap" width="180" height="180"></canvas>
|
||||
</div>
|
||||
|
||||
<div id="loading" class="hidden"><div class="loader-ring"></div><div id="loading-text">Warming up LifeTown…</div></div>
|
||||
</div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,162 @@
|
||||
// LifeTown Online — fully procedural audio: location music, ambience, SFX.
|
||||
// No external assets; everything is synthesised with WebAudio.
|
||||
|
||||
type TrackId = 'home' | 'town' | 'park' | 'date' | 'night';
|
||||
|
||||
export class AudioEngine {
|
||||
ctx: AudioContext | null = null;
|
||||
master!: GainNode;
|
||||
musicGain!: GainNode;
|
||||
sfxGain!: GainNode;
|
||||
rainGain!: GainNode;
|
||||
private track: TrackId | null = null;
|
||||
private stepTimer: number | null = null;
|
||||
private step = 0;
|
||||
private nextStepAt = 0;
|
||||
enabled = true;
|
||||
|
||||
init() {
|
||||
if (this.ctx) return;
|
||||
this.ctx = new AudioContext();
|
||||
this.master = this.ctx.createGain(); this.master.gain.value = 0.8;
|
||||
this.master.connect(this.ctx.destination);
|
||||
this.musicGain = this.ctx.createGain(); this.musicGain.gain.value = 0.34; this.musicGain.connect(this.master);
|
||||
this.sfxGain = this.ctx.createGain(); this.sfxGain.gain.value = 0.5; this.sfxGain.connect(this.master);
|
||||
// rain noise loop
|
||||
const buf = this.noiseBuffer(2);
|
||||
const src = this.ctx.createBufferSource(); src.buffer = buf; src.loop = true;
|
||||
const filt = this.ctx.createBiquadFilter(); filt.type = 'lowpass'; filt.frequency.value = 900;
|
||||
this.rainGain = this.ctx.createGain(); this.rainGain.gain.value = 0;
|
||||
src.connect(filt); filt.connect(this.rainGain); this.rainGain.connect(this.master);
|
||||
src.start();
|
||||
}
|
||||
|
||||
setEnabled(on: boolean) {
|
||||
this.enabled = on;
|
||||
if (this.ctx) this.master.gain.value = on ? 0.8 : 0;
|
||||
}
|
||||
|
||||
private noiseBuffer(sec: number) {
|
||||
const len = this.ctx!.sampleRate * sec;
|
||||
const buf = this.ctx!.createBuffer(1, len, this.ctx!.sampleRate);
|
||||
const d = buf.getChannelData(0);
|
||||
for (let i = 0; i < len; i++) d[i] = Math.random() * 2 - 1;
|
||||
return buf;
|
||||
}
|
||||
|
||||
setTrack(t: TrackId) {
|
||||
if (this.track === t) return;
|
||||
this.track = t;
|
||||
this.step = 0;
|
||||
}
|
||||
|
||||
setRain(amount: number) {
|
||||
if (!this.ctx) return;
|
||||
this.rainGain.gain.linearRampToValueAtTime(amount * 0.14, (this.ctx.currentTime + 1));
|
||||
}
|
||||
|
||||
/** call every frame; schedules notes just-in-time */
|
||||
tick() {
|
||||
if (!this.ctx || !this.enabled || !this.track) return;
|
||||
const bpmByTrack: Record<TrackId, number> = { home: 72, town: 112, park: 92, date: 84, night: 120 };
|
||||
const bpm = bpmByTrack[this.track];
|
||||
const beat = 60 / bpm / 2; // 8th notes
|
||||
const now = this.ctx.currentTime;
|
||||
if (this.nextStepAt < now) this.nextStepAt = now + 0.05;
|
||||
while (this.nextStepAt < now + 0.25) {
|
||||
this.scheduleStep(this.step++, this.nextStepAt, this.track);
|
||||
this.nextStepAt += beat;
|
||||
}
|
||||
}
|
||||
|
||||
private scheduleStep(step: number, at: number, tr: TrackId) {
|
||||
const bar = Math.floor(step / 16) % 4;
|
||||
const s = step % 16;
|
||||
const N = (n: number) => 440 * Math.pow(2, (n - 69) / 12);
|
||||
if (tr === 'town') {
|
||||
const bass = [45, 45, 50, 52];
|
||||
if (s % 4 === 0) this.pluck(N(bass[bar]) - 12, at, .3, 'triangle', .18);
|
||||
if (s % 2 === 0) this.pluck(N([72, 76, 79, 81][(s / 2 + bar) % 4]), at, .22, 'square', .05);
|
||||
if (s === 8 || s === 12) this.pluck(N(64 + bar), at, .3, 'sawtooth', .04);
|
||||
if (s % 8 === 4) this.hat(at, .05);
|
||||
if (s % 4 === 0) this.kick(at);
|
||||
} else if (tr === 'home') {
|
||||
const chords = [[57, 60, 64], [55, 59, 62], [53, 57, 60], [55, 58, 62]];
|
||||
if (s === 0) for (const n of chords[bar]) this.pad(N(n), at, 2.2, .05);
|
||||
if (s === 10) this.pluck(N(chords[bar][2] + 12), at, .5, 'sine', .06);
|
||||
} else if (tr === 'park') {
|
||||
if (s % 4 === 0) this.pluck(N([67, 69, 64, 62][bar]), at, .4, 'sine', .09);
|
||||
if (s % 8 === 6) this.pluck(N([74, 76, 71, 69][bar]), at, .5, 'triangle', .06);
|
||||
if (s === 0) for (const n of [48, 55]) this.pad(N(n), at, 1.6, .03);
|
||||
} else if (tr === 'date') {
|
||||
if (s % 8 === 0) { for (const n of [65, 69, 72]) this.pad(N(n - 12), at, 2.4, .04); }
|
||||
if (s % 4 === 2) this.pluck(N([77, 76, 72, 69][bar]), at, .6, 'sine', .05);
|
||||
} else { // night
|
||||
if (s % 4 === 0) this.kick(at);
|
||||
if (s % 4 === 2) this.hat(at, .07);
|
||||
if (s % 2 === 0) this.pluck(N([57, 57, 60, 62][bar]) + (s % 8 === 6 ? 3 : 0), at, .2, 'sawtooth', .045);
|
||||
}
|
||||
}
|
||||
|
||||
private pluck(freq: number, at: number, dur: number, type: OscillatorType, gain: number) {
|
||||
const o = this.ctx!.createOscillator(); o.type = type; o.frequency.value = freq;
|
||||
const g = this.ctx!.createGain();
|
||||
g.gain.setValueAtTime(gain, at);
|
||||
g.gain.exponentialRampToValueAtTime(0.0001, at + dur);
|
||||
o.connect(g); g.connect(this.musicGain);
|
||||
o.start(at); o.stop(at + dur + 0.05);
|
||||
}
|
||||
private pad(freq: number, at: number, dur: number, gain: number) {
|
||||
const o = this.ctx!.createOscillator(); o.type = 'sine'; o.frequency.value = freq;
|
||||
const o2 = this.ctx!.createOscillator(); o2.type = 'triangle'; o2.frequency.value = freq * 1.005;
|
||||
const g = this.ctx!.createGain();
|
||||
g.gain.setValueAtTime(0, at);
|
||||
g.gain.linearRampToValueAtTime(gain, at + 0.4);
|
||||
g.gain.linearRampToValueAtTime(0, at + dur);
|
||||
o.connect(g); o2.connect(g); g.connect(this.musicGain);
|
||||
o.start(at); o.stop(at + dur); o2.start(at); o2.stop(at + dur);
|
||||
}
|
||||
private kick(at: number) {
|
||||
const o = this.ctx!.createOscillator(); o.type = 'sine';
|
||||
o.frequency.setValueAtTime(130, at); o.frequency.exponentialRampToValueAtTime(42, at + 0.11);
|
||||
const g = this.ctx!.createGain();
|
||||
g.gain.setValueAtTime(.22, at); g.gain.exponentialRampToValueAtTime(.001, at + 0.16);
|
||||
o.connect(g); g.connect(this.musicGain); o.start(at); o.stop(at + 0.2);
|
||||
}
|
||||
private hat(at: number, gain: number) {
|
||||
const src = this.ctx!.createBufferSource(); src.buffer = this.noiseBuffer(0.06);
|
||||
const f = this.ctx!.createBiquadFilter(); f.type = 'highpass'; f.frequency.value = 6500;
|
||||
const g = this.ctx!.createGain();
|
||||
g.gain.setValueAtTime(gain, at); g.gain.exponentialRampToValueAtTime(.0001, at + 0.05);
|
||||
src.connect(f); f.connect(g); g.connect(this.musicGain);
|
||||
src.start(at);
|
||||
}
|
||||
|
||||
// ---------------- SFX ----------------
|
||||
private blip(freq: number, dur: number, type: OscillatorType, gain = 0.2, slideTo?: number) {
|
||||
if (!this.ctx || !this.enabled) return;
|
||||
const t = this.ctx.currentTime;
|
||||
const o = this.ctx.createOscillator(); o.type = type; o.frequency.setValueAtTime(freq, t);
|
||||
if (slideTo) o.frequency.exponentialRampToValueAtTime(slideTo, t + dur);
|
||||
const g = this.ctx.createGain();
|
||||
g.gain.setValueAtTime(gain, t); g.gain.exponentialRampToValueAtTime(.0001, t + dur);
|
||||
o.connect(g); g.connect(this.sfxGain);
|
||||
o.start(t); o.stop(t + dur + 0.02);
|
||||
}
|
||||
click() { this.blip(600, .07, 'square', .08); }
|
||||
pop() { this.blip(300, .12, 'sine', .18, 520); }
|
||||
cash() { this.blip(880, .09, 'square', .12); setTimeout(() => this.blip(1320, .14, 'square', .12), 70); }
|
||||
door() { this.blip(180, .18, 'triangle', .15, 90); }
|
||||
err() { this.blip(220, .16, 'sawtooth', .1, 160); }
|
||||
footstep(run: boolean) { this.blip(90 + Math.random() * 30, .05, 'triangle', run ? .06 : .04); }
|
||||
thunder() { this.blip(55 + Math.random() * 20, .9, 'sawtooth', .12, 26); }
|
||||
achievement() {
|
||||
[523, 659, 784, 1047].forEach((f, i) => setTimeout(() => this.blip(f, .18, 'triangle', .16), i * 95));
|
||||
}
|
||||
levelup() { [392, 494, 587, 784].forEach((f, i) => setTimeout(() => this.blip(f, .16, 'square', .1), i * 80)); }
|
||||
}
|
||||
|
||||
export const audio = new AudioEngine();
|
||||
|
||||
// resume on first gesture
|
||||
window.addEventListener('pointerdown', () => { audio.init(); audio.ctx?.resume?.(); }, { once: false });
|
||||
@@ -0,0 +1,175 @@
|
||||
// LifeTown Online — player controller: input, movement & collision, camera rig,
|
||||
// interaction scanning, network transform sync.
|
||||
import * as THREE from 'three';
|
||||
import { Engine } from './gfx/engine';
|
||||
import { Town } from './gfx/town';
|
||||
import { Interior } from './gfx/interior';
|
||||
import { S, bus } from './state';
|
||||
import { net } from './net';
|
||||
import { audio } from './audio';
|
||||
|
||||
export interface InputState {
|
||||
x: number; z: number; // joystick / axes (-1..1)
|
||||
run: boolean;
|
||||
}
|
||||
|
||||
const WALK = 3.4, RUN = 6.0;
|
||||
|
||||
export class Controller {
|
||||
input: InputState = { x: 0, z: 0, run: false };
|
||||
yaw = Math.PI; // camera orbit yaw
|
||||
pitch = 0.62;
|
||||
dist = 11;
|
||||
private targetPos = new THREE.Vector3(0, 1.2, 0);
|
||||
private keys = new Set<string>();
|
||||
private lastSent = 0;
|
||||
private lastFx = { x: 0, z: 0 };
|
||||
frozen = false; // during dialogs/sleep
|
||||
mode: 'live' | 'build' = 'live';
|
||||
onPrompt: ((info: { label: string; key?: string; cb: () => void } | null) => void) | null = null;
|
||||
|
||||
constructor(
|
||||
public engine: Engine,
|
||||
public town: Town | null,
|
||||
public interior: Interior | null,
|
||||
public getPlayerPos: () => THREE.Vector3,
|
||||
public scanInteract: () => { label: string; cb: () => void } | null,
|
||||
) {
|
||||
window.addEventListener('keydown', (e) => {
|
||||
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLSelectElement || e.target instanceof HTMLTextAreaElement) return;
|
||||
this.keys.add(e.code);
|
||||
if (e.code === 'KeyE') this.tryInteract();
|
||||
if (e.code === 'KeyF') bus.emit('act:dance');
|
||||
});
|
||||
window.addEventListener('keyup', (e) => this.keys.delete(e.code));
|
||||
// mouse orbit
|
||||
const el = engine.renderer.domElement;
|
||||
let dragging = false, lx = 0, ly = 0;
|
||||
el.addEventListener('pointerdown', (e) => { if (e.button === 0 && !S.buildMode) { dragging = true; lx = e.clientX; ly = e.clientY; } });
|
||||
window.addEventListener('pointerup', () => dragging = false);
|
||||
window.addEventListener('pointermove', (e) => {
|
||||
if (!dragging) return;
|
||||
this.yaw -= (e.clientX - lx) * 0.0052;
|
||||
this.pitch = THREE.MathUtils.clamp(this.pitch + (e.clientY - ly) * 0.0035, 0.12, 1.25);
|
||||
lx = e.clientX; ly = e.clientY;
|
||||
});
|
||||
el.addEventListener('wheel', (e) => {
|
||||
this.dist = THREE.MathUtils.clamp(this.dist + e.deltaY * 0.011, 4, 26);
|
||||
}, { passive: true });
|
||||
}
|
||||
|
||||
setInterior(i: Interior | null) { this.interior = i; }
|
||||
|
||||
tryInteract() {
|
||||
if (this.frozen || S.chatOpen) return;
|
||||
const hit = this.scanInteract();
|
||||
if (hit) { audio.click(); hit.cb(); }
|
||||
}
|
||||
|
||||
update(dt: number, myAvatar: { group: THREE.Group; setAnim(a: string): void; getAnim(): string } | null) {
|
||||
const cam = this.engine.camera;
|
||||
if (!myAvatar) { this.updateCamera(dt); return; }
|
||||
const g = myAvatar.group;
|
||||
|
||||
// ---- gather input ----
|
||||
let ix = this.input.x, iz = this.input.z;
|
||||
if (!S.chatOpen && !this.frozen) {
|
||||
if (this.keys.has('KeyW') || this.keys.has('ArrowUp')) iz -= 1;
|
||||
if (this.keys.has('KeyS') || this.keys.has('ArrowDown')) iz += 1;
|
||||
if (this.keys.has('KeyA') || this.keys.has('ArrowLeft')) ix -= 1;
|
||||
if (this.keys.has('KeyD') || this.keys.has('ArrowRight')) ix += 1;
|
||||
}
|
||||
const run = this.input.run || this.keys.has('ShiftLeft') || this.keys.has('ShiftRight');
|
||||
const mag = Math.min(1, Math.hypot(ix, iz));
|
||||
const moving = mag > 0.06 && !this.frozen && !S.chatOpen && this.mode === 'live';
|
||||
|
||||
if (moving) {
|
||||
// camera-relative direction
|
||||
const sin = Math.sin(this.yaw), cos = Math.cos(this.yaw);
|
||||
const dx = ix * cos - iz * sin;
|
||||
const dz = ix * sin + iz * cos;
|
||||
const len = Math.hypot(dx, dz) || 1;
|
||||
const speed = (run ? RUN : WALK) * Math.min(1, mag);
|
||||
let nx = g.position.x + (dx / len) * speed * dt;
|
||||
let nz = g.position.z + (dz / len) * speed * dt;
|
||||
|
||||
// collisions
|
||||
if (this.interior) {
|
||||
const b = this.interior.bounds();
|
||||
nx = THREE.MathUtils.clamp(nx, -b.hw, b.hw);
|
||||
nz = THREE.MathUtils.clamp(nz, -b.hd, b.hd);
|
||||
// furniture rough blocking (circle vs footprint)
|
||||
for (const f of this.interior.furniture) {
|
||||
const dx2 = nx - f.group.position.x, dz2 = nz - f.group.position.z;
|
||||
const d2 = Math.hypot(dx2, dz2);
|
||||
if (d2 < 0.55 && d2 > 0.001) { nx = f.group.position.x + dx2 / d2 * 0.55; nz = f.group.position.z + dz2 / d2 * 0.55; }
|
||||
}
|
||||
} else if (this.town) {
|
||||
nx = THREE.MathUtils.clamp(nx, -72, 72);
|
||||
nz = THREE.MathUtils.clamp(nz, -46, 46);
|
||||
for (const c of this.town.colliders) {
|
||||
if (nx > c.x - c.hw - 0.35 && nx < c.x + c.hw + 0.35 && nz > c.z - c.hd - 0.35 && nz < c.z + c.hd + 0.35) {
|
||||
// push out along smallest penetration
|
||||
const pxl = nx - (c.x - c.hw - 0.35), pxr = (c.x + c.hw + 0.35) - nx;
|
||||
const pzl = nz - (c.z - c.hd - 0.35), pzr = (c.z + c.hd + 0.35) - nz;
|
||||
const m = Math.min(pxl, pxr, pzl, pzr);
|
||||
if (m === pxl) nx = c.x - c.hw - 0.35;
|
||||
else if (m === pxr) nx = c.x + c.hw + 0.35;
|
||||
else if (m === pzl) nz = c.z - c.hd - 0.35;
|
||||
else nz = c.z + c.hd + 0.35;
|
||||
}
|
||||
}
|
||||
}
|
||||
g.position.set(nx, 0, nz);
|
||||
g.rotation.y = Math.atan2(dx, dz);
|
||||
|
||||
const anim = run ? 'run' : 'walk';
|
||||
myAvatar.setAnim(anim);
|
||||
// footsteps
|
||||
const moved = Math.hypot(nx - this.lastFx.x, nz - this.lastFx.z);
|
||||
if (moved > 1.1) { this.lastFx.x = nx; this.lastFx.z = nz; audio.footstep(run); }
|
||||
} else if (this.mode === 'live') {
|
||||
if (['walk', 'run'].includes(myAvatar.getAnim())) myAvatar.setAnim('idle');
|
||||
}
|
||||
|
||||
// ---- network sync (throttled) ----
|
||||
const now = performance.now();
|
||||
if (now - this.lastSent > 66) {
|
||||
this.lastSent = now;
|
||||
net.send({ t: 'move', x: +g.position.x.toFixed(2), z: +g.position.z.toFixed(2), ry: +g.rotation.y.toFixed(2), anim: myAvatar.getAnim() });
|
||||
}
|
||||
|
||||
this.updateCamera(dt, g.position);
|
||||
}
|
||||
|
||||
private focusOverride: { pos: THREE.Vector3; until: number } | null = null;
|
||||
socialFocus(pos: THREE.Vector3) {
|
||||
this.focusOverride = { pos: pos.clone(), until: performance.now() + 2600 };
|
||||
}
|
||||
|
||||
updateCamera(dt: number, playerPos?: THREE.Vector3) {
|
||||
const cam = this.engine.camera;
|
||||
let target = this.targetPos;
|
||||
if (playerPos) target = playerPos;
|
||||
if (this.focusOverride) {
|
||||
if (performance.now() > this.focusOverride.until) this.focusOverride = null;
|
||||
else target = this.focusOverride.pos;
|
||||
}
|
||||
const wantDist = S.buildMode ? 16 : this.dist;
|
||||
const wantPitch = S.buildMode ? 1.05 : this.pitch;
|
||||
const curDist = cam.position.distanceTo(new THREE.Vector3(target.x, 0, target.z));
|
||||
const k = 1 - Math.pow(0.0001, dt); // smoothing
|
||||
const dist = THREE.MathUtils.lerp(curDist || wantDist, wantDist, Math.min(1, dt * 5));
|
||||
|
||||
const ox = Math.sin(this.yaw) * Math.cos(wantPitch * 0.9);
|
||||
const oz = Math.cos(this.yaw) * Math.cos(wantPitch * 0.9);
|
||||
const oy = Math.sin(wantPitch);
|
||||
cam.position.set(
|
||||
target.x + ox * dist,
|
||||
(target.y ?? 0) + oy * dist,
|
||||
target.z + oz * dist
|
||||
);
|
||||
cam.lookAt(target.x, (target.y ?? 0) + 1.1, target.z);
|
||||
void k;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,463 @@
|
||||
// LifeTown Online — stylized original characters: modular avatar builder +
|
||||
// procedural animation controller (walk, run, dance, emotes, expressions).
|
||||
import * as THREE from 'three';
|
||||
|
||||
const SKINS = ['#f7d7c4', '#eab98f', '#c98a5b', '#8a5a37', '#5d3a21', '#f2a97e'];
|
||||
|
||||
function mat(color: string | number, rough = 0.85, metal = 0) {
|
||||
return new THREE.MeshStandardMaterial({ color, roughness: rough, metalness: metal });
|
||||
}
|
||||
|
||||
function limb(len: number, thick: number, material: THREE.Material) {
|
||||
const g = new THREE.CapsuleGeometry(thick, len - thick * 2, 4, 10);
|
||||
const m = new THREE.Mesh(g, material);
|
||||
m.position.y = -len / 2;
|
||||
m.castShadow = true;
|
||||
return m;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
export class Avatar {
|
||||
group = new THREE.Group();
|
||||
private bodyG = new THREE.Group();
|
||||
private hips = new THREE.Group();
|
||||
private legL = new THREE.Group();
|
||||
private legR = new THREE.Group();
|
||||
private shinL = new THREE.Group();
|
||||
private shinR = new THREE.Group();
|
||||
private armL = new THREE.Group();
|
||||
private armR = new THREE.Group();
|
||||
private head = new THREE.Group();
|
||||
private eyeL!: THREE.Mesh; private eyeR!: THREE.Mesh;
|
||||
private irisL!: THREE.Mesh; private irisR!: THREE.Mesh;
|
||||
private browL!: THREE.Mesh; private browR!: THREE.Mesh;
|
||||
private mouth!: THREE.Mesh;
|
||||
private headTop = new THREE.Group();
|
||||
|
||||
private anim = 'idle';
|
||||
private phase = Math.random() * 10;
|
||||
private blinkAt = 2 + Math.random() * 4;
|
||||
private blinkT = 0;
|
||||
private expr = 'happy';
|
||||
private moodSprite: THREE.Sprite | null = null;
|
||||
|
||||
constructor(app: any, npcColorSeed?: number) {
|
||||
const a = app || {};
|
||||
const skin = a.skin || SKINS[npcColorSeed ? npcColorSeed % SKINS.length : 1];
|
||||
const topColor = a.topColor ?? (npcColorSeed != null ? hslColor(npcColorSeed * 47 % 360, .55, .6) : '#4f8fd9');
|
||||
const bottomColor = a.bottomColor ?? '#33415c';
|
||||
const hairColor = a.hairColor || '#4a2f1d';
|
||||
const shoeColor = a.shoes === 'boots' ? '#5a4632' : a.shoes === 'loafers' ? '#2e2a28' : '#e8e8ee';
|
||||
const bodyType = a.bodyType || 'average';
|
||||
const height = THREE.MathUtils.clamp(a.height ?? 1, .85, 1.12);
|
||||
|
||||
const skinM = mat(skin, .65);
|
||||
const topM = mat(topColor, .9);
|
||||
const bottomM = mat(bottomColor, .9);
|
||||
const shoeM = mat(shoeColor, .8);
|
||||
const hairM = mat(hairColor, .95);
|
||||
const whiteM = mat('#ffffff', .3);
|
||||
|
||||
// proportions
|
||||
const widthK = bodyType === 'slim' ? 0.86 : bodyType === 'plump' ? 1.22 : 1;
|
||||
|
||||
// ---- legs ----
|
||||
for (const [side, hip, shin] of [[-1, this.legL, this.shinL], [1, this.legR, this.shinR]] as const) {
|
||||
hip.position.set(side * 0.155 * widthK, 0.92, 0);
|
||||
const thigh = limb(0.48, 0.105, bottomM); thigh.position.y = -0.24; hip.add(thigh);
|
||||
shin.position.y = -0.48; hip.add(shin);
|
||||
const calf = limb(0.44, 0.09, bottomM); calf.position.y = -0.2; shin.add(calf);
|
||||
const shoe = new THREE.Mesh(new THREE.BoxGeometry(0.19, 0.11, 0.32), shoeM);
|
||||
shoe.position.set(0, -0.43, 0.05); shoe.castShadow = true; shin.add(shoe);
|
||||
this.hips.add(hip);
|
||||
}
|
||||
|
||||
// ---- torso ----
|
||||
const torso = new THREE.Mesh(new THREE.CapsuleGeometry(0.26 * widthK, 0.5, 6, 14), topM);
|
||||
torso.position.y = 1.24; torso.scale.set(widthK, 1, 0.92);
|
||||
torso.castShadow = true;
|
||||
this.bodyG.add(torso);
|
||||
|
||||
if (a.outfitBottom === 'skirt') {
|
||||
const skirt = new THREE.Mesh(new THREE.ConeGeometry(0.34 * widthK, 0.42, 14, 1, true), bottomM);
|
||||
skirt.position.y = 0.86; skirt.castShadow = true; this.bodyG.add(skirt);
|
||||
}
|
||||
|
||||
// ---- arms ----
|
||||
for (const [side, arm] of [[-1, this.armL], [1, this.armR]] as const) {
|
||||
arm.position.set(side * (0.27 * widthK + 0.075), 1.52, 0);
|
||||
const sleeve = limb(0.66, 0.085, topM); arm.add(sleeve);
|
||||
const hand = new THREE.Mesh(new THREE.SphereGeometry(0.085, 10, 8), skinM);
|
||||
hand.position.y = -0.68; hand.castShadow = true; arm.add(hand);
|
||||
this.bodyG.add(arm);
|
||||
}
|
||||
|
||||
// ---- head ----
|
||||
this.head.position.y = 1.78;
|
||||
const skull = new THREE.Mesh(new THREE.SphereGeometry(0.315, 20, 16), skinM);
|
||||
skull.scale.set(1, 1.06, 0.96); skull.castShadow = true;
|
||||
this.head.add(skull);
|
||||
const faceShape = a.faceShape || 'round';
|
||||
if (faceShape === 'oval') skull.scale.set(.94, 1.14, .96);
|
||||
if (faceShape === 'square') skull.scale.set(1.03, 1, .99);
|
||||
|
||||
// ears
|
||||
for (const side of [-1, 1]) {
|
||||
const ear = new THREE.Mesh(new THREE.SphereGeometry(0.06, 8, 6), skinM);
|
||||
ear.position.set(side * 0.3, 0, 0); this.head.add(ear);
|
||||
}
|
||||
|
||||
// eyes (white + iris + highlight)
|
||||
const eyeGeo = new THREE.SphereGeometry(0.072, 12, 10);
|
||||
const irisGeo = new THREE.SphereGeometry(0.038, 10, 8);
|
||||
const irisColor = a.eyeColor || '#3a5a40';
|
||||
const eyeStyle = a.eyes || 'round';
|
||||
const mkEye = (side: number) => {
|
||||
const holder = new THREE.Group(); holder.position.set(side * 0.115, 0.05, 0.26);
|
||||
const w = new THREE.Mesh(eyeGeo, whiteM);
|
||||
if (eyeStyle === 'sleepy') w.scale.set(1.15, 0.7, 0.8);
|
||||
if (eyeStyle === 'wide') w.scale.set(1.05, 1.2, 0.85);
|
||||
holder.add(w);
|
||||
const iris = new THREE.Mesh(irisGeo, mat(irisColor, .35));
|
||||
iris.position.z = 0.045; holder.add(iris);
|
||||
const glint = new THREE.Mesh(new THREE.SphereGeometry(0.013, 6, 5), mat('#ffffff', .2));
|
||||
glint.position.set(0.015, 0.02, 0.075); holder.add(glint);
|
||||
return { holder, w, iris };
|
||||
};
|
||||
const eL = mkEye(-1), eR = mkEye(1);
|
||||
this.eyeL = eL.w; this.eyeR = eR.w; this.irisL = eL.iris; this.irisR = eR.iris;
|
||||
this.head.add(eL.holder, eR.holder);
|
||||
|
||||
// brows
|
||||
const browGeo = new THREE.BoxGeometry(0.1, 0.022, 0.02);
|
||||
const browM = mat(hairColor, .9);
|
||||
this.browL = new THREE.Mesh(browGeo, browM); this.browL.position.set(-0.115, 0.155, 0.285);
|
||||
this.browR = new THREE.Mesh(browGeo, browM); this.browR.position.set(0.115, 0.155, 0.285);
|
||||
const browStyle = a.brows || 'natural';
|
||||
if (browStyle === 'thick') { this.browL.scale.y = 2; this.browR.scale.y = 2; }
|
||||
if (browStyle === 'thin') { this.browL.scale.y = 0.55; this.browR.scale.y = 0.55; }
|
||||
this.head.add(this.browL, this.browR);
|
||||
|
||||
// nose + mouth
|
||||
const nose = new THREE.Mesh(new THREE.SphereGeometry(0.035, 8, 6), skinM);
|
||||
nose.position.set(0, -0.03, 0.31);
|
||||
if ((a.nose || 'button') === 'round') { nose.scale.setScalar(1.5); nose.position.y = -0.04; }
|
||||
if ((a.nose || 'button') === 'small') nose.scale.setScalar(0.7);
|
||||
this.head.add(nose);
|
||||
this.mouth = new THREE.Mesh(new THREE.TorusGeometry(0.075, 0.017, 8, 14, Math.PI), mat('#a04a4a', .6));
|
||||
this.mouth.position.set(0, -0.115, 0.28);
|
||||
this.mouth.rotation.z = Math.PI; // default smile (arc opens downward->up)
|
||||
if ((a.mouth || 'smile') === 'neutral') this.mouth.rotation.z = Math.PI / 2;
|
||||
if ((a.mouth || 'smile') === 'grin') { this.mouth.scale.set(1.25, 1.25, 1); }
|
||||
this.head.add(this.mouth);
|
||||
|
||||
// ---- hair ----
|
||||
this.buildHair(a.hair || 'short', hairM);
|
||||
this.head.add(this.headTop);
|
||||
|
||||
// accessory
|
||||
this.buildAccessory(a.accessory || 'none');
|
||||
|
||||
this.bodyG.add(this.hips, this.head);
|
||||
this.group.add(this.bodyG);
|
||||
|
||||
// soft blob shadow (fake AO under feet)
|
||||
const blob = new THREE.Mesh(
|
||||
new THREE.CircleGeometry(0.42, 20),
|
||||
new THREE.MeshBasicMaterial({ color: 0x000000, transparent: true, opacity: 0.28, depthWrite: false })
|
||||
);
|
||||
blob.rotation.x = -Math.PI / 2; blob.position.y = 0.02;
|
||||
this.group.add(blob);
|
||||
|
||||
this.group.scale.set(1, height, 1);
|
||||
}
|
||||
|
||||
private buildHair(style: string, hairM: THREE.Material) {
|
||||
const add = (m: THREE.Object3D) => this.headTop.add(m);
|
||||
const cap = () => {
|
||||
const c = new THREE.Mesh(new THREE.SphereGeometry(0.325, 18, 14, 0, Math.PI * 2, 0, Math.PI * 0.55), hairM);
|
||||
c.position.y = 0.045; c.castShadow = true; return c;
|
||||
};
|
||||
switch (style) {
|
||||
case 'bald': break;
|
||||
case 'long': {
|
||||
add(cap());
|
||||
const back = new THREE.Mesh(new THREE.BoxGeometry(0.52, 0.5, 0.16), hairM);
|
||||
back.position.set(0, -0.18, -0.22); add(back);
|
||||
break;
|
||||
}
|
||||
case 'ponytail': {
|
||||
add(cap());
|
||||
const tail = new THREE.Mesh(new THREE.CapsuleGeometry(0.07, 0.3, 4, 8), hairM);
|
||||
tail.position.set(0, 0.05, -0.36); tail.rotation.x = 0.5; add(tail);
|
||||
break;
|
||||
}
|
||||
case 'curly': {
|
||||
for (let i = 0; i < 7; i++) {
|
||||
const s = new THREE.Mesh(new THREE.SphereGeometry(0.13, 10, 8), hairM);
|
||||
const ang = (i / 7) * Math.PI * 2;
|
||||
s.position.set(Math.cos(ang) * 0.2, 0.22 + (i % 2) * 0.06, Math.sin(ang) * 0.2 - 0.03);
|
||||
add(s);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'bob': {
|
||||
add(cap());
|
||||
const ring = new THREE.Mesh(new THREE.TorusGeometry(0.27, 0.09, 10, 18), hairM);
|
||||
ring.rotation.x = Math.PI / 2; ring.position.y = -0.05; ring.scale.z = 0.8; add(ring);
|
||||
break;
|
||||
}
|
||||
case 'spiky': {
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const sp = new THREE.Mesh(new THREE.ConeGeometry(0.06, 0.22, 6), hairM);
|
||||
sp.position.set((i - 2) * 0.11, 0.34, -0.02 + Math.abs(i - 2) * -0.04);
|
||||
sp.rotation.z = (i - 2) * -0.18; add(sp);
|
||||
}
|
||||
add(cap());
|
||||
break;
|
||||
}
|
||||
default: add(cap()); // short
|
||||
}
|
||||
}
|
||||
|
||||
private buildAccessory(acc: string) {
|
||||
const add = (m: THREE.Object3D) => this.headTop.add(m);
|
||||
if (acc === 'glasses') {
|
||||
const gm = mat('#222831', .4, .4);
|
||||
for (const side of [-1, 1]) {
|
||||
const rim = new THREE.Mesh(new THREE.TorusGeometry(0.085, 0.012, 8, 16), gm);
|
||||
rim.position.set(side * 0.115, 0.05, 0.3); add(rim);
|
||||
}
|
||||
const bridge = new THREE.Mesh(new THREE.BoxGeometry(0.07, 0.014, 0.014), gm);
|
||||
bridge.position.set(0, 0.06, 0.3); add(bridge);
|
||||
} else if (acc === 'cap') {
|
||||
const cm = mat('#e2564a', .8);
|
||||
const dome = new THREE.Mesh(new THREE.SphereGeometry(0.33, 14, 10, 0, Math.PI * 2, 0, Math.PI * 0.5), cm);
|
||||
dome.position.y = 0.1; add(dome);
|
||||
const brim = new THREE.Mesh(new THREE.CylinderGeometry(0.3, 0.3, 0.03, 14, 1, false, -Math.PI / 2.6, Math.PI / 1.3), cm);
|
||||
brim.position.set(0, 0.12, 0.16); add(brim);
|
||||
} else if (acc === 'beanie') {
|
||||
const bm = mat('#3f7fd9', .9);
|
||||
const dome = new THREE.Mesh(new THREE.SphereGeometry(0.33, 14, 10, 0, Math.PI * 2, 0, Math.PI * 0.55), bm);
|
||||
dome.position.y = 0.08; add(dome);
|
||||
const pom = new THREE.Mesh(new THREE.SphereGeometry(0.06, 8, 8), mat('#fff', .8));
|
||||
pom.position.y = 0.42; add(pom);
|
||||
}
|
||||
}
|
||||
|
||||
setAnim(a: string) { if (this.anim !== a) { this.anim = a; this.phase = 0; } }
|
||||
getAnim() { return this.anim; }
|
||||
|
||||
setExpressionFromMood(mood: string) {
|
||||
const map: Record<string, string> = {
|
||||
Happy: 'happy', Excited: 'excited', Relaxed: 'relaxed', Confident: 'confident', Inspired: 'happy',
|
||||
Romantic: 'romantic', Sad: 'sad', Angry: 'angry', Bored: 'flat', Embarrassed: 'oops',
|
||||
Lonely: 'sad', Tired: 'tired', Stressed: 'angry',
|
||||
};
|
||||
this.expr = map[mood] || 'happy';
|
||||
}
|
||||
|
||||
/** position of head top in world space (for bubbles/nametags) */
|
||||
headWorldPos(out: THREE.Vector3) {
|
||||
out.set(0, 2.35, 0);
|
||||
this.group.updateMatrixWorld();
|
||||
out.applyMatrix4(this.group.matrixWorld);
|
||||
return out;
|
||||
}
|
||||
|
||||
update(dt: number) {
|
||||
const p = (this.phase += dt);
|
||||
const L = this.legL, R = this.legR, LA = this.armL, RA = this.armR;
|
||||
const sL = this.shinL, sR = this.shinR;
|
||||
// reset base targets
|
||||
let bobY = 0, leanX = 0;
|
||||
let lL = 0, lR = 0, sLr = 0.05, sRr = 0.05, aL = 0, aR = 0, aLz = 0.09, aRz = -0.09;
|
||||
let headNod = 0, headTilt = 0;
|
||||
|
||||
switch (this.anim) {
|
||||
case 'walk': {
|
||||
const k = p * 7.2;
|
||||
lL = Math.sin(k) * 0.55; lR = Math.sin(k + Math.PI) * 0.55;
|
||||
sLr = Math.max(0.06, -Math.sin(k - 0.6) * 0.7); sRr = Math.max(0.06, -Math.sin(k + Math.PI - 0.6) * 0.7);
|
||||
aL = Math.sin(k + Math.PI) * 0.42; aR = Math.sin(k) * 0.42;
|
||||
bobY = Math.abs(Math.sin(k)) * 0.045; leanX = 0.05;
|
||||
break;
|
||||
}
|
||||
case 'run': {
|
||||
const k = p * 11.5;
|
||||
lL = Math.sin(k) * 0.95; lR = Math.sin(k + Math.PI) * 0.95;
|
||||
sLr = Math.max(0.1, -Math.sin(k - 0.7)) * 1.1; sRr = Math.max(0.1, -Math.sin(k + Math.PI - 0.7)) * 1.1;
|
||||
aL = Math.sin(k + Math.PI) * 0.85 - 0.25; aR = Math.sin(k) * 0.85 - 0.25;
|
||||
bobY = Math.abs(Math.sin(k)) * 0.09; leanX = 0.22;
|
||||
break;
|
||||
}
|
||||
case 'dance': {
|
||||
const k = p * 6.4;
|
||||
bobY = Math.abs(Math.sin(k)) * 0.12;
|
||||
this.bodyG.rotation.y = Math.sin(k * 0.5) * 0.35;
|
||||
aLz = 0.4 + Math.sin(k) * 1.5; aRz = -0.4 - Math.sin(k + Math.PI) * 1.5;
|
||||
aL = -0.6; aR = -0.6;
|
||||
lL = Math.sin(k) * 0.25; lR = -Math.sin(k) * 0.25;
|
||||
headTilt = Math.sin(k * 0.5) * 0.2;
|
||||
break;
|
||||
}
|
||||
case 'wave': {
|
||||
aR = -2.5; aRz = -0.5 + Math.sin(p * 8) * 0.45; aL = 0.1;
|
||||
headTilt = 0.08;
|
||||
break;
|
||||
}
|
||||
case 'talk': {
|
||||
const k = p * 3.2;
|
||||
aR = -0.5 + Math.sin(k) * 0.25; aRz = -0.35;
|
||||
headNod = Math.sin(k * 1.4) * 0.1;
|
||||
this.mouth.scale.setScalar(1 + Math.abs(Math.sin(k * 3)) * 0.5);
|
||||
break;
|
||||
}
|
||||
case 'laugh': {
|
||||
const k = p * 9;
|
||||
bobY = Math.abs(Math.sin(k)) * 0.06; leanX = -0.12 + Math.abs(Math.sin(k)) * 0.1;
|
||||
aL = -0.4; aR = -0.4; headNod = -0.15;
|
||||
this.mouth.scale.set(1.4, 1.4, 1.4);
|
||||
break;
|
||||
}
|
||||
case 'sit': {
|
||||
lL = -1.45; lR = -1.45; sLr = 1.35; sRr = 1.35;
|
||||
bobY = -0.42; aL = -0.35; aR = -0.35; aLz = 0.18; aRz = -0.18;
|
||||
break;
|
||||
}
|
||||
case 'sleep': {
|
||||
this.bodyG.rotation.x = -Math.PI / 2;
|
||||
this.bodyG.position.y = -0.62;
|
||||
lL = 0.08; lR = -0.08; aL = 0.15; aR = -0.15; sLr = 0.15; sRr = 0.15;
|
||||
headTilt = 0.12;
|
||||
this.applyExpr();
|
||||
this.updateBlink(dt, 0.25);
|
||||
return;
|
||||
}
|
||||
case 'hug': { aL = -1.9; aR = -1.9; aLz = 0.5; aRz = -0.5; leanX = 0.18; break; }
|
||||
case 'kiss': { aL = -0.7; aR = -0.7; leanX = 0.22; headTilt = 0.18; break; }
|
||||
case 'holdhands': { aR = -0.55; aRz = -0.5; break; }
|
||||
case 'highfive': { aR = -2.7; aRz = -0.15; break; }
|
||||
case 'gift': { aL = -1.5; aR = -1.5; aLz = 0.22; aRz = -0.22; leanX = 0.12; break; }
|
||||
case 'flirt': { headTilt = 0.22; aR = -1.2; aRz = -0.9; this.mouth.scale.set(0.8, 1.2, 0.8); break; }
|
||||
case 'argue': {
|
||||
const k = p * 8;
|
||||
aL = -1 + Math.sin(k) * 0.3; aR = -1 - Math.sin(k) * 0.3;
|
||||
aLz = 0.7; aRz = -0.7; headNod = Math.sin(k * 0.7) * 0.12;
|
||||
break;
|
||||
}
|
||||
case 'celebrate': {
|
||||
const k = p * 5;
|
||||
bobY = Math.abs(Math.sin(k)) * 0.22;
|
||||
aLz = 2.6; aRz = -2.6; headNod = -0.1;
|
||||
break;
|
||||
}
|
||||
case 'eat': {
|
||||
const k = p * 4;
|
||||
aR = -1.9 + Math.sin(k) * 0.35; aRz = -0.3;
|
||||
chewMouth(this.mouth, k);
|
||||
break;
|
||||
}
|
||||
case 'work': {
|
||||
const k = p * 10;
|
||||
aL = -1.05 + Math.sin(k) * 0.06; aR = -1.05 - Math.sin(k) * 0.06;
|
||||
aLz = 0.3; aRz = -0.3; leanX = 0.14; headNod = 0.18 + Math.sin(k * 0.2) * 0.03;
|
||||
break;
|
||||
}
|
||||
default: { // idle
|
||||
const k = p * 1.8;
|
||||
bobY = Math.sin(k) * 0.02;
|
||||
aL = Math.sin(k * 0.9) * 0.05; aR = Math.sin(k * 0.9 + 1) * 0.05;
|
||||
headNod = Math.sin(k * 0.7) * 0.04;
|
||||
break;
|
||||
}
|
||||
}
|
||||
this.bodyG.rotation.x += (leanX - this.bodyG.rotation.x) * Math.min(1, dt * 10);
|
||||
if (this.anim !== 'dance') this.bodyG.rotation.y *= Math.max(0, 1 - dt * 6);
|
||||
if (this.anim !== 'sleep') this.bodyG.position.y = 0;
|
||||
|
||||
this.bodyG.position.y = this.anim === 'sleep' ? -0.62 : bobY;
|
||||
L.rotation.x = lL; R.rotation.x = lR;
|
||||
sL.rotation.x = sLr; sR.rotation.x = sRr;
|
||||
LA.rotation.x += (aL - LA.rotation.x) * Math.min(1, dt * 12);
|
||||
RA.rotation.x += (aR - RA.rotation.x) * Math.min(1, dt * 12);
|
||||
LA.rotation.z += (aLz - LA.rotation.z) * Math.min(1, dt * 12);
|
||||
RA.rotation.z += (aRz - RA.rotation.z) * Math.min(1, dt * 12);
|
||||
this.head.rotation.x = headNod;
|
||||
this.head.rotation.z = headTilt;
|
||||
|
||||
if (this.anim !== 'talk' && this.anim !== 'eat' && this.anim !== 'flirt' && this.anim !== 'laugh') {
|
||||
this.mouth.scale.lerp(new THREE.Vector3(1, 1, 1), Math.min(1, dt * 6));
|
||||
}
|
||||
this.applyExpr();
|
||||
this.updateBlink(dt, 1);
|
||||
}
|
||||
|
||||
private updateBlink(dt: number, rate: number) {
|
||||
this.blinkAt -= dt * rate;
|
||||
if (this.blinkAt <= 0) { this.blinkT = 0.13; this.blinkAt = 2.5 + Math.random() * 4; }
|
||||
if (this.blinkT > 0) {
|
||||
this.blinkT -= dt;
|
||||
const s = 0.12;
|
||||
this.eyeL.scale.y = s; this.eyeR.scale.y = s;
|
||||
} else {
|
||||
this.eyeL.scale.y += (1 - this.eyeL.scale.y) * Math.min(1, dt * 18);
|
||||
this.eyeR.scale.y = this.eyeL.scale.y;
|
||||
}
|
||||
}
|
||||
|
||||
private applyExpr() {
|
||||
const e = this.expr;
|
||||
const bl = this.browL, br = this.browR;
|
||||
let browAng = 0, browY = 0;
|
||||
switch (e) {
|
||||
case 'angry': browAng = 0.35; browY = -0.02; this.mouth.rotation.z = 0; this.mouth.position.y = -0.135; break; // frown
|
||||
case 'sad': browAng = -0.3; this.mouth.rotation.z = 0; this.mouth.position.y = -0.14; break;
|
||||
case 'tired': browAng = 0.15; browY = 0.01; this.mouth.rotation.z = 0; this.mouth.position.y = -0.125; break;
|
||||
case 'flat': this.mouth.rotation.z = Math.PI / 2; this.mouth.position.y = -0.12; break;
|
||||
case 'oops': browAng = -0.25; browY = 0.02; this.mouth.rotation.z = 0; this.mouth.scale.set(0.7, 0.7, 0.7); break;
|
||||
case 'romantic': this.mouth.rotation.z = Math.PI * 0.96; this.mouth.position.y = -0.11; break;
|
||||
default: this.mouth.rotation.z = Math.PI; this.mouth.position.y = -0.115; // smile
|
||||
}
|
||||
bl.rotation.z += (browAng - bl.rotation.z) * 0.2;
|
||||
br.rotation.z += (-browAng - br.rotation.z) * 0.2;
|
||||
bl.position.y = 0.155 + browY; br.position.y = 0.155 + browY;
|
||||
}
|
||||
|
||||
dispose() {
|
||||
this.group.traverse((o: any) => {
|
||||
if (o.geometry) o.geometry.dispose();
|
||||
if (o.material) { Array.isArray(o.material) ? o.material.forEach((mm: any) => mm.dispose()) : o.material.dispose(); }
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function chewMouth(mouth: THREE.Mesh, k: number) {
|
||||
const s = 1 + Math.abs(Math.sin(k * 2)) * 0.6;
|
||||
mouth.scale.set(s * 0.8, 1, 0.8);
|
||||
}
|
||||
|
||||
function hslColor(h: number, s: number, l: number): string {
|
||||
const c = new THREE.Color().setHSL(h / 360, s, l);
|
||||
return '#' + c.getHexString();
|
||||
}
|
||||
|
||||
/** Derive an appearance object for an NPC definition. */
|
||||
export function npcAppearance(def: any, index: number): any {
|
||||
const hairs = ['short', 'long', 'curly', 'bob', 'ponytail', 'spiky'];
|
||||
return {
|
||||
skin: SKINS[index % SKINS.length],
|
||||
hairColor: '#' + new THREE.Color(def.hairColor).getHexString(),
|
||||
hair: hairs[index % hairs.length],
|
||||
eyeColor: ['#3a5a40', '#3d5a80', '#6b4226'][index % 3],
|
||||
topColor: '#' + new THREE.Color(def.color).getHexString(),
|
||||
bottomColor: ['#33415c', '#3d3a4a', '#4a4038'][index % 3],
|
||||
outfitBottom: 'jeans',
|
||||
bodyType: ['average', 'slim', 'plump'][index % 3],
|
||||
height: 0.92 + ((index * 37) % 20) / 100,
|
||||
faceShape: ['round', 'oval', 'square'][index % 3],
|
||||
eyes: ['round', 'wide', 'sleepy'][index % 3],
|
||||
accessory: ['none', 'glasses', 'none', 'cap'][index % 4],
|
||||
name: def.name,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
// LifeTown Online — three.js engine: renderer, scene, lighting, day/night sky.
|
||||
import * as THREE from 'three';
|
||||
|
||||
const DAY_SKY = new THREE.Color(0x8ecdf2);
|
||||
const DUSK_SKY = new THREE.Color(0xf2a06b);
|
||||
const NIGHT_SKY = new THREE.Color(0x10142e);
|
||||
const DAWN_SKY = new THREE.Color(0xf7c48f);
|
||||
const RAIN_TINT = new THREE.Color(0x6b7891);
|
||||
|
||||
export class Engine {
|
||||
renderer: THREE.WebGLRenderer;
|
||||
scene: THREE.Scene;
|
||||
camera: THREE.PerspectiveCamera;
|
||||
sun: THREE.DirectionalLight;
|
||||
hemi: THREE.HemisphereLight;
|
||||
ambient: THREE.AmbientLight;
|
||||
moonFill: THREE.DirectionalLight;
|
||||
worldGroup = new THREE.Group(); // static town / interior geometry
|
||||
actorGroup = new THREE.Group(); // avatars
|
||||
fxGroup = new THREE.Group(); // weather particles etc.
|
||||
private sunBase = { intensity: 2.6 };
|
||||
|
||||
constructor(canvas: HTMLCanvasElement) {
|
||||
this.renderer = new THREE.WebGLRenderer({ canvas, antialias: true, powerPreference: 'high-performance', preserveDrawingBuffer: true });
|
||||
this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
|
||||
this.renderer.shadowMap.enabled = true;
|
||||
this.renderer.shadowMap.type = THREE.PCFSoftShadowMap;
|
||||
this.renderer.outputColorSpace = THREE.SRGBColorSpace;
|
||||
this.renderer.toneMapping = THREE.ACESFilmicToneMapping;
|
||||
this.renderer.toneMappingExposure = 1.05;
|
||||
|
||||
this.scene = new THREE.Scene();
|
||||
this.scene.background = DAY_SKY.clone();
|
||||
this.scene.fog = new THREE.Fog(DAY_SKY.clone(), 70, 190);
|
||||
|
||||
this.camera = new THREE.PerspectiveCamera(52, 1, 0.1, 400);
|
||||
|
||||
this.hemi = new THREE.HemisphereLight(0xcfe8ff, 0x8a9a6a, 0.85);
|
||||
this.scene.add(this.hemi);
|
||||
this.ambient = new THREE.AmbientLight(0xffffff, 0.12);
|
||||
this.scene.add(this.ambient);
|
||||
|
||||
this.sun = new THREE.DirectionalLight(0xfff2dd, this.sunBase.intensity);
|
||||
this.sun.castShadow = true;
|
||||
this.sun.shadow.mapSize.set(2048, 2048);
|
||||
const c = this.sun.shadow.camera as THREE.OrthographicCamera;
|
||||
c.left = -75; c.right = 75; c.top = 75; c.bottom = -75; c.near = 10; c.far = 260;
|
||||
this.sun.shadow.bias = -0.0004;
|
||||
this.scene.add(this.sun);
|
||||
this.scene.add(this.sun.target);
|
||||
|
||||
this.moonFill = new THREE.DirectionalLight(0x8fa8ff, 0.0);
|
||||
this.moonFill.position.set(-40, 60, -30);
|
||||
this.scene.add(this.moonFill);
|
||||
|
||||
this.scene.add(this.worldGroup, this.actorGroup, this.fxGroup);
|
||||
window.addEventListener('resize', () => this.resize());
|
||||
this.resize();
|
||||
}
|
||||
|
||||
resize() {
|
||||
const w = window.innerWidth, h = window.innerHeight;
|
||||
this.renderer.setSize(w, h, false);
|
||||
this.camera.aspect = w / h;
|
||||
this.camera.updateProjectionMatrix();
|
||||
}
|
||||
|
||||
// minutes 0..1440 ; drives sky colour, sun position/intensity, fog
|
||||
setTimeOfDay(minutes: number, weather: string) {
|
||||
const t = minutes / 60;
|
||||
// sun path: rises 6h, sets 20h
|
||||
const dayK = Math.max(0, Math.sin(((t - 6) / 14) * Math.PI)); // 0..1 daylight factor
|
||||
const azimuth = ((t - 6) / 14) * Math.PI; // 0..π across the sky
|
||||
const sx = Math.cos(azimuth) * 90;
|
||||
const sy = Math.max(4, Math.sin(azimuth) * 110 + 6);
|
||||
this.sun.position.set(sx, sy, 38);
|
||||
this.sun.target.position.set(0, 0, 0);
|
||||
|
||||
// sky colour blend: night -> dawn/dusk -> day
|
||||
const sky = new THREE.Color();
|
||||
if (t < 5 || t > 21.5) sky.copy(NIGHT_SKY);
|
||||
else if (t < 7.5) sky.copy(NIGHT_SKY).lerp(DAWN_SKY, (t - 5) / 2.5);
|
||||
else if (t < 9) sky.copy(DAWN_SKY).lerp(DAY_SKY, (t - 7.5) / 1.5);
|
||||
else if (t < 17.5) sky.copy(DAY_SKY);
|
||||
else if (t < 19.5) sky.copy(DAY_SKY).lerp(DUSK_SKY, (t - 17.5) / 2);
|
||||
else sky.copy(DUSK_SKY).lerp(NIGHT_SKY, (t - 19.5) / 2);
|
||||
|
||||
if (weather === 'rain' || weather === 'storm') sky.lerp(RAIN_TINT, weather === 'storm' ? 0.62 : 0.45);
|
||||
else if (weather === 'cloudy') sky.lerp(new THREE.Color(0x9aa4b8), 0.35);
|
||||
else if (weather === 'fog') sky.lerp(new THREE.Color(0xb9c3cf), 0.55);
|
||||
|
||||
(this.scene.background as THREE.Color).copy(sky);
|
||||
(this.scene.fog as THREE.Fog).color.copy(sky);
|
||||
(this.scene.fog as THREE.Fog).near = weather === 'fog' ? 18 : 60;
|
||||
(this.scene.fog as THREE.Fog).far = weather === 'fog' ? 80 : weather === 'storm' ? 130 : 200;
|
||||
|
||||
const stormDim = weather === 'storm' ? 0.42 : weather === 'rain' ? 0.6 : weather === 'cloudy' ? 0.72 : weather === 'fog' ? 0.68 : 1;
|
||||
this.sun.intensity = this.sunBase.intensity * dayK * stormDim;
|
||||
this.sun.color.setHSL(0.09, 0.5, 0.5 + 0.5 * dayK); // warmer when low
|
||||
this.hemi.intensity = 0.22 + 0.66 * dayK * stormDim;
|
||||
this.hemi.color.copy(sky).lerp(new THREE.Color(0xffffff), 0.35);
|
||||
this.moonFill.intensity = (1 - dayK) * 0.28;
|
||||
this.renderer.toneMappingExposure = 1.02 + 0.1 * dayK;
|
||||
}
|
||||
|
||||
render() { this.renderer.render(this.scene, this.camera); }
|
||||
}
|
||||
@@ -0,0 +1,351 @@
|
||||
// LifeTown Online — procedural furniture models for the catalogue.
|
||||
// Every item is built from clean rounded primitives in a warm palette.
|
||||
import * as THREE from 'three';
|
||||
import { CATALOG_BY_ID } from '../../../shared/data.mjs';
|
||||
|
||||
function mat(color: number | string, rough = 0.85, metal = 0) {
|
||||
return new THREE.MeshStandardMaterial({ color, roughness: rough, metalness: metal });
|
||||
}
|
||||
function box(w: number, h: number, d: number, m: THREE.Material, x = 0, y = 0, z = 0) {
|
||||
const mesh = new THREE.Mesh(new THREE.BoxGeometry(w, h, d), m);
|
||||
mesh.position.set(x, y, z);
|
||||
mesh.castShadow = true; mesh.receiveShadow = true;
|
||||
return mesh;
|
||||
}
|
||||
function cyl(rt: number, rb: number, h: number, m: THREE.Material, x = 0, y = 0, z = 0, seg = 14) {
|
||||
const mesh = new THREE.Mesh(new THREE.CylinderGeometry(rt, rb, h, seg), m);
|
||||
mesh.position.set(x, y, z);
|
||||
mesh.castShadow = true;
|
||||
return mesh;
|
||||
}
|
||||
const WOOD = [0x9a6b43, 0xb98a5a, 0x7a5236];
|
||||
const FABRIC = [0xe8875f, 0x67b56a, 0x8fb7d9, 0xd9c9a8, 0xb58fd9];
|
||||
|
||||
export function buildFurniture(itemId: string): THREE.Group {
|
||||
const def = CATALOG_BY_ID[itemId] || { cat: 'decor', id: itemId };
|
||||
const g = new THREE.Group();
|
||||
const seedNum = [...itemId].reduce((a, c) => a + c.charCodeAt(0), 0);
|
||||
const wood = WOOD[seedNum % WOOD.length];
|
||||
const fabric = FABRIC[seedNum % FABRIC.length];
|
||||
|
||||
switch (def.cat) {
|
||||
case 'bed': {
|
||||
g.add(box(2, 0.32, def.d ?? 3, mat(wood), 0, 0.26));
|
||||
g.add(box(1.86, 0.22, (def.d ?? 3) - 0.3, mat('#fdf6ec', .95), 0, 0.52));
|
||||
g.add(box(1.86, 0.14, (def.d ?? 3) * 0.55, mat(fabric, .95), 0, 0.62, -0.45));
|
||||
g.add(box(1.2, 0.16, 0.42, mat('#ffffff', .95), 0, 0.66, (def.d ?? 3) / 2 - 0.5));
|
||||
if ((def.d ?? 3) > 2.4) g.add(box(1.1, 0.16, 0.42, mat('#ffffff', .95), 0.35, 0.66, (def.d ?? 3) / 2 - 0.5));
|
||||
g.add(box(2, 0.9, 0.18, mat(wood), 0, 0.65, -(def.d ?? 3) / 2));
|
||||
break;
|
||||
}
|
||||
case 'sofa': {
|
||||
const w = def.w ?? 3;
|
||||
g.add(box(w, 0.42, 1.3, mat(fabric, .95), 0, 0.34));
|
||||
g.add(box(w, 0.62, 0.28, mat(fabric, .95), 0, 0.78, -0.51));
|
||||
for (const s of [-1, 1]) g.add(box(0.26, 0.62, 1.3, mat(fabric, .95), s * (w / 2 - 0.13), 0.62));
|
||||
const cush = Math.max(2, Math.round(w / 1.1));
|
||||
for (let i = 0; i < cush; i++) {
|
||||
g.add(box(w / cush - 0.12, 0.16, 1.05, mat(i % 2 ? '#ffffff' : '#fff2e0', .95), -w / 2 + (i + 0.5) * (w / cush), 0.62, 0.08));
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'table': {
|
||||
if (itemId === 'table_desk') {
|
||||
g.add(box(def.w ?? 2, 0.09, def.d ?? 1, mat(wood), 0, 0.76));
|
||||
for (const sx of [-1, 1]) for (const sz of [-1, 1])
|
||||
g.add(box(0.09, 0.76, 0.09, mat(0x4a4f5a, .6, .3), sx * ((def.w ?? 2) / 2 - 0.1), 0.38, sz * ((def.d ?? 1) / 2 - 0.1)));
|
||||
} else if (itemId === 'counter_kitchen') {
|
||||
g.add(box(def.w ?? 1.8, 0.88, def.d ?? 0.9, mat('#e8e2d4', .9)));
|
||||
g.add(box((def.w ?? 1.8) + 0.06, 0.06, (def.d ?? 0.9) + 0.06, mat(wood), 0, 0.91));
|
||||
g.add(box((def.w ?? 1.8) - 0.2, 0.55, 0.03, mat('#cfc8bb', .85), 0, 0.44, (def.d ?? 0.9) / 2));
|
||||
} else {
|
||||
const r = itemId === 'table_coffee' ? 0.7 : 1;
|
||||
g.add(cyl(r * 0.9, r * 0.9, 0.07, mat(wood), 0, itemId === 'table_coffee' ? 0.42 : 0.74, 0, 20));
|
||||
g.add(cyl(0.09, 0.13, itemId === 'table_coffee' ? 0.42 : 0.72, mat(0x4a4f5a, .6, .3), 0, (itemId === 'table_coffee' ? 0.42 : 0.72) / 2, 0, 10));
|
||||
g.add(cyl(r * 0.55, r * 0.62, 0.05, mat(0x4a4f5a, .6, .3), 0, 0.03, 0, 16));
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'chair': {
|
||||
if (itemId === 'chair_office') {
|
||||
g.add(cyl(0.28, 0.32, 0.06, mat(0x22252e, .8), 0, 0.05, 0, 16));
|
||||
g.add(cyl(0.05, 0.05, 0.36, mat(0x4a4f5a, .5, .5), 0, 0.25, 0, 8));
|
||||
g.add(box(0.52, 0.1, 0.5, mat(0x35405c, .9), 0, 0.46));
|
||||
g.add(box(0.5, 0.56, 0.1, mat(0x35405c, .9), 0, 0.78, -0.22));
|
||||
} else if (itemId === 'stool') {
|
||||
g.add(cyl(0.24, 0.28, 0.07, mat(wood), 0, 0.5, 0, 12));
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const leg = cyl(0.03, 0.04, 0.5, mat(wood), Math.cos(i * 2.1) * 0.15, 0.25, Math.sin(i * 2.1) * 0.15, 8);
|
||||
leg.rotation.z = Math.cos(i * 2.1) * 0.15; leg.rotation.x = -Math.sin(i * 2.1) * 0.15;
|
||||
g.add(leg);
|
||||
}
|
||||
} else {
|
||||
g.add(box(0.46, 0.07, 0.46, mat(wood), 0, 0.48));
|
||||
g.add(box(0.46, 0.5, 0.06, mat(wood), 0, 0.75, -0.2));
|
||||
for (const sx of [-1, 1]) for (const sz of [-1, 1])
|
||||
g.add(box(0.05, 0.48, 0.05, mat(wood), sx * 0.19, 0.24, sz * 0.19));
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'tv': {
|
||||
const w = def.w ?? 1.6;
|
||||
g.add(box(w, w * 0.58, 0.07, mat('#10131c', .35), 0, (w * 0.58) / 2 + 0.35));
|
||||
const screen = new THREE.Mesh(new THREE.PlaneGeometry(w - 0.1, w * 0.58 - 0.1),
|
||||
new THREE.MeshStandardMaterial({ color: 0x27406b, emissive: 0x3d6ea8, emissiveIntensity: 0.7 }));
|
||||
screen.position.set(0, (w * 0.58) / 2 + 0.35, 0.045);
|
||||
g.add(screen);
|
||||
g.add(box(0.5, 0.05, 0.3, mat(0x22252e, .6), 0, 0.02));
|
||||
g.add(box(0.08, 0.35, 0.08, mat(0x22252e, .6), 0, 0.18));
|
||||
break;
|
||||
}
|
||||
case 'pc': {
|
||||
g.add(box(1.5, 0.06, 0.7, mat(wood), 0, 0.74));
|
||||
for (const sx of [-1, 1]) g.add(box(0.07, 0.74, 0.55, mat(0x4a4f5a, .6, .3), sx * 0.68, 0.37));
|
||||
const mon = box(0.95, 0.55, 0.05, mat('#10131c', .35), 0.1, 1.15);
|
||||
g.add(mon);
|
||||
const scr = new THREE.Mesh(new THREE.PlaneGeometry(0.85, 0.45),
|
||||
new THREE.MeshStandardMaterial({ color: 0x1e3a5f, emissive: 0x4fa0d9, emissiveIntensity: 0.8 }));
|
||||
scr.position.set(0.1, 1.15, 0.03); g.add(scr);
|
||||
g.add(box(0.42, 0.03, 0.16, mat('#e8e8ee', .8), 0.1, 0.79, 0.18)); // keyboard
|
||||
g.add(box(0.2, 0.38, 0.45, mat('#22252e', .7), -0.55, 0.96)); // tower
|
||||
break;
|
||||
}
|
||||
case 'fridge': {
|
||||
const h = itemId === 'fridge_dual' ? 2.0 : 1.75;
|
||||
g.add(box(def.w ?? 1.2, h, 1, mat(itemId === 'fridge_retro' ? '#9fd8c9' : '#dfe3ea', .55, .1), 0, h / 2));
|
||||
g.add(box((def.w ?? 1.2) + 0.02, 0.03, 1.02, mat('#aab2bd', .5), 0, h * 0.62));
|
||||
g.add(box(0.05, 0.5, 0.06, mat('#7a8494', .4, .6), (def.w ?? 1.2) / 2 - 0.12, h * 0.68, 0.53));
|
||||
break;
|
||||
}
|
||||
case 'stove': {
|
||||
g.add(box(def.w ?? 1.2, 0.92, 0.9, mat('#dfe3ea', .6, .1)));
|
||||
g.add(box((def.w ?? 1.2) - 0.16, 0.5, 0.04, mat('#4a5060', .5, .2), 0, 0.42, 0.47));
|
||||
for (const bx of [-0.28, 0.28]) g.add(cyl(0.14, 0.14, 0.03, mat('#22252e', .8), bx, 0.94, 0, 12));
|
||||
break;
|
||||
}
|
||||
case 'shower': {
|
||||
if (itemId === 'bath_tub') {
|
||||
g.add(box(1.8, 0.55, 1, mat('#f2f4f8', .5), 0, 0.28));
|
||||
g.add(box(1.5, 0.1, 0.7, mat('#8fd0e8', .3), 0, 0.5));
|
||||
g.add(cyl(0.03, 0.03, 0.9, mat('#aab2bd', .4, .6), -0.75, 1.1, -0.3, 8));
|
||||
g.add(box(0.16, 0.05, 0.16, mat('#aab2bd', .4, .6), -0.62, 1.5, -0.3));
|
||||
} else {
|
||||
g.add(box(1.2, 0.12, 1.2, mat('#f2f4f8', .5), 0, 0.06));
|
||||
g.add(box(1.2, 2, 0.05, mat('#bcd9e8', .15, .1), 0, 1.06, -0.57));
|
||||
g.add(box(0.05, 2, 1.2, mat('#bcd9e8', .15, .1), -0.57, 1.06));
|
||||
g.add(cyl(0.02, 0.02, 1.4, mat('#aab2bd', .4, .6), 0.45, 1.7, -0.45, 8));
|
||||
g.add(cyl(0.12, 0.02, 0.06, mat('#aab2bd', .4, .6), 0.45, 1.0, -0.3, 10));
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'toilet': {
|
||||
g.add(cyl(0.26, 0.22, 0.4, mat('#f2f4f8', .45), 0, 0.2, 0.05, 16));
|
||||
g.add(cyl(0.29, 0.29, 0.06, mat('#ffffff', .4), 0, 0.43, 0.05, 16));
|
||||
g.add(box(0.44, 0.5, 0.2, mat('#f2f4f8', .45), 0, 0.5, -0.24));
|
||||
break;
|
||||
}
|
||||
case 'sink': {
|
||||
g.add(cyl(0.09, 0.13, 0.72, mat('#f2f4f8', .5), 0, 0.36, 0, 12));
|
||||
g.add(cyl(0.3, 0.24, 0.16, mat('#ffffff', .4), 0, 0.8, 0, 16));
|
||||
g.add(cyl(0.02, 0.02, 0.24, mat('#aab2bd', .4, .6), 0, 0.95, -0.1, 8));
|
||||
break;
|
||||
}
|
||||
case 'wardrobe': {
|
||||
const w = def.w ?? 1.6, h = 2.05;
|
||||
g.add(box(w, h, def.d ?? 0.8, mat(wood), 0, h / 2));
|
||||
g.add(box(w / 2 - 0.04, h - 0.2, 0.03, mat(wood + 0x111111), -w / 4, h / 2, (def.d ?? 0.8) / 2 + 0.01));
|
||||
g.add(box(w / 2 - 0.04, h - 0.2, 0.03, mat(wood), w / 4, h / 2, (def.d ?? 0.8) / 2 + 0.01));
|
||||
g.add(cyl(0.015, 0.015, 0.22, mat('#d9c07a', .3, .7), -0.06, h / 2, (def.d ?? 0.8) / 2 + 0.04, 8));
|
||||
g.add(cyl(0.015, 0.015, 0.22, mat('#d9c07a', .3, .7), 0.06, h / 2, (def.d ?? 0.8) / 2 + 0.04, 8));
|
||||
break;
|
||||
}
|
||||
case 'shelf': {
|
||||
const w = def.w ?? 1.6;
|
||||
g.add(box(w, 1.8, 0.06, mat(0x4a4038), 0, 0.9, -0.22));
|
||||
for (let i = 0; i < 4; i++) g.add(box(w, 0.05, 0.44, mat(wood), 0, 0.3 + i * 0.45));
|
||||
// books
|
||||
let bx = -w / 2 + 0.12;
|
||||
let shelf = 0;
|
||||
while (shelf < 3 && bx < w / 2 - 0.1) {
|
||||
const bw = 0.05 + (seedNum % 3) * 0.012;
|
||||
g.add(box(bw, 0.26, 0.3, mat([0xef6f91, 0x5f86e8, 0x67b56a, 0xffcf6b][(seedNum + bx * 97 | 0) % 4], .9), bx, 0.45 + shelf * 0.45));
|
||||
bx += bw + 0.02;
|
||||
if (bx > w / 2 - 0.15) { bx = -w / 2 + 0.12; shelf++; }
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'plant': {
|
||||
const potM = mat(itemId === 'plant_cactus' ? '#e0793f' : '#c96f4a', .9);
|
||||
if (itemId === 'plant_tree') {
|
||||
g.add(cyl(0.16, 0.2, 0.4, potM, 0, 0.2, 0, 12));
|
||||
g.add(cyl(0.06, 0.08, 0.8, mat(0x7a5236), 0, 0.75, 0, 8));
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const lf = new THREE.Mesh(new THREE.SphereGeometry(0.34 - i * 0.06, 10, 8), mat(0x5ea75e, .95));
|
||||
lf.position.set(0, 1.25 + i * 0.28, 0); lf.castShadow = true; g.add(lf);
|
||||
}
|
||||
} else if (itemId === 'plant_cactus') {
|
||||
g.add(cyl(0.14, 0.17, 0.24, potM, 0, 0.12, 0, 12));
|
||||
g.add(cyl(0.09, 0.11, 0.5, mat(0x4f9850, .95), 0, 0.48, 0, 10));
|
||||
const arm = cyl(0.05, 0.06, 0.24, mat(0x4f9850, .95), 0.16, 0.5, 0, 8);
|
||||
arm.rotation.z = -1.1;
|
||||
g.add(arm);
|
||||
} else {
|
||||
g.add(cyl(0.15, 0.19, 0.28, potM, 0, 0.14, 0, 12));
|
||||
for (let i = 0; i < 6; i++) {
|
||||
const leaf = new THREE.Mesh(new THREE.SphereGeometry(0.12, 8, 6), mat(0x5ea75e, .95));
|
||||
leaf.position.set(Math.cos(i) * 0.13, 0.34 + (i % 2) * 0.1, Math.sin(i) * 0.13);
|
||||
leaf.scale.set(1, 0.6, 1); leaf.castShadow = true; g.add(leaf);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'light': {
|
||||
const glowM = new THREE.MeshStandardMaterial({ color: 0xfff2cc, emissive: 0xffdf9e, emissiveIntensity: 1.4 });
|
||||
if (itemId === 'lamp_floor') {
|
||||
g.add(cyl(0.14, 0.18, 0.05, mat(0x33384a, .6), 0, 0.03, 0, 12));
|
||||
g.add(cyl(0.025, 0.025, 1.4, mat(0x33384a, .6), 0, 0.73, 0, 8));
|
||||
const shade = new THREE.Mesh(new THREE.ConeGeometry(0.22, 0.26, 12, 1, true), glowM);
|
||||
shade.position.y = 1.5; g.add(shade);
|
||||
} else if (itemId === 'lamp_arc') {
|
||||
g.add(cyl(0.16, 0.2, 0.05, mat(0x33384a, .6), 0, 0.03, 0, 12));
|
||||
const arc = new THREE.Mesh(new THREE.TorusGeometry(0.8, 0.03, 8, 20, Math.PI / 1.6), mat(0x33384a, .5, .5));
|
||||
arc.position.set(-0.4, 1.6, 0); arc.rotation.z = Math.PI / 2.6; g.add(arc);
|
||||
const bulb = new THREE.Mesh(new THREE.SphereGeometry(0.12, 10, 8), glowM);
|
||||
bulb.position.set(0.55, 1.85, 0); g.add(bulb);
|
||||
} else if (itemId === 'light_neon') {
|
||||
const tube = new THREE.Mesh(new THREE.CapsuleGeometry(0.045, 1.8, 4, 8),
|
||||
new THREE.MeshStandardMaterial({ color: 0xff7ab8, emissive: 0xff4f9e, emissiveIntensity: 2 }));
|
||||
tube.rotation.z = Math.PI / 2; tube.position.y = 0.1; g.add(tube);
|
||||
} else { // table lamp
|
||||
g.add(cyl(0.1, 0.13, 0.04, mat(wood), 0, 0.02, 0, 10));
|
||||
g.add(cyl(0.02, 0.02, 0.26, mat(0x33384a, .6), 0, 0.16, 0, 8));
|
||||
const shade = new THREE.Mesh(new THREE.ConeGeometry(0.16, 0.2, 12, 1, true), glowM);
|
||||
shade.position.y = 0.38; g.add(shade);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'rug': {
|
||||
const rm = mat(fabric, 1);
|
||||
const r = itemId === 'rug_round'
|
||||
? new THREE.Mesh(new THREE.CircleGeometry(Math.min(def.w ?? 2.4, 2.4) / 2, 24), rm)
|
||||
: box(def.w ?? 2.4, 0.03, def.d ?? 1.4, rm);
|
||||
if (r.geometry.type === 'CircleGeometry') { r.rotation.x = -Math.PI / 2; r.position.y = 0.03; }
|
||||
else r.position.y = 0.02;
|
||||
r.receiveShadow = true;
|
||||
g.add(r);
|
||||
break;
|
||||
}
|
||||
case 'paint': {
|
||||
const frame = box(def.w ?? 1.2, (def.w ?? 1.2) * 0.8, 0.06, mat(wood), 0, 0.9, 0);
|
||||
g.add(frame);
|
||||
const c = document.createElement('canvas'); c.width = 128; c.height = 100;
|
||||
const ctx = c.getContext('2d')!;
|
||||
if (itemId === 'paint_meadow') {
|
||||
ctx.fillStyle = '#8ecdf2'; ctx.fillRect(0, 0, 128, 60);
|
||||
ctx.fillStyle = '#79b56a'; ctx.fillRect(0, 60, 128, 40);
|
||||
ctx.fillStyle = '#ffcf6b'; ctx.beginPath(); ctx.arc(96, 22, 12, 0, 7); ctx.fill();
|
||||
} else if (itemId === 'paint_abstract') {
|
||||
const cols = ['#ef6f91', '#5f86e8', '#ffcf6b', '#67b56a'];
|
||||
for (let i = 0; i < 12; i++) { ctx.fillStyle = cols[i % 4]; ctx.fillRect((i * 37) % 110, (i * 23) % 80, 22, 14); }
|
||||
} else {
|
||||
ctx.fillStyle = '#e8dcc8'; ctx.fillRect(0, 0, 128, 100);
|
||||
ctx.fillStyle = '#8a5a37'; ctx.beginPath(); ctx.arc(64, 42, 20, 0, 7); ctx.fill();
|
||||
ctx.fillRect(40, 62, 48, 38);
|
||||
}
|
||||
const tex = new THREE.CanvasTexture(c); tex.colorSpace = THREE.SRGBColorSpace;
|
||||
const art = new THREE.Mesh(new THREE.PlaneGeometry((def.w ?? 1.2) - 0.12, (def.w ?? 1.2) * 0.8 - 0.12),
|
||||
new THREE.MeshBasicMaterial({ map: tex }));
|
||||
art.position.set(0, 0.9, 0.04); g.add(art);
|
||||
break;
|
||||
}
|
||||
case 'elec': {
|
||||
if (itemId === 'console') {
|
||||
g.add(box(0.5, 0.12, 0.36, mat('#22252e', .6), 0, 0.06));
|
||||
g.add(box(0.14, 0.02, 0.02, mat('#4fd98a', .4), 0.1, 0.125, 0.1));
|
||||
} else if (itemId === 'speaker') {
|
||||
g.add(box(0.34, 0.55, 0.3, mat('#2b3040', .8), 0, 0.28));
|
||||
g.add(cyl(0.11, 0.11, 0.03, mat('#171a24', .9), 0, 0.2, 0.16, 12).rotateX(Math.PI / 2));
|
||||
} else if (itemId === 'microwave') {
|
||||
g.add(box(0.6, 0.36, 0.4, mat('#dfe3ea', .55, .1), 0, 0.18));
|
||||
g.add(box(0.42, 0.26, 0.02, mat('#3a3f4e', .5), -0.05, 0.18, 0.21));
|
||||
} else if (itemId === 'coffeemaker') {
|
||||
g.add(box(0.28, 0.4, 0.26, mat('#8a4a3a', .7), 0, 0.2));
|
||||
g.add(cyl(0.08, 0.06, 0.1, mat('#e8e8ee', .5), 0, 0.08, 0.1, 10));
|
||||
} else { // synth
|
||||
g.add(box(1.3, 0.12, 0.42, mat('#22252e', .7), 0, 0.72));
|
||||
g.add(box(1.2, 0.03, 0.14, mat('#f2f4f8', .6), 0, 0.79, 0.1));
|
||||
for (let i = 0; i < 8; i++) g.add(box(0.06, 0.02, 0.1, mat('#171a24', .6), -0.54 + i * 0.155, 0.81, 0.08));
|
||||
g.add(box(0.1, 0.5, 0.1, mat(wood), -0.5, 0.35, 0));
|
||||
g.add(box(0.1, 0.5, 0.1, mat(wood), 0.5, 0.35, 0));
|
||||
}
|
||||
break;
|
||||
}
|
||||
default: { // decor
|
||||
if (itemId === 'decor_vase') {
|
||||
g.add(cyl(0.12, 0.16, 0.34, mat('#e0793f', .7), 0, 0.17, 0, 12));
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const fl = new THREE.Mesh(new THREE.SphereGeometry(0.05, 6, 5), mat([0xef6f91, 0xffcf6b, '#ffffff'][i], .8));
|
||||
fl.position.set(Math.cos(i * 2.1) * 0.07, 0.42, Math.sin(i * 2.1) * 0.07); g.add(fl);
|
||||
}
|
||||
} else if (itemId === 'decor_clock') {
|
||||
g.add(cyl(0.22, 0.22, 0.05, mat('#f2ead8', .8), 0, 1.2, 0, 20).rotateX(Math.PI / 2));
|
||||
g.add(box(0.02, 0.14, 0.01, mat('#333', .6), 0, 1.22, 0.035));
|
||||
g.add(box(0.1, 0.02, 0.01, mat('#333', .6), 0.03, 1.2, 0.035));
|
||||
} else if (itemId === 'decor_mirror') {
|
||||
g.add(box(0.8, 1.3, 0.05, mat(wood), 0, 1.1));
|
||||
const mir = new THREE.Mesh(new THREE.PlaneGeometry(0.66, 1.16),
|
||||
new THREE.MeshStandardMaterial({ color: 0xcfe4ee, roughness: 0.08, metalness: 0.7 }));
|
||||
mir.position.set(0, 1.1, 0.03); g.add(mir);
|
||||
} else if (itemId === 'decor_aquarium') {
|
||||
g.add(box(1.2, 0.5, 0.5, mat(wood), 0, 0.25));
|
||||
const waterBox = new THREE.Mesh(new THREE.BoxGeometry(1.1, 0.42, 0.42),
|
||||
new THREE.MeshStandardMaterial({ color: 0x6fc4e8, transparent: true, opacity: 0.55, roughness: .2 }));
|
||||
waterBox.position.y = 0.72; g.add(waterBox);
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const fish = new THREE.Mesh(new THREE.SphereGeometry(0.045, 6, 5), mat([0xef8a5f, 0xffcf6b, 0xef6f91][i], .7));
|
||||
fish.scale.set(1.5, 0.8, 0.6); fish.position.set(-0.3 + i * 0.3, 0.68 + i * 0.06, 0); g.add(fish);
|
||||
}
|
||||
} else if (itemId === 'guitar') {
|
||||
const bodyG = new THREE.Mesh(new THREE.CylinderGeometry(0.22, 0.26, 0.09, 16), mat(0xb0672f, .7));
|
||||
bodyG.rotation.x = Math.PI / 2; bodyG.position.set(0, 0.9, 0); bodyG.rotation.y = 0; g.add(bodyG);
|
||||
g.add(box(0.07, 0.7, 0.05, mat(0x6b4226, .8), 0, 1.35, 0));
|
||||
} else if (itemId === 'easel') {
|
||||
for (const lx of [-0.3, 0.3]) {
|
||||
const legg = box(0.05, 1.4, 0.05, mat(wood), lx, 0.7, 0);
|
||||
legg.rotation.z = lx > 0 ? -0.12 : 0.12; g.add(legg);
|
||||
}
|
||||
g.add(box(0.7, 0.55, 0.04, mat('#f2f4f8', .9), 0, 0.95, 0.06));
|
||||
g.add(box(0.4, 0.3, 0.01, mat('#79b56a', .9), 0, 0.95, 0.085));
|
||||
} else if (itemId === 'teddy') {
|
||||
const tm = mat('#c98a5b', .95);
|
||||
const bod = new THREE.Mesh(new THREE.SphereGeometry(0.16, 10, 8), tm); bod.position.y = 0.18; g.add(bod);
|
||||
const hed = new THREE.Mesh(new THREE.SphereGeometry(0.12, 10, 8), tm); hed.position.y = 0.38; g.add(hed);
|
||||
for (const ex of [-0.06, 0.06]) {
|
||||
const ear = new THREE.Mesh(new THREE.SphereGeometry(0.045, 8, 6), tm); ear.position.set(ex, 0.47, 0); g.add(ear);
|
||||
const eye = new THREE.Mesh(new THREE.SphereGeometry(0.014, 6, 5), mat('#171a24', .5)); eye.position.set(ex, 0.39, 0.1); g.add(eye);
|
||||
}
|
||||
} else {
|
||||
g.add(box(0.4, 0.4, 0.4, mat(fabric), 0, 0.2));
|
||||
}
|
||||
}
|
||||
}
|
||||
return g;
|
||||
}
|
||||
|
||||
/** Which need-fulfilling actions each furniture category supports. */
|
||||
export function categoryActions(cat: string): { id: string; label: string; emoji: string }[] {
|
||||
switch (cat) {
|
||||
case 'bed': return [{ id: 'sleep', label: 'Sleep', emoji: '😴' }];
|
||||
case 'shower': return [{ id: 'wash', label: 'Shower / Bathe', emoji: '🚿' }];
|
||||
case 'toilet': return [{ id: 'use_toilet', label: 'Use', emoji: '🚽' }];
|
||||
case 'sink': return [{ id: 'wash_hands', label: 'Freshen Up', emoji: '🧼' }];
|
||||
case 'tv': return [{ id: 'watch_tv', label: 'Watch TV', emoji: '📺' }];
|
||||
case 'pc': return [{ id: 'computer', label: 'Play on PC', emoji: '🖥️' }];
|
||||
case 'elec': return [{ id: 'play_games', label: 'Play Games', emoji: '🎮' }];
|
||||
case 'fridge': return [{ id: 'snack', label: 'Grab Snack', emoji: '🍎' }];
|
||||
case 'stove': return [{ id: 'cook', label: 'Cook Meal', emoji: '🍳' }];
|
||||
case 'shelf': return [{ id: 'read', label: 'Read Book', emoji: '📚' }];
|
||||
case 'sofa': return [{ id: 'lounge', label: 'Lounge', emoji: '🛋️' }];
|
||||
default: return [];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
// LifeTown Online — instanced home interiors + build-mode scaffolding.
|
||||
import * as THREE from 'three';
|
||||
import { HOUSES, CATALOG_BY_ID } from '../../../shared/data.mjs';
|
||||
import { buildFurniture, categoryActions } from './furniture';
|
||||
|
||||
export interface PlacedItem { itemId: string; x: number; z: number; rot: number }
|
||||
export interface FurnitureEntry { item: PlacedItem; group: THREE.Group; cat: string }
|
||||
|
||||
export class Interior {
|
||||
group = new THREE.Group();
|
||||
size: [number, number];
|
||||
tier: string;
|
||||
label = 'Your Home';
|
||||
furniture: FurnitureEntry[] = [];
|
||||
private grid: THREE.GridHelper | null = null;
|
||||
buildMode = false;
|
||||
onFurnitureChanged: (() => void) | null = null;
|
||||
|
||||
constructor(tierOrSize: string | [number, number]) {
|
||||
if (Array.isArray(tierOrSize)) {
|
||||
this.tier = 'venue';
|
||||
this.size = [tierOrSize[0], tierOrSize[1]];
|
||||
} else {
|
||||
const h = HOUSES.find(x => x.id === tierOrSize) || HOUSES[0];
|
||||
this.tier = h.id;
|
||||
this.size = [h.size[0], h.size[1]];
|
||||
}
|
||||
this.buildShell();
|
||||
this.setBuildMode(false);
|
||||
}
|
||||
|
||||
private buildShell() {
|
||||
const [w, d] = this.size;
|
||||
const floorM = new THREE.MeshStandardMaterial({ color: 0xd9c9a8, roughness: 0.85 });
|
||||
// two-tone wood planks
|
||||
const floor = new THREE.Mesh(new THREE.PlaneGeometry(w, d), floorM);
|
||||
floor.rotation.x = -Math.PI / 2; floor.receiveShadow = true;
|
||||
this.group.add(floor);
|
||||
for (let i = -Math.floor(d / 2); i < d / 2; i += 1.2) {
|
||||
const plank = new THREE.Mesh(new THREE.PlaneGeometry(w, 0.06), new THREE.MeshStandardMaterial({ color: 0xcbb894, roughness: .9 }));
|
||||
plank.rotation.x = -Math.PI / 2; plank.position.set(0, 0.006, i);
|
||||
this.group.add(plank);
|
||||
}
|
||||
|
||||
const wallH = 3;
|
||||
const wallM = new THREE.MeshStandardMaterial({ color: 0xf2e8d8, roughness: 0.95 });
|
||||
const accentM = new THREE.MeshStandardMaterial({ color: 0xefd3b8, roughness: 0.95 });
|
||||
// back wall (z-) with windows
|
||||
const back = new THREE.Mesh(new THREE.PlaneGeometry(w, wallH), accentM);
|
||||
back.position.set(0, wallH / 2, -d / 2); back.receiveShadow = true;
|
||||
this.group.add(back);
|
||||
// front wall (z+) with door gap
|
||||
const doorW = 1.6;
|
||||
const segW = (w - doorW) / 2;
|
||||
for (const sx of [-1, 1]) {
|
||||
const seg = new THREE.Mesh(new THREE.PlaneGeometry(segW, wallH), wallM);
|
||||
seg.position.set(sx * (doorW / 2 + segW / 2), wallH / 2, d / 2);
|
||||
seg.rotation.y = Math.PI; seg.receiveShadow = true;
|
||||
this.group.add(seg);
|
||||
}
|
||||
const header = new THREE.Mesh(new THREE.PlaneGeometry(doorW, wallH - 2.4), wallM);
|
||||
header.position.set(0, 2.4 + (wallH - 2.4) / 2, d / 2);
|
||||
header.rotation.y = Math.PI;
|
||||
this.group.add(header);
|
||||
const doorFrame = new THREE.Mesh(new THREE.BoxGeometry(doorW + 0.24, 2.5, 0.14),
|
||||
new THREE.MeshStandardMaterial({ color: 0x8a6f56, roughness: .85 }));
|
||||
doorFrame.position.set(0, 1.25, d / 2 + 0.02);
|
||||
this.group.add(doorFrame);
|
||||
const mat0 = new THREE.Mesh(new THREE.PlaneGeometry(1.4, 0.7), new THREE.MeshStandardMaterial({ color: 0xb0574a, roughness: 1 }));
|
||||
mat0.rotation.x = -Math.PI / 2; mat0.position.set(0, 0.01, d / 2 + 0.55);
|
||||
this.group.add(mat0);
|
||||
// side walls
|
||||
for (const sx of [-1, 1]) {
|
||||
const side = new THREE.Mesh(new THREE.PlaneGeometry(d, wallH), wallM);
|
||||
side.position.set(sx * w / 2, wallH / 2, 0); side.rotation.y = -sx * Math.PI / 2;
|
||||
side.receiveShadow = true;
|
||||
this.group.add(side);
|
||||
}
|
||||
// windows on back wall
|
||||
const winY = 1.6;
|
||||
const nWin = Math.max(1, Math.floor(w / 5));
|
||||
for (let i = 0; i < nWin; i++) {
|
||||
const wx = -w / 2 + (i + 0.5) * (w / nWin);
|
||||
const skyM = new THREE.MeshStandardMaterial({ color: 0xbfe3ff, emissive: 0x9fd0f2, emissiveIntensity: 0.35 });
|
||||
const win = new THREE.Mesh(new THREE.PlaneGeometry(1.7, 1.25), skyM);
|
||||
win.position.set(wx, winY, -d / 2 + 0.02);
|
||||
this.group.add(win);
|
||||
const frame = new THREE.Mesh(new THREE.BoxGeometry(1.86, 1.4, 0.08),
|
||||
new THREE.MeshStandardMaterial({ color: 0xffffff, roughness: .8 }));
|
||||
frame.position.set(wx, winY, -d / 2 - 0.01);
|
||||
this.group.add(frame);
|
||||
}
|
||||
// ceiling (visible only from below)
|
||||
const ceil = new THREE.Mesh(new THREE.PlaneGeometry(w, d), new THREE.MeshStandardMaterial({ color: 0xfffaf0, roughness: 1 }));
|
||||
ceil.rotation.x = Math.PI / 2; ceil.position.y = wallH;
|
||||
this.group.add(ceil);
|
||||
// ceiling lamp
|
||||
const bulb = new THREE.Mesh(new THREE.SphereGeometry(0.12, 10, 8),
|
||||
new THREE.MeshStandardMaterial({ color: 0xfff2cc, emissive: 0xffe9b0, emissiveIntensity: 1.6 }));
|
||||
bulb.position.y = wallH - 0.35;
|
||||
this.group.add(bulb);
|
||||
this.group.add((() => {
|
||||
const wire = new THREE.Mesh(new THREE.CylinderGeometry(0.008, 0.008, 0.3, 6), new THREE.MeshBasicMaterial({ color: 0x333333 }));
|
||||
wire.position.y = wallH - 0.15; return wire;
|
||||
})());
|
||||
}
|
||||
|
||||
setBuildMode(on: boolean) {
|
||||
this.buildMode = on;
|
||||
if (on && !this.grid) {
|
||||
this.grid = new THREE.GridHelper(Math.max(this.size[0], this.size[1]), Math.max(this.size[0], this.size[1]), 0x35c4b5, 0x88a8a0);
|
||||
(this.grid.material as any).opacity = 0.18;
|
||||
(this.grid.material as any).transparent = true;
|
||||
this.grid.position.y = 0.02;
|
||||
this.group.add(this.grid);
|
||||
}
|
||||
if (this.grid) this.grid.visible = on;
|
||||
}
|
||||
|
||||
snap = 0.5;
|
||||
|
||||
setLayout(layout: PlacedItem[]) {
|
||||
// clear old
|
||||
for (const f of this.furniture) {
|
||||
this.group.remove(f.group);
|
||||
f.group.traverse((o: any) => { o.geometry?.dispose?.(); });
|
||||
}
|
||||
this.furniture = [];
|
||||
for (const item of layout || []) {
|
||||
const def = CATALOG_BY_ID[item.itemId];
|
||||
if (!def) continue;
|
||||
const g = buildFurniture(item.itemId);
|
||||
let x = item.x, z = item.z;
|
||||
if (this.buildMode) { x = Math.round(x / this.snap) * this.snap; z = Math.round(z / this.snap) * this.snap; }
|
||||
g.position.set(x, 0, z);
|
||||
g.rotation.y = item.rot || 0;
|
||||
this.group.add(g);
|
||||
this.furniture.push({ item, group: g, cat: def.cat });
|
||||
}
|
||||
this.onFurnitureChanged?.();
|
||||
}
|
||||
|
||||
addFurniture(item: PlacedItem): FurnitureEntry | null {
|
||||
const def = CATALOG_BY_ID[item.itemId];
|
||||
if (!def) return null;
|
||||
const g = buildFurniture(item.itemId);
|
||||
g.position.set(item.x, 0, item.z);
|
||||
g.rotation.y = item.rot || 0;
|
||||
this.group.add(g);
|
||||
const entry: FurnitureEntry = { item, group: g, cat: def.cat };
|
||||
this.furniture.push(entry);
|
||||
this.onFurnitureChanged?.();
|
||||
return entry;
|
||||
}
|
||||
|
||||
removeEntry(entry: FurnitureEntry) {
|
||||
const i = this.furniture.indexOf(entry);
|
||||
if (i !== -1) this.furniture.splice(i, 1);
|
||||
this.group.remove(entry.group);
|
||||
this.onFurnitureChanged?.();
|
||||
}
|
||||
|
||||
bounds() { return { hw: this.size[0] / 2 - 0.4, hd: this.size[1] / 2 - 0.4 }; }
|
||||
}
|
||||
|
||||
export function furnitureActions(cat: string) { return categoryActions(cat); }
|
||||
@@ -0,0 +1,369 @@
|
||||
// LifeTown Online — procedural "Maple Court" district (original world design).
|
||||
import * as THREE from 'three';
|
||||
import { BUILDINGS, POIS } from '../../../shared/data.mjs';
|
||||
|
||||
function mat(color: number | string, rough = 0.9, metal = 0) {
|
||||
return new THREE.MeshStandardMaterial({ color, roughness: rough, metalness: metal });
|
||||
}
|
||||
|
||||
function textTexture(text: string, bg = '#f7ead2', fg = '#5a4632') {
|
||||
const c = document.createElement('canvas');
|
||||
c.width = 512; c.height = 128;
|
||||
const g = c.getContext('2d')!;
|
||||
g.fillStyle = bg; g.fillRect(0, 0, c.width, c.height);
|
||||
g.strokeStyle = fg; g.lineWidth = 10; g.strokeRect(8, 8, c.width - 16, c.height - 16);
|
||||
g.fillStyle = fg; g.font = '900 72px Nunito, Arial'; g.textAlign = 'center'; g.textBaseline = 'middle';
|
||||
g.fillText(text, c.width / 2, c.height / 2 + 4);
|
||||
const t = new THREE.CanvasTexture(c);
|
||||
t.colorSpace = THREE.SRGBColorSpace;
|
||||
return t;
|
||||
}
|
||||
|
||||
export interface Collider { x: number; z: number; hw: number; hd: number }
|
||||
|
||||
export class Town {
|
||||
group = new THREE.Group();
|
||||
colliders: Collider[] = [];
|
||||
private windowMat = mat('#dff1ff', .4);
|
||||
private lampHeadMat = mat('#fff6da', .5);
|
||||
private lampGlows: THREE.Sprite[] = [];
|
||||
private waterMats: THREE.MeshStandardMaterial[] = [];
|
||||
poiRings = new Map<string, THREE.Mesh>();
|
||||
private pulseT = 0;
|
||||
|
||||
constructor() {
|
||||
this.windowMat.emissive = new THREE.Color(0xffdf9e);
|
||||
this.lampHeadMat.emissive = new THREE.Color(0xffe9b0);
|
||||
this.ground();
|
||||
this.roads();
|
||||
this.buildings();
|
||||
this.plaza();
|
||||
this.park();
|
||||
this.streetFurniture();
|
||||
this.poiMarkers();
|
||||
}
|
||||
|
||||
// ---------- layers ----------
|
||||
private ground() {
|
||||
const g = new THREE.Mesh(new THREE.PlaneGeometry(320, 240), mat(0x88c07a, 1));
|
||||
g.rotation.x = -Math.PI / 2; g.receiveShadow = true;
|
||||
this.group.add(g);
|
||||
// subtle grass tone patches
|
||||
for (let i = 0; i < 26; i++) {
|
||||
const p = new THREE.Mesh(new THREE.CircleGeometry(3 + Math.random() * 5, 12), mat(0x7db56f, 1));
|
||||
p.rotation.x = -Math.PI / 2;
|
||||
p.position.set((Math.random() - .5) * 220, 0.01, (Math.random() - .5) * 160);
|
||||
p.receiveShadow = true;
|
||||
this.group.add(p);
|
||||
}
|
||||
}
|
||||
|
||||
private roads() {
|
||||
const road = new THREE.Mesh(new THREE.PlaneGeometry(170, 8), mat(0x4a4f5a, 1));
|
||||
road.rotation.x = -Math.PI / 2; road.position.set(0, 0.02, 0); road.receiveShadow = true;
|
||||
this.group.add(road);
|
||||
// centre dashes
|
||||
const dashM = mat(0xf2e394, 1);
|
||||
for (let x = -80; x <= 80; x += 6) {
|
||||
const d = new THREE.Mesh(new THREE.PlaneGeometry(2.4, 0.28), dashM);
|
||||
d.rotation.x = -Math.PI / 2; d.position.set(x, 0.03, 0);
|
||||
this.group.add(d);
|
||||
}
|
||||
// sidewalks north & south
|
||||
for (const z of [5.4, -5.4]) {
|
||||
const sw = new THREE.Mesh(new THREE.BoxGeometry(170, 0.14, 2.6), mat(0xcfc8bb, 1));
|
||||
sw.position.set(0, 0.07, z); sw.receiveShadow = true; sw.castShadow = false;
|
||||
this.group.add(sw);
|
||||
}
|
||||
// plaza path to city hall + park path
|
||||
const pathM = mat(0xd8cfbf, 1);
|
||||
const mkPath = (x1: number, z1: number, x2: number, z2: number, w = 2.4) => {
|
||||
const dx = x2 - x1, dz = z2 - z1;
|
||||
const len = Math.hypot(dx, dz);
|
||||
const p = new THREE.Mesh(new THREE.PlaneGeometry(len, w), pathM);
|
||||
p.rotation.x = -Math.PI / 2;
|
||||
p.rotation.z = -Math.atan2(dz, dx);
|
||||
p.position.set((x1 + x2) / 2, 0.03, (z1 + z2) / 2);
|
||||
p.receiveShadow = true;
|
||||
this.group.add(p);
|
||||
};
|
||||
mkPath(0, -12, 0, -26); // spawn -> fountain
|
||||
mkPath(-48, -19, -20, -30); // cafe -> plaza
|
||||
mkPath(24, -20, 6, -30); // city hall -> plaza
|
||||
mkPath(54, -19, 42, -22, 2); // gym -> park
|
||||
mkPath(36, -26, 46, -38, 2); // park -> pond
|
||||
}
|
||||
|
||||
private buildings() {
|
||||
for (const b of BUILDINGS) {
|
||||
const grp = new THREE.Group();
|
||||
const body = new THREE.Mesh(new THREE.BoxGeometry(b.w, b.h ?? 8, b.d), mat(b.color));
|
||||
body.position.y = (b.h ?? 8) / 2;
|
||||
body.castShadow = true; body.receiveShadow = true;
|
||||
grp.add(body);
|
||||
|
||||
// roof slab + trim
|
||||
const roof = new THREE.Mesh(new THREE.BoxGeometry(b.w + 0.8, 0.7, b.d + 0.8), mat(b.roof));
|
||||
roof.position.y = (b.h ?? 8) + 0.35;
|
||||
roof.castShadow = true;
|
||||
grp.add(roof);
|
||||
|
||||
// windows facing the street (z<0 row faces +z toward road; z>0 row faces -z)
|
||||
const winRows = b.id === 'apt_a' || b.id === 'apt_b' ? 3 : 1;
|
||||
const cols = Math.max(2, Math.floor(b.w / 3.4));
|
||||
const frontK = b.z > 0 ? -1 : 1; // street-facing local direction
|
||||
for (let r = 0; r < winRows; r++) {
|
||||
for (let ci = 0; ci < cols; ci++) {
|
||||
const win = new THREE.Mesh(
|
||||
new THREE.PlaneGeometry(1.15, 1.35),
|
||||
this.windowMat
|
||||
);
|
||||
const px = -b.w / 2 + (ci + 0.5) * (b.w / cols);
|
||||
const py = 1.9 + r * 2.3;
|
||||
win.position.set(px, py, frontK * (b.d / 2 + 0.02));
|
||||
win.rotation.y = frontK > 0 ? 0 : Math.PI;
|
||||
grp.add(win);
|
||||
}
|
||||
}
|
||||
|
||||
// door on the street side + handle
|
||||
const door = new THREE.Mesh(new THREE.PlaneGeometry(1.5, 2.4), mat(0x7a5236, .8));
|
||||
const dz = frontK * (b.d / 2 + 0.03);
|
||||
door.position.set(0, 1.2, dz);
|
||||
door.rotation.y = frontK > 0 ? 0 : Math.PI;
|
||||
grp.add(door);
|
||||
const handle = new THREE.Mesh(new THREE.SphereGeometry(0.05, 8, 6), mat(0xd9c07a, .35, .6));
|
||||
handle.position.set(0.5, 1.15, dz + frontK * 0.04);
|
||||
grp.add(handle);
|
||||
// steps
|
||||
const step = new THREE.Mesh(new THREE.BoxGeometry(3, 0.24, 1.2), mat(0xbfb6a6, 1));
|
||||
step.position.set(0, 0.12, dz + frontK * 0.7);
|
||||
grp.add(step);
|
||||
|
||||
// hanging sign above the entrance
|
||||
const sign = new THREE.Mesh(
|
||||
new THREE.PlaneGeometry(Math.min(9, b.w * 0.62), 1.5),
|
||||
new THREE.MeshBasicMaterial({ map: textTexture(b.sign, '#fff4dd', '#4a3a28'), transparent: false })
|
||||
);
|
||||
sign.position.set(0, (b.h ?? 8) - 0.9, dz + frontK * 0.08);
|
||||
sign.rotation.y = frontK > 0 ? 0 : Math.PI;
|
||||
grp.add(sign);
|
||||
|
||||
grp.position.set(b.x, 0, b.z);
|
||||
this.group.add(grp);
|
||||
this.colliders.push({ x: b.x, z: b.z, hw: b.w / 2 + 0.3, hd: b.d / 2 + 0.3 });
|
||||
}
|
||||
}
|
||||
|
||||
private plaza() {
|
||||
const pave = new THREE.Mesh(new THREE.CylinderGeometry(14, 14, 0.12, 40), mat(0xe3d9c8, 1));
|
||||
pave.position.set(0, 0.06, -26); pave.receiveShadow = true;
|
||||
this.group.add(pave);
|
||||
|
||||
// fountain: tiers + water + centrepiece
|
||||
const stoneM = mat(0xc9b8a0, .95);
|
||||
const base = new THREE.Mesh(new THREE.CylinderGeometry(3.4, 3.7, 0.8, 24), stoneM);
|
||||
base.position.set(0, 0.5, -26); base.castShadow = true;
|
||||
this.group.add(base);
|
||||
const mid = new THREE.Mesh(new THREE.CylinderGeometry(1.7, 2.1, 0.7, 20), stoneM);
|
||||
mid.position.set(0, 1.15, -26); mid.castShadow = true;
|
||||
this.group.add(mid);
|
||||
const top = new THREE.Mesh(new THREE.CylinderGeometry(0.5, 0.8, 1.1, 16), stoneM);
|
||||
top.position.set(0, 1.85, -26); top.castShadow = true;
|
||||
this.group.add(top);
|
||||
const orb = new THREE.Mesh(new THREE.SphereGeometry(0.45, 16, 12), mat(0x67c7d9, .35, .1));
|
||||
orb.position.set(0, 2.6, -26); orb.castShadow = true;
|
||||
this.group.add(orb);
|
||||
const water = new THREE.Mesh(new THREE.CircleGeometry(3.25, 26), mat(0x54b8d9, .25, .05));
|
||||
water.rotation.x = -Math.PI / 2; water.position.set(0, 0.86, -26);
|
||||
this.group.add(water);
|
||||
this.waterMats.push(water.material as THREE.MeshStandardMaterial);
|
||||
this.colliders.push({ x: 0, z: -26, hw: 3.9, hd: 3.9 });
|
||||
|
||||
// stage (west of plaza)
|
||||
const stage = new THREE.Mesh(new THREE.CylinderGeometry(4.6, 4.8, 0.55, 26), mat(0xa9744f, .9));
|
||||
stage.position.set(-18, 0.28, -34); stage.receiveShadow = true; stage.castShadow = true;
|
||||
this.group.add(stage);
|
||||
const back = new THREE.Mesh(new THREE.BoxGeometry(7, 3.4, 0.4), mat(0x8a63b8, .9));
|
||||
back.position.set(-18, 2, -37.4); back.castShadow = true;
|
||||
this.group.add(back);
|
||||
for (let i = 0; i < 2; i++) {
|
||||
const spot = new THREE.Mesh(new THREE.SphereGeometry(0.28, 10, 8), mat(0xfff2b0, .4));
|
||||
spot.material.emissive = new THREE.Color(0xffe9a0);
|
||||
spot.position.set(-18 - 2.4 + i * 4.8, 3.9, -37.2);
|
||||
this.group.add(spot);
|
||||
void spot;
|
||||
}
|
||||
this.colliders.push({ x: -18, z: -37.4, hw: 3.6, hd: 0.5 });
|
||||
}
|
||||
|
||||
private park() {
|
||||
const lawn = new THREE.Mesh(new THREE.PlaneGeometry(44, 40), mat(0x79b56a, 1));
|
||||
lawn.rotation.x = -Math.PI / 2; lawn.position.set(42, 0.04, -30); lawn.receiveShadow = true;
|
||||
this.group.add(lawn);
|
||||
// pond
|
||||
const pond = new THREE.Mesh(new THREE.CircleGeometry(6.4, 30), mat(0x4fa8cc, .25, .05));
|
||||
pond.rotation.x = -Math.PI / 2; pond.scale.x = 1.25; pond.position.set(46, 0.06, -40);
|
||||
this.group.add(pond);
|
||||
const rim = new THREE.Mesh(new THREE.TorusGeometry(6.6, 0.35, 10, 34), mat(0xcfc0a8, 1));
|
||||
rim.rotation.x = -Math.PI / 2; rim.scale.x = 1.25; rim.position.set(46, 0.1, -40);
|
||||
this.group.add(rim);
|
||||
this.waterMats.push(pond.material as THREE.MeshStandardMaterial);
|
||||
// ducks :)
|
||||
for (const [dx, dz] of [[-2, 1], [1.5, -1]]) {
|
||||
const duck = new THREE.Group();
|
||||
const bd = new THREE.Mesh(new THREE.SphereGeometry(0.18, 8, 6), mat(0xf7d749, .8));
|
||||
bd.scale.set(1.3, 1, 1); duck.add(bd);
|
||||
const hd = new THREE.Mesh(new THREE.SphereGeometry(0.11, 8, 6), mat(0xf7d749, .8));
|
||||
hd.position.set(0.2, 0.16, 0); duck.add(hd);
|
||||
const bk = new THREE.Mesh(new THREE.ConeGeometry(0.045, 0.12, 6), mat(0xe8873c, .8));
|
||||
bk.rotation.z = -Math.PI / 2; bk.position.set(0.33, 0.16, 0); duck.add(bk);
|
||||
duck.position.set(46 + dx, 0.18, -40 + dz);
|
||||
this.group.add(duck);
|
||||
}
|
||||
// trees
|
||||
for (let i = 0; i < 14; i++) {
|
||||
const a = (i / 14) * Math.PI * 2;
|
||||
const tx = 42 + Math.cos(a) * (13 + Math.random() * 6);
|
||||
const tz = -30 + Math.sin(a) * (13 + Math.random() * 5);
|
||||
this.tree(tx, tz);
|
||||
}
|
||||
// extra town trees along the street
|
||||
for (const x of [-60, -36, -12, 12, 36, 60]) { this.tree(x, 8.6); this.tree(x + 14, -8.6); }
|
||||
// flowers
|
||||
for (let i = 0; i < 30; i++) {
|
||||
const f = new THREE.Mesh(new THREE.SphereGeometry(0.14, 6, 5),
|
||||
mat([0xef6f91, 0xf7d749, 0xef8a5f, 0xc77fe0][i % 4], .8));
|
||||
f.position.set(42 + (Math.random() - .5) * 36, 0.14, -30 + (Math.random() - .5) * 32);
|
||||
this.group.add(f);
|
||||
}
|
||||
}
|
||||
|
||||
tree(x: number, z: number) {
|
||||
const g = new THREE.Group();
|
||||
const trunkH = 1.4 + Math.random() * 0.8;
|
||||
const trunk = new THREE.Mesh(new THREE.CylinderGeometry(0.16, 0.22, trunkH, 8), mat(0x7a5236, .95));
|
||||
trunk.position.y = trunkH / 2; trunk.castShadow = true;
|
||||
g.add(trunk);
|
||||
const greens = [0x5ea75e, 0x6fb96a, 0x4f9850];
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const leaf = new THREE.Mesh(new THREE.SphereGeometry(0.85 - i * 0.18, 10, 8), mat(greens[i % 3], .95));
|
||||
leaf.position.set((Math.random() - .5) * 0.5, trunkH + 0.55 + i * 0.5, (Math.random() - .5) * 0.5);
|
||||
leaf.castShadow = true;
|
||||
g.add(leaf);
|
||||
}
|
||||
g.position.set(x, 0, z);
|
||||
this.group.add(g);
|
||||
}
|
||||
|
||||
bench(x: number, z: number, ry = 0) {
|
||||
const g = new THREE.Group();
|
||||
const woodM = mat(0x9a6b43, .9);
|
||||
const seat = new THREE.Mesh(new THREE.BoxGeometry(1.8, 0.1, 0.55), woodM);
|
||||
seat.position.y = 0.52; seat.castShadow = true; g.add(seat);
|
||||
const back = new THREE.Mesh(new THREE.BoxGeometry(1.8, 0.5, 0.09), woodM);
|
||||
back.position.set(0, 0.82, -0.24); back.castShadow = true; g.add(back);
|
||||
for (const sx of [-0.75, 0.75]) {
|
||||
const leg = new THREE.Mesh(new THREE.BoxGeometry(0.1, 0.5, 0.5), mat(0x4a4f5a, .7, .3));
|
||||
leg.position.set(sx, 0.26, 0); g.add(leg);
|
||||
}
|
||||
g.position.set(x, 0, z); g.rotation.y = ry;
|
||||
this.group.add(g);
|
||||
}
|
||||
|
||||
private streetFurniture() {
|
||||
// street lamps
|
||||
for (let x = -66; x <= 66; x += 22) {
|
||||
for (const z of [7.6, -7.6]) {
|
||||
const g = new THREE.Group();
|
||||
const pole = new THREE.Mesh(new THREE.CylinderGeometry(0.07, 0.1, 4.4, 8), mat(0x3c4254, .6, .4));
|
||||
pole.position.y = 2.2; pole.castShadow = true; g.add(pole);
|
||||
const arm = new THREE.Mesh(new THREE.BoxGeometry(0.9, 0.08, 0.08), mat(0x3c4254, .6, .4));
|
||||
arm.position.set(z > 0 ? -0.4 : 0.4, 4.35, 0); g.add(arm);
|
||||
const head = new THREE.Mesh(new THREE.SphereGeometry(0.22, 10, 8), this.lampHeadMat);
|
||||
head.position.set(z > 0 ? -0.8 : 0.8, 4.25, 0); g.add(head);
|
||||
const glow = new THREE.Sprite(new THREE.SpriteMaterial({
|
||||
map: glowTexture(), color: 0xffdf9e, transparent: true, opacity: 0,
|
||||
blending: THREE.AdditiveBlending, depthWrite: false,
|
||||
}));
|
||||
glow.scale.setScalar(3.2);
|
||||
glow.position.copy(head.position);
|
||||
g.add(glow);
|
||||
this.lampGlows.push(glow);
|
||||
g.position.set(x, 0, z);
|
||||
this.group.add(g);
|
||||
}
|
||||
}
|
||||
// benches
|
||||
this.bench(36, -21.6, 0.2);
|
||||
this.bench(-30, 7.4, Math.PI);
|
||||
this.bench(6, -33.4, 0.1);
|
||||
this.bench(-8, -18.6, Math.PI);
|
||||
// food truck
|
||||
const truck = new THREE.Group();
|
||||
const tb = new THREE.Mesh(new THREE.BoxGeometry(4.6, 2.3, 2), mat(0xf2545b, .8));
|
||||
tb.position.y = 1.5; tb.castShadow = true; truck.add(tb);
|
||||
const tcab = new THREE.Mesh(new THREE.BoxGeometry(1.2, 1.2, 2), mat(0xf7f7f7, .8));
|
||||
tcab.position.set(2.6, 0.9, 0); tcab.castShadow = true; truck.add(tcab);
|
||||
const tw = new THREE.Mesh(new THREE.PlaneGeometry(3, 0.9), new THREE.MeshBasicMaterial({ map: textTexture('TACO COMET', '#ffd98a', '#a03a3a') }));
|
||||
tw.position.set(0, 2.1, 1.02); truck.add(tw);
|
||||
for (const wx of [-1.4, 1.6]) for (const wz of [-1.02, 1.02]) {
|
||||
const wheel = new THREE.Mesh(new THREE.CylinderGeometry(0.38, 0.38, 0.25, 12), mat(0x22252e, .9));
|
||||
wheel.rotation.x = Math.PI / 2; wheel.position.set(wx, 0.38, wz); truck.add(wheel);
|
||||
}
|
||||
truck.position.set(12, 0, 10);
|
||||
this.group.add(truck);
|
||||
this.colliders.push({ x: 12, z: 10, hw: 3.4, hd: 1.6 });
|
||||
// bus stop shelter near apartments
|
||||
const shelter = new THREE.Group();
|
||||
const roofS = new THREE.Mesh(new THREE.BoxGeometry(4.4, 0.14, 1.8), mat(0x35c4b5, .8));
|
||||
roofS.position.y = 2.6; roofS.castShadow = true; shelter.add(roofS);
|
||||
for (const sx of [-2, 2]) {
|
||||
const post = new THREE.Mesh(new THREE.CylinderGeometry(0.06, 0.06, 2.6, 8), mat(0x3c4254, .6, .3));
|
||||
post.position.set(sx, 1.3, 0); shelter.add(post);
|
||||
}
|
||||
shelter.position.set(-8, 0, 8);
|
||||
this.group.add(shelter);
|
||||
}
|
||||
|
||||
private poiMarkers() {
|
||||
for (const poi of POIS) {
|
||||
const ring = new THREE.Mesh(
|
||||
new THREE.RingGeometry(poi.radius * 0.82, poi.radius, 32),
|
||||
new THREE.MeshBasicMaterial({ color: 0x35c4b5, transparent: true, opacity: 0.35, side: THREE.DoubleSide, depthWrite: false })
|
||||
);
|
||||
ring.rotation.x = -Math.PI / 2;
|
||||
ring.position.set(poi.x, 0.05, poi.z);
|
||||
this.group.add(ring);
|
||||
this.poiRings.set(poi.id, ring);
|
||||
}
|
||||
}
|
||||
|
||||
update(dt: number, minutes: number, _weather: string) {
|
||||
const t = minutes / 60;
|
||||
const night = t < 6.2 || t > 19.4 ? 1 : 0;
|
||||
const glowK = t < 6.2 ? Math.max(0, 1 - (t - 4.6)) : t > 19.4 ? Math.min(1, (t - 19.4) * 1.6) : 0;
|
||||
this.windowMat.emissiveIntensity = glowK * 1.6;
|
||||
this.lampHeadMat.emissiveIntensity = glowK * 2.2;
|
||||
for (const gl of this.lampGlows) (gl.material as THREE.SpriteMaterial).opacity = glowK * 0.75;
|
||||
this.pulseT += dt;
|
||||
for (const ring of this.poiRings.values()) {
|
||||
const m = ring.material as THREE.MeshBasicMaterial;
|
||||
m.opacity = 0.22 + Math.abs(Math.sin(this.pulseT * 2.4)) * 0.22;
|
||||
}
|
||||
for (const w of this.waterMats) { /* gentle shimmer */ }
|
||||
void night;
|
||||
}
|
||||
|
||||
disposeAll() { /* static for app lifetime */ }
|
||||
}
|
||||
|
||||
function glowTexture() {
|
||||
const c = document.createElement('canvas'); c.width = c.height = 64;
|
||||
const g = c.getContext('2d')!;
|
||||
const grad = g.createRadialGradient(32, 32, 2, 32, 32, 30);
|
||||
grad.addColorStop(0, 'rgba(255,230,170,1)');
|
||||
grad.addColorStop(1, 'rgba(255,230,170,0)');
|
||||
g.fillStyle = grad; g.fillRect(0, 0, 64, 64);
|
||||
return new THREE.CanvasTexture(c);
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
// LifeTown Online — rain/storm/fog particle FX + wet-ground puddles.
|
||||
import * as THREE from 'three';
|
||||
|
||||
export class WeatherFX {
|
||||
group = new THREE.Group();
|
||||
private rain: THREE.Points | null = null;
|
||||
private velocities: Float32Array | null = null;
|
||||
private count = 900;
|
||||
private wetness = 0; // 0 dry .. 1 soaked
|
||||
private puddles: THREE.Mesh[] = [];
|
||||
private lightningT = 0;
|
||||
|
||||
constructor() {
|
||||
// puddle discs (hidden while dry) — placed around town
|
||||
const spots: [number, number, number][] = [
|
||||
[-8, 3.2, 1], [14, -6, 0.8], [-30, -12, 1.2], [30, 4, 0.9], [-52, -10, 1],
|
||||
[4, -20, 1.1], [40, -18, 1], [60, -10, 0.8], [-64, -8, 0.9], [22, -24, 0.7],
|
||||
];
|
||||
for (const [x, z, s] of spots) {
|
||||
const p = new THREE.Mesh(
|
||||
new THREE.CircleGeometry(1.6 * s, 16),
|
||||
new THREE.MeshStandardMaterial({ color: 0x9fc8e8, roughness: 0.05, metalness: 0.35, transparent: true, opacity: 0 })
|
||||
);
|
||||
p.rotation.x = -Math.PI / 2;
|
||||
p.position.set(x, 0.045, z);
|
||||
this.puddles.push(p);
|
||||
this.group.add(p);
|
||||
}
|
||||
this.buildRain();
|
||||
}
|
||||
|
||||
private buildRain() {
|
||||
const geo = new THREE.BufferGeometry();
|
||||
const pos = new Float32Array(this.count * 3);
|
||||
this.velocities = new Float32Array(this.count);
|
||||
for (let i = 0; i < this.count; i++) {
|
||||
pos[i * 3] = (Math.random() - .5) * 120;
|
||||
pos[i * 3 + 1] = Math.random() * 30;
|
||||
pos[i * 3 + 2] = (Math.random() - .5) * 90;
|
||||
this.velocities[i] = 22 + Math.random() * 14;
|
||||
}
|
||||
geo.setAttribute('position', new THREE.BufferAttribute(pos, 3));
|
||||
const material = new THREE.PointsMaterial({
|
||||
color: 0xaaccee, size: 0.09, transparent: true, opacity: 0,
|
||||
depthWrite: false,
|
||||
});
|
||||
this.rain = new THREE.Points(geo, material);
|
||||
this.rain.visible = false;
|
||||
this.group.add(this.rain);
|
||||
}
|
||||
|
||||
update(dt: number, weather: string, camX: number, camZ: number) {
|
||||
const wantRain = weather === 'rain' ? 0.75 : weather === 'storm' ? 1 : 0;
|
||||
const mat = this.rain!.material as THREE.PointsMaterial;
|
||||
if (wantRain > 0) {
|
||||
this.rain!.visible = true;
|
||||
mat.opacity += (wantRain * 0.55 - mat.opacity) * Math.min(1, dt * 2);
|
||||
const posAttr = this.rain!.geometry.getAttribute('position') as THREE.BufferAttribute;
|
||||
const arr = posAttr.array as Float32Array;
|
||||
for (let i = 0; i < this.count; i++) {
|
||||
arr[i * 3 + 1] -= this.velocities![i] * dt;
|
||||
if (arr[i * 3 + 1] < 0) {
|
||||
arr[i * 3] = camX + (Math.random() - .5) * 100;
|
||||
arr[i * 3 + 1] = 26 + Math.random() * 8;
|
||||
arr[i * 3 + 2] = camZ + (Math.random() - .5) * 80;
|
||||
}
|
||||
}
|
||||
posAttr.needsUpdate = true;
|
||||
} else if (this.rain!.visible) {
|
||||
mat.opacity -= dt * 0.4;
|
||||
if (mat.opacity <= 0) this.rain!.visible = false;
|
||||
}
|
||||
|
||||
// ground wetness & puddle visibility
|
||||
this.wetness += ((wantRain > 0 ? 1 : 0) - this.wetness) * Math.min(1, dt * 0.08);
|
||||
for (const p of this.puddles) {
|
||||
(p.material as THREE.MeshStandardMaterial).opacity = this.wetness * 0.65;
|
||||
}
|
||||
|
||||
// lightning
|
||||
if (weather === 'storm') {
|
||||
this.lightningT -= dt;
|
||||
if (this.lightningT <= 0 && Math.random() < 0.02) {
|
||||
this.lightningT = 0.18;
|
||||
window.dispatchEvent(new CustomEvent('lt:lightning'));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Vendored
+2
@@ -0,0 +1,2 @@
|
||||
// Ambient declarations for JS modules shared with the Node server.
|
||||
declare module '*.mjs';
|
||||
@@ -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); }
|
||||
});
|
||||
@@ -0,0 +1,88 @@
|
||||
// LifeTown Online — WebSocket client with reconnect + message dispatch to bus.
|
||||
import { S, bus } from './state';
|
||||
|
||||
export class Net {
|
||||
ws: WebSocket | null = null;
|
||||
private queue: any[] = [];
|
||||
private retry = 0;
|
||||
private intentionalClose = false;
|
||||
url: string;
|
||||
|
||||
constructor() {
|
||||
const proto = location.protocol === 'https:' ? 'wss' : 'ws';
|
||||
this.url = `${proto}://${location.host}/ws`;
|
||||
}
|
||||
|
||||
connect(opts: { name: string; token?: string; appearance?: any }) {
|
||||
this.intentionalClose = false;
|
||||
bus.emit('net:connecting');
|
||||
try { this.ws = new WebSocket(this.url); } catch { return this.scheduleRetry(); }
|
||||
const ws = this.ws!;
|
||||
ws.onopen = () => {
|
||||
this.retry = 0;
|
||||
this.raw({ t: 'hello', name: opts.name, token: opts.token ?? undefined, appearance: opts.appearance ?? undefined });
|
||||
// flush queue
|
||||
for (const m of this.queue) this.raw(m);
|
||||
this.queue.length = 0;
|
||||
};
|
||||
ws.onmessage = (e) => {
|
||||
let msg; try { msg = JSON.parse(e.data); } catch { return; }
|
||||
if (msg.t === 'error') { bus.emit('srv:error', msg); return; }
|
||||
bus.emit('srv:' + msg.t, msg);
|
||||
};
|
||||
ws.onclose = () => {
|
||||
S.connected = false;
|
||||
bus.emit('net:down');
|
||||
if (!this.intentionalClose) this.scheduleRetry();
|
||||
};
|
||||
ws.onerror = () => {};
|
||||
}
|
||||
|
||||
private scheduleRetry() {
|
||||
this.retry++;
|
||||
const delay = Math.min(1000 * this.retry, 6000);
|
||||
setTimeout(() => {
|
||||
const name = S.name || localStorage.getItem('lt.name') || '';
|
||||
if (!name) return;
|
||||
this.connect({ name, token: localStorage.getItem('lt.token') || undefined });
|
||||
}, delay);
|
||||
}
|
||||
|
||||
send(msg: any) {
|
||||
if (this.ws && this.ws.readyState === WebSocket.OPEN) this.raw(msg);
|
||||
else this.queue.push(msg);
|
||||
}
|
||||
private raw(msg: any) { this.ws?.send(JSON.stringify(msg)); }
|
||||
close() { this.intentionalClose = true; this.ws?.close(); }
|
||||
}
|
||||
|
||||
export const net = new Net();
|
||||
|
||||
// ---- server → state wiring ----------------------------------
|
||||
bus.on('srv:welcome', (m: any) => {
|
||||
S.connected = true;
|
||||
S.yourId = m.yourId;
|
||||
S.token = m.token;
|
||||
const profile = m.profile;
|
||||
S.profile = profile;
|
||||
S.money = profile.money;
|
||||
S.zone = m.zone;
|
||||
S.time = { day: m.time.day, minutes: m.time.minutes };
|
||||
S.timeScale = m.time.scale || 60;
|
||||
S.weather = m.weather;
|
||||
S.online = (m.online?.length || 0) + 1;
|
||||
if (profile.needs) S.needs = { ...S.needs, ...profile.needs };
|
||||
localStorage.setItem('lt.token', m.token);
|
||||
localStorage.setItem('lt.name', profile.name);
|
||||
bus.emit('game:ready', m);
|
||||
});
|
||||
bus.on('srv:time', (m: any) => { S.time.day = m.day; S.time.minutes = m.minutes; S.weather = m.weather; bus.emit('world:time'); });
|
||||
bus.on('srv:weather', (m: any) => { S.weather = m.weather; bus.emit('world:weather'); });
|
||||
bus.on('srv:money', (m: any) => { S.money = m.amount; if (S.profile) S.profile.money = m.amount; bus.emit('eco:money'); });
|
||||
bus.on('srv:profile', (m: any) => {
|
||||
S.profile = m.profile; S.money = m.profile.money;
|
||||
bus.emit('profile:update');
|
||||
});
|
||||
bus.on('srv:inventory', (m: any) => { if (S.profile) S.profile.inventory = m.inventory; bus.emit('inv:update'); });
|
||||
bus.on('srv:jobUpdate', (m: any) => { if (S.profile) S.profile.job = m.job; bus.emit('job:update'); });
|
||||
bus.on('srv:relUpdate', (m: any) => { if (S.profile) S.profile.relationships = m.relationships; bus.emit('rel:update'); });
|
||||
@@ -0,0 +1,76 @@
|
||||
// LifeTown Online — client state store + tiny event bus.
|
||||
export type Need = 'hunger' | 'energy' | 'hygiene' | 'fun' | 'social' | 'comfort';
|
||||
|
||||
export interface Moodlet { id: string; label: string; emoji: string; happiness: number; expires?: number }
|
||||
|
||||
export interface RelEntry { f: number; r: number; partner?: boolean }
|
||||
|
||||
export interface JobState { id: string; rank: number; xp: number }
|
||||
|
||||
export interface Profile {
|
||||
name: string; money: number; level: number; xp: number;
|
||||
job: JobState | null;
|
||||
houseTier: string; housesOwned: string[];
|
||||
homeLayout: { itemId: string; x: number; z: number; rot: number }[];
|
||||
inventory: { itemId: string }[];
|
||||
relationships: Record<string, RelEntry>;
|
||||
achievements: string[];
|
||||
stats: { shifts?: number; dances?: number; giftsSent?: number; partners?: number };
|
||||
appearance: any; needs: Record<Need, number> | null; moodlets: Moodlet[];
|
||||
}
|
||||
|
||||
export interface Appearance {
|
||||
bodyType: string; height: number; skin: string; faceShape: string;
|
||||
hair: string; hairColor: string; eyes: string; eyeColor: string;
|
||||
brows: string; nose: string; mouth: string;
|
||||
outfit: { top: string; bottom: string; dress: string | null; shoes: string; accessory: string; topColor: string; bottomColor: string };
|
||||
personality: string[]; voice: string; age: number;
|
||||
}
|
||||
|
||||
type Handler = (data: any) => void;
|
||||
|
||||
class Bus {
|
||||
private map = new Map<string, Set<Handler>>();
|
||||
on(ev: string, fn: Handler) {
|
||||
if (!this.map.has(ev)) this.map.set(ev, new Set());
|
||||
this.map.get(ev)!.add(fn);
|
||||
return () => this.off(ev, fn);
|
||||
}
|
||||
once(ev: string, fn: Handler) {
|
||||
const wrap: Handler = (d) => { this.off(ev, wrap); fn(d); };
|
||||
this.on(ev, wrap);
|
||||
}
|
||||
off(ev: string, fn: Handler) { this.map.get(ev)?.delete(fn); }
|
||||
emit(ev: string, data?: any) { this.map.get(ev)?.forEach(fn => { try { fn(data); } catch (e) { console.error(e); } }); }
|
||||
}
|
||||
export const bus = new Bus();
|
||||
|
||||
export const S = {
|
||||
connected: false,
|
||||
yourId: '',
|
||||
token: '' as string,
|
||||
name: '',
|
||||
zone: 'town' as string, // 'town' | 'home:<name>'
|
||||
profile: null as Profile | null,
|
||||
time: { day: 1, minutes: 450 }, // interpolated locally
|
||||
timeScale: 60,
|
||||
weather: 'sunny',
|
||||
online: 1,
|
||||
// local simulation
|
||||
needs: { hunger: 82, energy: 88, hygiene: 90, fun: 70, social: 65, comfort: 75 } as Record<Need, number>,
|
||||
moodlets: [] as Moodlet[],
|
||||
money: 0,
|
||||
chatOpen: false,
|
||||
buildMode: false,
|
||||
audioOn: true,
|
||||
};
|
||||
|
||||
export function relLabel(f: number): string {
|
||||
if (f >= 75) return 'Best Friend';
|
||||
if (f >= 50) return 'Close Friend';
|
||||
if (f >= 25) return 'Friend';
|
||||
if (f >= 10) return 'Acquaintance';
|
||||
return 'Stranger';
|
||||
}
|
||||
|
||||
export function relKey(type: 'npc' | 'p', id: string) { return `${type}:${id.toLowerCase()}`; }
|
||||
@@ -0,0 +1,376 @@
|
||||
/* ============================================================
|
||||
LifeTown Online — original UI identity
|
||||
Rounded panels, soft shadows, coral/teal brand, friendly type.
|
||||
============================================================ */
|
||||
:root {
|
||||
--coral: #ff7e5f;
|
||||
--coral-deep: #f0643c;
|
||||
--teal: #35c4b5;
|
||||
--teal-deep: #1fa396;
|
||||
--gold: #ffcf6b;
|
||||
--ink: #141826;
|
||||
--ink-2: #1d2338;
|
||||
--paper: #fff7f0;
|
||||
--text: #ffffff;
|
||||
--dim: rgba(255,255,255,.62);
|
||||
--glass: rgba(18, 22, 38, .82);
|
||||
--glass-border: rgba(255,255,255,.09);
|
||||
--radius: 18px;
|
||||
--shadow: 0 10px 30px rgba(0,0,0,.35);
|
||||
font-size: 16px;
|
||||
}
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; -webkit-tap-highlight-color: transparent; }
|
||||
html, body { height: 100%; overflow: hidden; }
|
||||
body {
|
||||
font-family: "Nunito", "Quicksand", ui-rounded, "Segoe UI", system-ui, -apple-system, sans-serif;
|
||||
background: #0d1020; color: var(--text);
|
||||
user-select: none; -webkit-user-select: none;
|
||||
touch-action: none;
|
||||
}
|
||||
#app { position: fixed; inset: 0; }
|
||||
#gl { position: absolute; inset: 0; width: 100%; height: 100%; display: block; }
|
||||
|
||||
.glass {
|
||||
background: var(--glass);
|
||||
backdrop-filter: blur(14px);
|
||||
-webkit-backdrop-filter: blur(14px);
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
.hidden { display: none !important; }
|
||||
|
||||
/* ---------- buttons ---------- */
|
||||
.btn {
|
||||
border: 0; cursor: pointer; color: #fff;
|
||||
border-radius: 999px; padding: 12px 22px;
|
||||
font-weight: 800; font-size: 1rem; letter-spacing: .01em;
|
||||
transition: transform .12s ease, filter .15s ease, box-shadow .15s ease;
|
||||
}
|
||||
.btn:active { transform: scale(.96); }
|
||||
.btn-primary { background: linear-gradient(135deg, var(--coral), var(--gold)); box-shadow: 0 6px 20px rgba(255,126,95,.45); }
|
||||
.btn-primary:hover { filter: brightness(1.07); }
|
||||
.btn-accent { background: linear-gradient(135deg, var(--teal), #56e0cf); box-shadow: 0 6px 20px rgba(53,196,181,.4); }
|
||||
.btn-ghost { background: transparent; border: 2px solid rgba(255,255,255,.25); color: var(--dim); }
|
||||
.btn-ghost:hover { color: #fff; border-color: rgba(255,255,255,.5); }
|
||||
.btn-big { font-size: 1.15rem; padding: 14px 30px; width: 100%; }
|
||||
.iconbtn {
|
||||
background: rgba(255,255,255,.08); border: 1px solid var(--glass-border); color: #fff;
|
||||
width: 38px; height: 38px; border-radius: 12px; cursor: pointer; font-size: 1.05rem;
|
||||
transition: background .15s ease, transform .12s ease;
|
||||
}
|
||||
.iconbtn:hover { background: rgba(255,255,255,.16); }
|
||||
.iconbtn:active { transform: scale(.92); }
|
||||
|
||||
input, select {
|
||||
background: rgba(255,255,255,.07); border: 1px solid var(--glass-border);
|
||||
color: #fff; border-radius: 12px; padding: 11px 14px; font: inherit; outline: none; width: 100%;
|
||||
}
|
||||
input:focus, select:focus { border-color: var(--teal); box-shadow: 0 0 0 3px rgba(53,196,181,.25); }
|
||||
select option { color: #14202b; }
|
||||
input[type="range"] { padding: 0; height: 26px; accent-color: var(--coral); }
|
||||
|
||||
/* ---------- title screen ---------- */
|
||||
.screen { position: absolute; inset: 0; z-index: 40; display: flex; align-items: center; justify-content: center; flex-direction: column; }
|
||||
.title-sky {
|
||||
position: absolute; inset: 0;
|
||||
background:
|
||||
radial-gradient(1200px 500px at 70% -10%, rgba(255,207,107,.28), transparent 60%),
|
||||
radial-gradient(900px 480px at 15% 110%, rgba(53,196,181,.30), transparent 60%),
|
||||
linear-gradient(180deg, #10152b 0%, #1a2140 55%, #242f57 100%);
|
||||
}
|
||||
.title-sky::after {
|
||||
content:""; position:absolute; left:0; right:0; bottom:0; height:34%;
|
||||
background:
|
||||
radial-gradient(60px 22px at 12% 88%, rgba(255,255,255,.10), transparent 70%),
|
||||
radial-gradient(90px 30px at 32% 94%, rgba(255,255,255,.08), transparent 70%),
|
||||
radial-gradient(70px 26px at 58% 90%, rgba(255,255,255,.09), transparent 70%),
|
||||
radial-gradient(110px 36px at 82% 96%, rgba(255,255,255,.07), transparent 70%);
|
||||
}
|
||||
.title-card { position: relative; width: min(420px, 92vw); padding: 34px 30px 26px; text-align: center; animation: rise .6s cubic-bezier(.2,.9,.3,1.2); }
|
||||
@keyframes rise { from { opacity: 0; transform: translateY(26px) scale(.97);} to { opacity:1; transform:none; } }
|
||||
.wordmark {
|
||||
font-size: 3rem; font-weight: 900; letter-spacing: -.02em;
|
||||
background: linear-gradient(120deg, #ffd98a, var(--coral) 55%, #ff9a76);
|
||||
-webkit-background-clip: text; background-clip: text; color: transparent;
|
||||
filter: drop-shadow(0 4px 14px rgba(255,126,95,.35));
|
||||
}
|
||||
.wordmark span { font-weight: 400; }
|
||||
.wordmark.small { font-size: 1.5rem; margin-bottom: 4px; }
|
||||
.tagline { color: var(--dim); margin: 6px 0 22px; font-size: .95rem; letter-spacing: .04em; }
|
||||
.field-row { margin-bottom: 14px; }
|
||||
#screen-title .btn { margin-top: 10px; }
|
||||
.hint { margin-top: 14px; font-size: .85rem; color: var(--dim); min-height: 1.2em; }
|
||||
.title-foot { position: absolute; bottom: 14px; color: var(--dim); font-size: .8rem; letter-spacing: .06em; }
|
||||
|
||||
/* ---------- creator ---------- */
|
||||
#screen-creator { background: linear-gradient(180deg,#131a33,#1c2447); }
|
||||
.creator-layout { display: flex; gap: 18px; width: min(1060px, 96vw); height: min(700px, 94vh); align-items: stretch; }
|
||||
#creator-preview { flex: 1.1; border-radius: 24px; overflow: hidden; position: relative;
|
||||
background: radial-gradient(500px 320px at 50% 68%, rgba(255,178,120,.16), transparent 65%), linear-gradient(180deg,#18203f,#232c54);
|
||||
border: 1px solid var(--glass-border); box-shadow: var(--shadow); }
|
||||
.creator-panel { flex: 1; display: flex; flex-direction: column; padding: 18px; gap: 10px; }
|
||||
.creator-scroll { flex: 1; overflow-y: auto; padding-right: 6px; display: flex; flex-direction: column; gap: 14px; scrollbar-width: thin; }
|
||||
.creator-scroll section { display: flex; flex-direction: column; gap: 7px; }
|
||||
.creator-scroll label { font-size: .82rem; font-weight: 800; color: var(--dim); text-transform: uppercase; letter-spacing: .08em; margin-top: 4px; }
|
||||
.chips { display: flex; flex-wrap: wrap; gap: 7px; }
|
||||
.chip {
|
||||
padding: 7px 13px; border-radius: 999px; cursor: pointer; font-size: .86rem; font-weight: 700;
|
||||
background: rgba(255,255,255,.07); border: 1.5px solid transparent; transition: all .13s ease;
|
||||
}
|
||||
.chip:hover { background: rgba(255,255,255,.13); }
|
||||
.chip.sel { background: linear-gradient(135deg, rgba(255,126,95,.28), rgba(255,207,107,.22)); border-color: var(--coral); color: #fff; }
|
||||
.swatches { display: flex; flex-wrap: wrap; gap: 7px; }
|
||||
.swatch { width: 30px; height: 30px; border-radius: 50%; cursor: pointer; border: 2.5px solid transparent; outline: 2px solid rgba(255,255,255,.12); transition: transform .12s ease; }
|
||||
.swatch:hover { transform: scale(1.12); }
|
||||
.swatch.sel { border-color: #fff; outline-color: rgba(255,255,255,.4); }
|
||||
.dim { color: var(--dim); font-weight: 600; text-transform: none; letter-spacing: 0; }
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.creator-layout { flex-direction: column; height: 96vh; }
|
||||
#creator-preview { min-height: 200px; flex: .7; }
|
||||
}
|
||||
|
||||
/* ---------- HUD ---------- */
|
||||
#hud { position: absolute; inset: 0; pointer-events: none; z-index: 20; }
|
||||
#hud > * { pointer-events: auto; }
|
||||
|
||||
#topbar {
|
||||
position: absolute; top: 12px; left: 50%; transform: translateX(-50%);
|
||||
display: flex; gap: 8px; align-items: center; pointer-events: auto; flex-wrap: wrap; justify-content: center;
|
||||
max-width: 98vw;
|
||||
}
|
||||
.pill {
|
||||
background: var(--glass); border: 1px solid var(--glass-border); border-radius: 999px;
|
||||
padding: 8px 15px; font-weight: 800; font-size: .92rem; display: flex; gap: 7px; align-items: center;
|
||||
backdrop-filter: blur(12px); box-shadow: 0 4px 14px rgba(0,0,0,.3);
|
||||
}
|
||||
.coin { color: var(--gold); }
|
||||
.money-pill #tb-money { color: var(--gold); }
|
||||
.top-actions { display: flex; gap: 6px; }
|
||||
|
||||
/* toasts */
|
||||
#toasts { position: absolute; top: 64px; right: 12px; display: flex; flex-direction: column; gap: 8px; width: min(320px, 84vw); z-index: 60; }
|
||||
.toast {
|
||||
background: var(--glass); border: 1px solid var(--glass-border); border-left: 4px solid var(--teal);
|
||||
border-radius: 14px; padding: 11px 14px; box-shadow: var(--shadow);
|
||||
animation: slideIn .35s cubic-bezier(.2,.9,.3,1.15), fadeOut .4s ease 4.6s forwards;
|
||||
display: flex; gap: 10px; align-items: flex-start; cursor: default;
|
||||
}
|
||||
.toast.gold { border-left-color: var(--gold); }
|
||||
.toast.coral { border-left-color: var(--coral); }
|
||||
.toast .t-icon { font-size: 1.3rem; }
|
||||
.toast b { display: block; font-size: .9rem; }
|
||||
.toast p { font-size: .83rem; color: var(--dim); }
|
||||
@keyframes slideIn { from { opacity: 0; transform: translateX(30px);} to { opacity:1; transform:none;} }
|
||||
@keyframes fadeOut { to { opacity: 0; visibility: hidden; } }
|
||||
|
||||
/* context panel (right) */
|
||||
#context-panel { position: absolute; right: 12px; top: 190px; width: 230px; padding: 12px 14px; max-height: 34vh; overflow-y: auto; }
|
||||
.cp-title { font-weight: 900; font-size: .8rem; letter-spacing: .1em; color: var(--gold); margin-bottom: 8px; }
|
||||
.ctx-item { display: flex; gap: 9px; padding: 6px 0; border-bottom: 1px dashed rgba(255,255,255,.08); font-size: .85rem; }
|
||||
.ctx-item:last-child { border-bottom: 0; }
|
||||
.ctx-item .ci-ico { font-size: 1.05rem; }
|
||||
.ctx-item small { color: var(--dim); display: block; }
|
||||
|
||||
/* need rings + mood + action bar */
|
||||
#bottombar { position: absolute; left: 50%; transform: translateX(-50%); bottom: 12px; display: flex; flex-direction: column; align-items: center; gap: 9px; pointer-events: none; }
|
||||
#bottombar > * { pointer-events: auto; }
|
||||
#need-rings { display: flex; gap: 9px; background: var(--glass); border-radius: 999px; padding: 9px 16px; border: 1px solid var(--glass-border); backdrop-filter: blur(12px); }
|
||||
.ring { position: relative; width: 44px; height: 44px; display: grid; place-items: center; }
|
||||
.ring svg { position: absolute; inset: 0; transform: rotate(-90deg); }
|
||||
.ring .r-bg { stroke: rgba(255,255,255,.12); }
|
||||
.ring .r-fg { transition: stroke-dashoffset .4s ease; stroke-linecap: round; }
|
||||
.ring .r-emoji { font-size: 1.05rem; z-index: 1; filter: drop-shadow(0 1px 2px rgba(0,0,0,.5)); }
|
||||
#mood-chip { display: flex; align-items: center; gap: 8px; padding: 6px 16px; border-radius: 999px; font-weight: 800; font-size: .92rem; }
|
||||
#mood-emoji { font-size: 1.2rem; }
|
||||
#action-bar { display: flex; gap: 9px; }
|
||||
.abtn {
|
||||
width: 64px; height: 60px; border-radius: 18px; border: 1px solid var(--glass-border);
|
||||
background: var(--glass); backdrop-filter: blur(12px); color: #fff; cursor: pointer;
|
||||
display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 2px;
|
||||
font-size: 1.35rem; box-shadow: 0 6px 16px rgba(0,0,0,.35); transition: transform .12s ease, background .15s ease;
|
||||
}
|
||||
.abtn span { font-size: .62rem; font-weight: 800; letter-spacing: .06em; color: var(--dim); }
|
||||
.abtn:hover { background: rgba(40,48,80,.9); }
|
||||
.abtn:active { transform: scale(.93); }
|
||||
.abtn.active { background: linear-gradient(135deg, rgba(255,126,95,.75), rgba(255,207,107,.6)); border-color: var(--coral); }
|
||||
|
||||
/* prompt */
|
||||
#prompt {
|
||||
position: absolute; left: 50%; bottom: 172px; transform: translateX(-50%);
|
||||
background: var(--glass); border: 1px solid var(--glass-border); border-radius: 999px;
|
||||
padding: 9px 18px; display: flex; gap: 10px; align-items: center; font-weight: 700; font-size: .95rem;
|
||||
backdrop-filter: blur(10px); box-shadow: var(--shadow);
|
||||
}
|
||||
#prompt kbd { background: #fff; color: #16203a; border-radius: 8px; padding: 3px 9px; font-weight: 900; font-family: inherit; font-size: .85rem; box-shadow: 0 2px 0 #c9cbd4; }
|
||||
|
||||
/* speech bubbles anchored via JS transforms */
|
||||
#bubble-layer { position: absolute; inset: 0; pointer-events: none; overflow: hidden; }
|
||||
.bubble {
|
||||
position: absolute; transform: translate(-50%, -100%);
|
||||
background: rgba(255,255,255,.96); color: #1c2333; font-weight: 700; font-size: .86rem;
|
||||
padding: 8px 13px; border-radius: 14px; max-width: 210px; text-align: center;
|
||||
box-shadow: 0 6px 18px rgba(0,0,0,.3); line-height: 1.25;
|
||||
}
|
||||
.bubble::after {
|
||||
content:""; position: absolute; left: 50%; bottom: -7px; transform: translateX(-50%);
|
||||
border: 8px solid transparent; border-top-color: rgba(255,255,255,.96); border-bottom: 0;
|
||||
}
|
||||
.bubble.npc::before { content:""; }
|
||||
.bubble.fade { animation: bubbleIn .25s cubic-bezier(.2,.9,.3,1.2), bubbleOut .5s ease 3.8s forwards; }
|
||||
@keyframes bubbleIn { from { opacity:0; transform:translate(-50%,-90%) scale(.8);} to {opacity:1; transform:translate(-50%,-100%) scale(1);} }
|
||||
@keyframes bubbleOut { to { opacity:0; } }
|
||||
.name-tag {
|
||||
position: absolute; transform: translate(-50%, -100%); pointer-events: none;
|
||||
font-size: .74rem; font-weight: 900; color: #fff; text-shadow: 0 2px 4px rgba(0,0,0,.8);
|
||||
background: rgba(16,20,36,.55); padding: 3px 10px; border-radius: 999px; white-space: nowrap;
|
||||
}
|
||||
|
||||
/* chat */
|
||||
#chat-panel { position: absolute; left: 12px; bottom: 12px; width: min(360px, 46vw); pointer-events: auto; }
|
||||
#chat-log {
|
||||
max-height: 148px; overflow-y: auto; display: flex; flex-direction: column; gap: 4px;
|
||||
padding: 10px 12px; background: rgba(14,18,32,.72); border: 1px solid var(--glass-border);
|
||||
border-radius: 14px; backdrop-filter: blur(10px); scrollbar-width: thin;
|
||||
mask-image: linear-gradient(to bottom, transparent, black 12%);
|
||||
-webkit-mask-image: linear-gradient(to bottom, transparent, black 12%);
|
||||
}
|
||||
.chat-line { font-size: .84rem; line-height: 1.35; word-break: break-word; }
|
||||
.chat-line b { color: var(--gold); }
|
||||
.chat-line.sys { color: var(--teal); font-style: italic; }
|
||||
.chat-line.glob b { color: #8fb7ff; }
|
||||
.chat-input-row { display: flex; gap: 6px; margin-top: 6px; }
|
||||
.chat-input-row input { flex: 1; }
|
||||
.chat-input-row select { width: 92px; }
|
||||
|
||||
/* touch controls */
|
||||
#touch-ui { position: absolute; inset: 0; pointer-events: none; }
|
||||
#joy-base {
|
||||
position: absolute; left: 26px; bottom: 96px; width: 128px; height: 128px; border-radius: 50%;
|
||||
background: rgba(255,255,255,.07); border: 2px solid rgba(255,255,255,.18); pointer-events: auto;
|
||||
backdrop-filter: blur(6px);
|
||||
}
|
||||
#joy-knob { position: absolute; left: 50%; top: 50%; width: 56px; height: 56px; border-radius: 50%;
|
||||
background: radial-gradient(circle at 35% 30%, #fff, #cfd6ea); transform: translate(-50%,-50%); box-shadow: 0 4px 12px rgba(0,0,0,.4); }
|
||||
#touch-buttons { position: absolute; right: 22px; bottom: 110px; display: flex; flex-direction: column; gap: 12px; pointer-events: auto; }
|
||||
#touch-buttons button {
|
||||
width: 66px; height: 66px; border-radius: 50%; border: 2px solid rgba(255,255,255,.25);
|
||||
background: rgba(18,22,38,.75); color: #fff; font-size: 1.5rem; backdrop-filter: blur(8px);
|
||||
}
|
||||
|
||||
/* interaction wheel */
|
||||
#wheel { position: absolute; z-index: 45; padding: 10px; display: flex; flex-direction: column; gap: 4px; min-width: 220px; animation: pop .18s cubic-bezier(.2,.9,.3,1.2); }
|
||||
@keyframes pop { from { opacity: 0; transform: scale(.92);} to { opacity: 1; transform: scale(1);} }
|
||||
.wheel-group { font-size: .72rem; font-weight: 900; color: var(--gold); letter-spacing: .1em; margin: 6px 6px 2px; text-transform: uppercase; }
|
||||
.wheel-item {
|
||||
display: flex; justify-content: space-between; align-items: center; gap: 14px;
|
||||
padding: 9px 12px; border-radius: 12px; cursor: pointer; font-weight: 700; font-size: .92rem;
|
||||
}
|
||||
.wheel-item:hover { background: rgba(255,255,255,.1); }
|
||||
.wheel-item.locked { opacity: .42; cursor: not-allowed; }
|
||||
.wheel-item small { color: var(--dim); font-weight: 700; }
|
||||
|
||||
/* phone */
|
||||
#phone { position: absolute; right: 14px; bottom: 90px; z-index: 50; }
|
||||
.phone-frame {
|
||||
width: 340px; height: min(600px, 78vh); border-radius: 34px; padding: 14px;
|
||||
background: linear-gradient(160deg, #232a44, #12162a); border: 1px solid rgba(255,255,255,.14);
|
||||
box-shadow: 0 24px 60px rgba(0,0,0,.55); display: flex; flex-direction: column; gap: 10px;
|
||||
animation: pop .2s ease;
|
||||
}
|
||||
.phone-top { display: flex; justify-content: space-between; font-size: .78rem; font-weight: 800; color: var(--dim); padding: 0 8px; }
|
||||
#phone-screen { flex: 1; background: linear-gradient(180deg,#171d36,#10142a); border-radius: 20px; overflow-y: auto; padding: 14px; scrollbar-width: thin; touch-action: pan-y; }
|
||||
.creator-scroll, .cat-grid, #chat-log, #cp-content, .modal-card { touch-action: pan-y; }
|
||||
.phone-dock { display: flex; justify-content: space-around; gap: 6px; }
|
||||
.papp-btn { width: 52px; height: 52px; border-radius: 16px; border: 0; font-size: 1.4rem; cursor: pointer;
|
||||
background: rgba(255,255,255,.08); transition: transform .12s ease, background .15s; position: relative; }
|
||||
.papp-btn:hover { background: rgba(255,255,255,.15); }
|
||||
.papp-btn:active { transform: scale(.9); }
|
||||
.papp-btn.on { background: linear-gradient(135deg, var(--coral), var(--gold)); }
|
||||
.papp-title { font-weight: 900; font-size: 1.2rem; margin-bottom: 12px; display:flex; align-items:center; gap:8px;}
|
||||
.contact-card, .list-card {
|
||||
display: flex; align-items: center; gap: 12px; padding: 10px 12px; border-radius: 14px;
|
||||
background: rgba(255,255,255,.05); margin-bottom: 8px;
|
||||
}
|
||||
.contact-face { width: 42px; height: 42px; border-radius: 50%; display: grid; place-items: center; font-size: 1.2rem; flex-shrink: 0; }
|
||||
.rel-bar { height: 6px; border-radius: 99px; background: rgba(255,255,255,.12); overflow: hidden; margin-top: 5px; }
|
||||
.rel-bar i { display: block; height: 100%; border-radius: 99px; background: linear-gradient(90deg, var(--teal), #7fe3d7); }
|
||||
.rel-bar i.rom { background: linear-gradient(90deg, #ff7e9d, #ffb3c7); }
|
||||
.stat-row { display: flex; justify-content: space-between; padding: 10px 4px; border-bottom: 1px dashed rgba(255,255,255,.1); font-size: .92rem; }
|
||||
.stat-row b { color: var(--gold); }
|
||||
.tx-in { color: #6fe3b1; font-weight: 800; } .tx-out { color: #ff9d9d; font-weight: 800; }
|
||||
.post-card { background: rgba(255,255,255,.05); border-radius: 16px; padding: 12px; margin-bottom: 10px; }
|
||||
.post-head { display:flex; gap:10px; align-items:center; margin-bottom:8px; font-weight:800; font-size:.9rem;}
|
||||
.post-img { width:100%; border-radius:12px; display:block; }
|
||||
.post-actions { display:flex; gap:16px; padding-top:8px; color:var(--dim); font-size:.85rem; font-weight:700;}
|
||||
|
||||
/* build bar */
|
||||
#buildbar {
|
||||
position: absolute; top: 64px; left: 50%; transform: translateX(-50%);
|
||||
display: flex; gap: 10px; align-items: center; padding: 10px 14px; flex-wrap: wrap; justify-content: center;
|
||||
background: var(--glass); border: 1px solid var(--glass-border); border-radius: 20px; backdrop-filter: blur(14px);
|
||||
box-shadow: var(--shadow); z-index: 30; max-width: 96vw;
|
||||
}
|
||||
.bb-title { font-weight: 900; color: var(--gold); letter-spacing: .06em; font-size: .85rem; }
|
||||
.bb-tools { display: flex; gap: 6px; flex-wrap: wrap; }
|
||||
.tool {
|
||||
padding: 8px 14px; border-radius: 999px; border: 1.5px solid transparent; cursor: pointer;
|
||||
background: rgba(255,255,255,.07); color: #fff; font-weight: 800; font-size: .85rem;
|
||||
}
|
||||
.tool:hover { background: rgba(255,255,255,.13); }
|
||||
.tool.active { border-color: var(--teal); background: rgba(53,196,181,.2); }
|
||||
#buildbar .btn { padding: 9px 16px; font-size: .85rem; }
|
||||
|
||||
/* catalog / inventory grids */
|
||||
.modal { position: absolute; inset: 0; background: rgba(8,10,20,.55); display: grid; place-items: center; z-index: 70; backdrop-filter: blur(4px); }
|
||||
.modal-card { width: min(720px, 94vw); max-height: 84vh; display: flex; flex-direction: column; padding: 16px; animation: pop .18s ease; }
|
||||
.modal-card.small { width: min(520px, 94vw); }
|
||||
.modal-head { display: flex; justify-content: space-between; align-items: center; font-weight: 900; font-size: 1.1rem; margin-bottom: 12px; }
|
||||
.cat-tabs { display: flex; gap: 6px; flex-wrap: wrap; margin-bottom: 12px; }
|
||||
.cat-tab { padding: 7px 14px; border-radius: 999px; background: rgba(255,255,255,.07); cursor: pointer; font-weight: 800; font-size: .82rem; border: 1.5px solid transparent; }
|
||||
.cat-tab.on { border-color: var(--coral); background: rgba(255,126,95,.18); }
|
||||
.cat-grid { overflow-y: auto; display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 10px; padding-bottom: 6px; scrollbar-width: thin; }
|
||||
.cat-item {
|
||||
border-radius: 16px; padding: 10px; background: rgba(255,255,255,.05); border: 1.5px solid transparent;
|
||||
display: flex; flex-direction: column; gap: 6px; cursor: pointer; transition: all .13s ease; position: relative;
|
||||
}
|
||||
.cat-item:hover { background: rgba(255,255,255,.1); border-color: rgba(255,255,255,.2); }
|
||||
.cat-item.locked { opacity: .45; }
|
||||
.cat-item .ci-name { font-weight: 800; font-size: .86rem; line-height: 1.2; }
|
||||
.cat-item .ci-meta { font-size: .75rem; color: var(--dim); display: flex; justify-content: space-between; }
|
||||
.cat-item .ci-price { color: var(--gold); font-weight: 900; }
|
||||
.cat-item .cat-ico { height: 74px; display: grid; place-items: center; font-size: 2.2rem; border-radius: 12px; background: rgba(255,255,255,.04); }
|
||||
.cat-item .fx-note { font-size: .68rem; color: var(--teal); font-weight: 700; }
|
||||
|
||||
/* minimap */
|
||||
#minimap {
|
||||
position: absolute; right: 12px; bottom: 90px; border-radius: 18px; border: 1px solid var(--glass-border);
|
||||
background: rgba(14,18,32,.8); backdrop-filter: blur(8px); box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
/* loading */
|
||||
#loading { position: absolute; inset: 0; z-index: 80; display: flex; flex-direction: column; gap: 18px; align-items: center; justify-content: center; background: #10152b; }
|
||||
.loader-ring { width: 54px; height: 54px; border-radius: 50%; border: 5px solid rgba(255,255,255,.12); border-top-color: var(--coral); animation: spin 1s linear infinite; }
|
||||
@keyframes spin { to { transform: rotate(360deg);} }
|
||||
#loading-text { color: var(--dim); font-weight: 700; letter-spacing: .04em; }
|
||||
|
||||
/* event banner */
|
||||
#event-banner {
|
||||
position: absolute; top: 108px; left: 50%; transform: translateX(-50%);
|
||||
background: linear-gradient(135deg, rgba(255,126,95,.92), rgba(255,207,107,.88));
|
||||
color: #20141f; font-weight: 900; padding: 10px 26px; border-radius: 999px; letter-spacing: .03em;
|
||||
box-shadow: 0 8px 26px rgba(255,126,95,.5); z-index: 35; animation: pop .3s ease; pointer-events: none;
|
||||
}
|
||||
|
||||
/* responsive */
|
||||
@media (max-width: 900px) {
|
||||
#context-panel { display: none; }
|
||||
#minimap { display: none; }
|
||||
.abtn { width: 52px; height: 52px; font-size: 1.1rem; border-radius: 15px; }
|
||||
.ring { width: 36px; height: 36px; }
|
||||
.pill { padding: 6px 11px; font-size: .8rem; }
|
||||
#chat-panel { width: min(300px, 60vw); }
|
||||
#chat-log { max-height: 90px; }
|
||||
.wordmark { font-size: 2.3rem; }
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
// LifeTown Online — needs simulation, moodlets & mood derivation (client-side sim,
|
||||
// synced to server periodically).
|
||||
import { S, bus, Moodlet, Need } from './state';
|
||||
import { NEED_DECAY_PER_GAME_HOUR, MOOD_NEEDS, MOOD_EMOJI } from '../../shared/data.mjs';
|
||||
|
||||
const clamp = (v: number, a = 0, b = 100) => Math.max(a, Math.min(b, v));
|
||||
|
||||
export const NeedsSys = {
|
||||
// multipliers granted by furniture/environment auras
|
||||
auras: { energyRate: 1, hygieneRate: 1, funRate: 1, comfortRate: 1, hungerRate: 1, cookSpeed: 1, productivity: 1 } as Record<string, number>,
|
||||
sleeping: false,
|
||||
|
||||
decayTick(gameMinutesDelta: number) {
|
||||
const hours = gameMinutesDelta / 60;
|
||||
const n = S.needs;
|
||||
n.hunger = clamp(n.hunger - NEED_DECAY_PER_GAME_HOUR.hunger * hours);
|
||||
n.energy = clamp(n.energy - NEED_DECAY_PER_GAME_HOUR.energy * hours * (this.sleeping ? -2.2 : 1));
|
||||
n.hygiene = clamp(n.hygiene - NEED_DECAY_PER_GAME_HOUR.hygiene * hours);
|
||||
n.fun = clamp(n.fun - NEED_DECAY_PER_GAME_HOUR.fun * hours * this.auras.funRate);
|
||||
n.social = clamp(n.social - NEED_DECAY_PER_GAME_HOUR.social * hours);
|
||||
n.comfort = clamp(n.comfort - NEED_DECAY_PER_GAME_HOUR.comfort * hours * this.auras.comfortRate);
|
||||
if (this.sleeping) n.energy = clamp(n.energy + 14 * hours * this.auras.energyRate);
|
||||
bus.emit('needs:update');
|
||||
this.updateMood();
|
||||
},
|
||||
|
||||
apply(fx: Partial<Record<Need | 'happiness' | 'energy' | 'fun', number>>) {
|
||||
for (const [k, v] of Object.entries(fx)) {
|
||||
if (k === 'happiness') continue;
|
||||
if (k in S.needs) (S.needs as any)[k] = clamp((S.needs as any)[k] + (v as number));
|
||||
}
|
||||
if (fx.happiness) {
|
||||
this.addMoodlet({ id: 'ev' + Date.now(), label: 'Event', emoji: fx.happiness > 0 ? '✨' : '💔', happiness: fx.happiness, expires: Date.now() + 120000 });
|
||||
}
|
||||
bus.emit('needs:update');
|
||||
this.updateMood();
|
||||
},
|
||||
|
||||
addMoodlet(m: Moodlet) {
|
||||
S.moodlets = S.moodlets.filter(x => x.id !== m.id && x.label !== m.label);
|
||||
S.moodlets.push(m);
|
||||
if (S.moodlets.length > 8) S.moodlets.shift();
|
||||
bus.emit('mood:moodlets');
|
||||
this.updateMood();
|
||||
},
|
||||
|
||||
removeMoodlet(idOrLabel: string) {
|
||||
S.moodlets = S.moodlets.filter(x => x.id !== idOrLabel && x.label !== idOrLabel);
|
||||
bus.emit('mood:moodlets');
|
||||
this.updateMood();
|
||||
},
|
||||
|
||||
/** -100..100 wellbeing score */
|
||||
score(): number {
|
||||
let acc = 0, wsum = 0;
|
||||
for (const [need, w] of MOOD_NEEDS) {
|
||||
const v = (S.needs as any)[need] ?? 50;
|
||||
acc += (v - 45) * w;
|
||||
wsum += w;
|
||||
}
|
||||
let base = acc / wsum;
|
||||
for (const m of S.moodlets) base += m.happiness;
|
||||
return Math.max(-100, Math.min(100, base));
|
||||
},
|
||||
|
||||
mood(): string {
|
||||
const sc = this.score();
|
||||
const n = S.needs;
|
||||
if (this.sleeping) return 'Relaxed';
|
||||
if (n.energy < 18) return 'Tired';
|
||||
if (n.hunger < 15) return 'Stressed';
|
||||
if (sc > 55) return S.needs.social > 70 && n.fun > 65 ? 'Excited' : 'Happy';
|
||||
if (sc > 30) return S.moodlets.some(m => m.label === 'Romance') ? 'Romantic' : 'Confident';
|
||||
if (sc > 10) return 'Relaxed';
|
||||
if (sc > -10) return S.needs.social < 25 ? 'Lonely' : 'Bored';
|
||||
if (sc > -35) return 'Sad';
|
||||
return 'Angry';
|
||||
},
|
||||
|
||||
emojiFor(mood: string) { return (MOOD_EMOJI as any)[mood] || '🙂'; },
|
||||
|
||||
updateMood() {
|
||||
const m = this.mood();
|
||||
if ((S as any)._lastMood !== m) {
|
||||
(S as any)._lastMood = m;
|
||||
bus.emit('mood:update', m);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
// ---- periodic drivers ---------------------------------------
|
||||
let lastGameMin = -1;
|
||||
bus.on('world:time', () => {
|
||||
const cur = S.time.day * 1440 + S.time.minutes;
|
||||
if (lastGameMin < 0) lastGameMin = cur;
|
||||
let d = cur - lastGameMin;
|
||||
if (d < 0) d += 1440;
|
||||
lastGameMin = cur;
|
||||
if (d > 0 && d < 200) NeedsSys.decayTick(d);
|
||||
});
|
||||
@@ -0,0 +1,281 @@
|
||||
// LifeTown Online — build mode: placement tools, catalogue shopping, inventory.
|
||||
import * as THREE from 'three';
|
||||
import { S, bus } from '../state';
|
||||
import { $, el, toast, CAT_EMOJI, fmtMoney, openDialog } from './common';
|
||||
import { net } from '../net';
|
||||
import { CATALOG, CATALOG_BY_ID, HOUSES } from '../../../shared/data.mjs';
|
||||
import { Interior, PlacedItem } from '../gfx/interior';
|
||||
import { buildFurniture } from '../gfx/furniture';
|
||||
import { audio } from '../audio';
|
||||
|
||||
type Tool = 'place' | 'move' | 'rotate' | 'duplicate' | 'delete';
|
||||
|
||||
export class BuildUI {
|
||||
tool: Tool = 'place';
|
||||
placingItemId: string | null = null;
|
||||
ghost: THREE.Group | null = null;
|
||||
private raycaster = new THREE.Raycaster();
|
||||
private pointer = new THREE.Vector2();
|
||||
private floorPlane = new THREE.Plane(new THREE.Vector3(0, 1, 0), 0);
|
||||
private hit = new THREE.Vector3();
|
||||
private selectedEntry: any = null;
|
||||
private dragging: any = null;
|
||||
|
||||
constructor(
|
||||
public getInterior: () => Interior | null,
|
||||
public camera: THREE.PerspectiveCamera,
|
||||
public canvas: HTMLCanvasElement,
|
||||
) {
|
||||
this.initToolbar();
|
||||
this.initCatalogUI();
|
||||
this.initInventoryUI();
|
||||
this.canvas.addEventListener('pointermove', (e) => {
|
||||
this.pointer.set((e.clientX / window.innerWidth) * 2 - 1, -(e.clientY / window.innerHeight) * 2 + 1);
|
||||
this.onPointerMove(e);
|
||||
});
|
||||
this.canvas.addEventListener('pointerdown', (e) => this.onCanvasClick(e));
|
||||
}
|
||||
|
||||
// ---------- toolbar ----------
|
||||
private initToolbar() {
|
||||
document.querySelectorAll('#buildbar .tool').forEach(b =>
|
||||
b.addEventListener('click', () => this.setTool((b as HTMLElement).dataset.tool as Tool)));
|
||||
$('#btn-shop').addEventListener('click', () => this.openCatalog());
|
||||
$('#btn-exit-build').addEventListener('click', () => bus.emit('build:exit'));
|
||||
}
|
||||
|
||||
setTool(t: Tool) {
|
||||
this.tool = t;
|
||||
document.querySelectorAll('#buildbar .tool').forEach(b => b.classList.toggle('active', (b as HTMLElement).dataset.tool === t));
|
||||
if (t !== 'place') this.clearGhost();
|
||||
audio.click();
|
||||
}
|
||||
|
||||
startPlacing(itemId: string) {
|
||||
this.placingItemId = itemId;
|
||||
this.setTool('place');
|
||||
this.makeGhost(itemId);
|
||||
}
|
||||
|
||||
private makeGhost(itemId: string) {
|
||||
this.clearGhost();
|
||||
const def = CATALOG_BY_ID[itemId];
|
||||
if (!def) return;
|
||||
this.ghost = buildFurniture(itemId);
|
||||
this.ghost.traverse(o => { const m = o as THREE.Mesh; if (m.material) { m.material = (m.material as any).clone(); if ((m.material as any).emissive !== undefined) (m.material as any).emissive = new THREE.Color(0x35c4b5); (m.material as any).transparent = true; (m.material as any).opacity = 0.65; } });
|
||||
this.ghost.visible = false;
|
||||
this.getInterior()?.group.add(this.ghost);
|
||||
}
|
||||
clearGhost() {
|
||||
if (this.ghost) { this.ghost.parent?.remove(this.ghost); this.ghost = null; }
|
||||
}
|
||||
|
||||
private onPointerMove(e: PointerEvent) {
|
||||
void e;
|
||||
if (!S.buildMode || !this.ghost || !this.placingItemId) return;
|
||||
const int = this.getInterior(); if (!int) return;
|
||||
this.raycaster.setFromCamera(this.pointer, this.camera);
|
||||
if (!this.raycaster.ray.intersectPlane(this.floorPlane, this.hit)) return;
|
||||
const b = int.bounds();
|
||||
const sx = Math.round(THREE.MathUtils.clamp(this.hit.x, -b.hw, b.hw) / int.snap) * int.snap;
|
||||
const sz = Math.round(THREE.MathUtils.clamp(this.hit.z, -b.hd, b.hd) / int.snap) * int.snap;
|
||||
this.ghost.position.set(sx, 0, sz);
|
||||
this.ghost.visible = true;
|
||||
}
|
||||
|
||||
private onCanvasClick(e: MouseEvent) {
|
||||
if (!S.buildMode || e.target !== this.canvas) return;
|
||||
const int = this.getInterior(); if (!int) return;
|
||||
if (this.tool === 'place' && this.placingItemId && this.ghost && this.ghost.visible) {
|
||||
// must own a copy in inventory
|
||||
const invIdx = S.profile?.inventory.findIndex(i => i.itemId === this.placingItemId) ?? -1;
|
||||
if (invIdx === -1) return toast('Not in inventory', 'Buy it at MapleMart first.', '🛒');
|
||||
S.profile!.inventory.splice(invIdx, 1);
|
||||
bus.emit('inv:update');
|
||||
const item: PlacedItem = {
|
||||
itemId: this.placingItemId,
|
||||
x: +this.ghost.position.x.toFixed(2),
|
||||
z: +this.ghost.position.z.toFixed(2),
|
||||
rot: this.ghost.rotation.y,
|
||||
};
|
||||
int.addFurniture(item);
|
||||
this.persistLayout(int);
|
||||
audio.pop();
|
||||
toast('Placed!', `${CATALOG_BY_ID[item.itemId].name} placed.`, '🔨');
|
||||
return;
|
||||
}
|
||||
if (this.tool === 'delete' || this.tool === 'rotate' || this.tool === 'move' || this.tool === 'duplicate') {
|
||||
// pick nearest furniture to click
|
||||
this.raycaster.setFromCamera(this.pointer, this.camera);
|
||||
const hits = this.raycaster.intersectObjects(int.furniture.map(f => f.group), true);
|
||||
if (!hits.length) return;
|
||||
let obj: THREE.Object3D | null = hits[0].object;
|
||||
while (obj && !int.furniture.some(f => f.group === obj)) obj = obj.parent;
|
||||
const entry = int.furniture.find(f => f.group === obj);
|
||||
if (!entry) return;
|
||||
this.selectedEntry = entry;
|
||||
if (this.tool === 'delete') {
|
||||
const refund = Math.round((CATALOG_BY_ID[entry.item.itemId]?.price ?? 0) * 0.6);
|
||||
int.removeEntry(entry);
|
||||
net.send({ t: 'sell_placed', itemId: entry.item.itemId });
|
||||
this.persistLayout(int);
|
||||
toast('Removed', `${refund} TC refunded by MapleMart`, '🗑');
|
||||
audio.pop();
|
||||
} else if (this.tool === 'rotate') {
|
||||
entry.item.rot = (entry.item.rot + Math.PI / 4) % (Math.PI * 2);
|
||||
entry.group.rotation.y = entry.item.rot;
|
||||
this.persistLayout(int);
|
||||
audio.click();
|
||||
} else if (this.tool === 'duplicate') {
|
||||
// duplicating requires owning another copy; charge nothing but check inventory
|
||||
const invIdx = S.profile?.inventory.findIndex(i => i.itemId === entry.item.itemId) ?? -1;
|
||||
if (invIdx === -1) return toast('No spare copy', 'Duplicate needs the same item in your inventory.', '⧉');
|
||||
S.profile!.inventory.splice(invIdx, 1);
|
||||
const copy: PlacedItem = { itemId: entry.item.itemId, x: +(entry.item.x + 1).toFixed(2), z: entry.item.z, rot: entry.item.rot };
|
||||
int.addFurniture(copy);
|
||||
this.persistLayout(int);
|
||||
audio.pop();
|
||||
} else if (this.tool === 'move') {
|
||||
// drag mode: move with pointer until next click
|
||||
this.dragging = entry;
|
||||
toast('Moving…', 'Click a spot to drop it.', '✥');
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (this.dragging) {
|
||||
this.dragging.item.x = +this.hit.x.toFixed(2);
|
||||
this.dragging.item.z = +this.hit.z.toFixed(2);
|
||||
this.dragging.group.position.x = this.dragging.item.x;
|
||||
this.dragging.group.position.z = this.dragging.item.z;
|
||||
this.persistLayout(int);
|
||||
this.dragging = null;
|
||||
audio.pop();
|
||||
}
|
||||
}
|
||||
|
||||
persistLayout(int: Interior) {
|
||||
const layout = int.furniture.map(f => f.item);
|
||||
if (S.profile) S.profile.homeLayout = layout;
|
||||
net.send({ t: 'place_home', layout });
|
||||
}
|
||||
|
||||
update() { /* ghost follows in pointermove */ }
|
||||
|
||||
// ---------- catalog ----------
|
||||
private activeTab = 'all';
|
||||
private initCatalogUI() {
|
||||
$('#catalog-close').addEventListener('click', () => $('#catalog').classList.add('hidden'));
|
||||
const tabs = $('#cat-tabs');
|
||||
const cats: string[] = ['all', ...new Set<string>(CATALOG.map((c: any) => c.cat))];
|
||||
for (const c of cats) {
|
||||
const t = el('div', 'cat-tab' + (c === 'all' ? ' on' : ''), c === 'all' ? 'All' : (c[0].toUpperCase() + c.slice(1)));
|
||||
t.addEventListener('click', () => {
|
||||
this.activeTab = c;
|
||||
tabs.querySelectorAll('.cat-tab').forEach(x => x.classList.toggle('on', x === t));
|
||||
this.renderCatalogGrid();
|
||||
});
|
||||
tabs.appendChild(t);
|
||||
}
|
||||
this.renderCatalogGrid();
|
||||
bus.on('inv:update', () => this.renderCatalogGrid());
|
||||
bus.on('eco:money', () => this.renderCatalogGrid());
|
||||
}
|
||||
|
||||
renderCatalogGrid() {
|
||||
const grid = $('#cat-grid');
|
||||
if ($('#catalog').classList.contains('hidden')) return;
|
||||
grid.innerHTML = '';
|
||||
const lvl = S.profile?.level ?? 1;
|
||||
for (const item of CATALOG) {
|
||||
if (this.activeTab !== 'all' && item.cat !== this.activeTab) continue;
|
||||
const ownedCount = S.profile?.inventory.filter(i => i.itemId === item.id).length ?? 0;
|
||||
const locked = lvl < item.lvl;
|
||||
const poor = S.money < item.price;
|
||||
const fxNotes: string[] = [];
|
||||
for (const [k, v] of Object.entries(item.fx)) {
|
||||
if (k.endsWith('Rate') || k === 'cookSpeed' || k === 'productivity') fxNotes.push(`${k.replace('Rate', '')} +${Math.round(((v as number) - 1) * 100)}%`);
|
||||
if (v === true) fxNotes.push(k.replace('Aura', ' aura'));
|
||||
}
|
||||
const card = el('div', 'cat-item' + (locked ? ' locked' : ''), `
|
||||
<div class="cat-ico">${CAT_EMOJI[item.cat] || '📦'}</div>
|
||||
<div class="ci-name">${item.name}</div>
|
||||
<div class="fx-note">${fxNotes.slice(0, 2).join(' · ')}</div>
|
||||
<div class="ci-meta"><span class="ci-price">${item.price.toLocaleString()} ◉</span><span>Lv ${item.lvl}${ownedCount ? ` · own ×${ownedCount}` : ''}</span></div>`);
|
||||
card.addEventListener('click', () => {
|
||||
if (locked) return toast('Locked', `Unlocks at level ${item.lvl}.`, '🔒');
|
||||
if (poor) return toast('Too pricey', `You need ${fmtMoney(item.price)} TC.`, '💸');
|
||||
net.send({ t: 'buy_item', itemId: item.id });
|
||||
audio.cash();
|
||||
// streamline: in build mode go straight to placing the new piece
|
||||
if (S.buildMode && S.zone !== 'town') {
|
||||
$('#catalog').classList.add('hidden');
|
||||
this.startPlacing(item.id);
|
||||
toast('Place it!', 'Click inside your home to drop it (grid-snapped).', '🔨');
|
||||
}
|
||||
});
|
||||
grid.appendChild(card);
|
||||
}
|
||||
}
|
||||
|
||||
openCatalog() {
|
||||
$('#catalog').classList.remove('hidden');
|
||||
this.renderCatalogGrid();
|
||||
audio.click();
|
||||
}
|
||||
|
||||
// ---------- inventory ----------
|
||||
private initInventoryUI() {
|
||||
$('#inv-close').addEventListener('click', () => $('#inventory').classList.add('hidden'));
|
||||
bus.on('inv:update', () => this.renderInventory());
|
||||
bus.on('profile:update', () => this.renderInventory());
|
||||
}
|
||||
|
||||
renderInventory() {
|
||||
if ($('#inventory').classList.contains('hidden')) return;
|
||||
const grid = $('#inv-grid');
|
||||
grid.innerHTML = '';
|
||||
const inv = S.profile?.inventory || [];
|
||||
if (!inv.length) grid.appendChild(el('div', 'dim', 'Empty — visit MapleMart in town!'));
|
||||
for (const slot of inv) {
|
||||
const item = CATALOG_BY_ID[slot.itemId];
|
||||
if (!item) continue;
|
||||
const card = el('div', 'cat-item', `
|
||||
<div class="cat-ico">${CAT_EMOJI[item.cat] || '📦'}</div>
|
||||
<div class="ci-name">${item.name}</div>
|
||||
<div class="ci-meta"><span>Sell ${Math.round(item.price * .6)} ◉</span></div>`);
|
||||
const actions = el('div'); actions.style.cssText = 'display:flex;gap:6px';
|
||||
if (S.zone !== 'town') {
|
||||
const placeBtn = el('button', 'btn btn-accent', 'Place');
|
||||
placeBtn.style.cssText = 'flex:1;padding:7px;font-size:.78rem';
|
||||
placeBtn.addEventListener('click', () => {
|
||||
if (!S.buildMode) { toast('Build Mode first', 'Tap 🔨 Build, then Place.', '🔨'); return; }
|
||||
$('#inventory').classList.add('hidden');
|
||||
this.startPlacing(slot.itemId);
|
||||
});
|
||||
actions.appendChild(placeBtn);
|
||||
}
|
||||
const sellBtn = el('button', 'btn btn-ghost', 'Sell');
|
||||
sellBtn.style.cssText = 'padding:7px;font-size:.78rem';
|
||||
sellBtn.addEventListener('click', () => { net.send({ t: 'sell_item', itemId: slot.itemId }); audio.cash(); });
|
||||
actions.appendChild(sellBtn);
|
||||
card.appendChild(actions);
|
||||
grid.appendChild(card);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// house market helper reused by Home app & city hall dialog
|
||||
export function openHouseMarket() {
|
||||
openDialog('🏡 Property Market', (body) => {
|
||||
for (const h of HOUSES) {
|
||||
const owned = S.profile?.housesOwned.includes(h.id);
|
||||
const locked = (S.profile?.level ?? 1) < h.lvl;
|
||||
const row = el('div', 'stat-row', `<span><b>${h.name}</b><br><span class="dim" style="font-weight:600">${h.size[0]}×${h.size[1]} m · Level ${h.lvl}+</span></span>
|
||||
${owned ? '<span class="tx-in">Owned ✓</span>' : `<span style="display:flex;gap:10px;align-items:center"><b>${h.price.toLocaleString()} ◉</b><button class="btn btn-accent" style="padding:7px 14px">Buy</button></span>`}`);
|
||||
if (!owned && !locked) {
|
||||
(row.querySelector('button') as HTMLElement).addEventListener('click', () => net.send({ t: 'buy_house', houseId: h.id }));
|
||||
}
|
||||
body.appendChild(row);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
// LifeTown Online — chat panel: say (zone + bubble) & global channels.
|
||||
import { S, bus } from '../state';
|
||||
import { $, el } from './common';
|
||||
import { net } from '../net';
|
||||
import { audio } from '../audio';
|
||||
import { MAX_CHAT_LEN } from '../../../shared/data.mjs';
|
||||
|
||||
export function initChat() {
|
||||
const log = $('#chat-log');
|
||||
const input = $('#chat-input') as HTMLInputElement;
|
||||
const channel = $('#chat-channel') as HTMLSelectElement;
|
||||
|
||||
const addLine = (html: string, cls = '') => {
|
||||
const line = el('div', `chat-line ${cls}`, html);
|
||||
log.appendChild(line);
|
||||
while (log.children.length > 60) log.firstChild!.remove();
|
||||
log.scrollTop = log.scrollHeight;
|
||||
};
|
||||
(window as any).__chatSys = (msg: string) => addLine(msg, 'sys');
|
||||
bus.on('chat:sys', (m: string) => addLine(m, 'sys'));
|
||||
|
||||
bus.on('srv:chat', (m: any) => {
|
||||
const cls = m.channel === 'global' ? 'glob' : '';
|
||||
addLine(`<b>${m.from}${m.channel === 'global' ? ' 🌐' : ''}:</b> ${escapeHtml(m.text)}`, cls);
|
||||
if (m.from !== S.name) audio.pop();
|
||||
});
|
||||
|
||||
const send = () => {
|
||||
const text = input.value.trim();
|
||||
if (!text) return;
|
||||
net.send({ t: 'chat', text, channel: channel.value });
|
||||
input.value = '';
|
||||
};
|
||||
input.addEventListener('keydown', (e) => {
|
||||
e.stopPropagation();
|
||||
if (e.key === 'Enter') { send(); input.blur(); }
|
||||
if (e.key === 'Escape') input.blur();
|
||||
});
|
||||
channel.addEventListener('change', () => input.focus());
|
||||
}
|
||||
|
||||
export function escapeHtml(s: string) {
|
||||
return s.replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c] as string));
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
// LifeTown Online — UI helpers shared across modules.
|
||||
import { bus } from '../state';
|
||||
import { audio } from '../audio';
|
||||
|
||||
export function $(sel: string): HTMLElement { return document.querySelector(sel) as HTMLElement; }
|
||||
export function $$(sel: string): HTMLElement[] { return [...document.querySelectorAll(sel)] as any; }
|
||||
|
||||
export function el(tag: string, cls?: string, html?: string): HTMLElement {
|
||||
const e = document.createElement(tag);
|
||||
if (cls) e.className = cls;
|
||||
if (html != null) e.innerHTML = html;
|
||||
return e;
|
||||
}
|
||||
|
||||
let toastN = 0;
|
||||
export function toast(title: string, text = '', icon = '✨', kind: '' | 'gold' | 'coral' = '', onClick?: () => void) {
|
||||
const box = $('#toasts');
|
||||
const t = el('div', `toast ${kind}`);
|
||||
t.innerHTML = `<span class="t-icon">${icon}</span><div><b>${title}</b><p>${text}</p></div>`;
|
||||
if (onClick) t.addEventListener('click', () => { onClick(); t.remove(); });
|
||||
box.appendChild(t);
|
||||
toastN++;
|
||||
setTimeout(() => { t.remove(); toastN--; }, 5200);
|
||||
while (box.children.length > 4) box.firstChild!.remove();
|
||||
}
|
||||
bus.on('srv:notify', (m: any) => {
|
||||
toast(m.title || '', m.text || '', m.icon || '✨', m.title === 'Promotion!' ? 'gold' : '',
|
||||
m.action?.type === 'visit' ? () => netVisit(m.action.home) : undefined);
|
||||
});
|
||||
bus.on('srv:achievement', (m: any) => {
|
||||
audio.achievement();
|
||||
toast(`🏆 Achievement — ${m.name}`, m.desc, '🏆', 'gold');
|
||||
});
|
||||
|
||||
import { net } from '../net';
|
||||
function netVisit(home: string) { net.send({ t: 'zone', id: home }); }
|
||||
|
||||
export function openDialog(title: string, buildBody: (body: HTMLElement, close: () => void) => void) {
|
||||
const modal = $('#dialog');
|
||||
$('#dialog-title').textContent = title;
|
||||
const body = $('#dialog-body');
|
||||
body.innerHTML = '';
|
||||
modal.classList.remove('hidden');
|
||||
const close = () => modal.classList.add('hidden');
|
||||
($('#dialog-close') as HTMLElement).onclick = close;
|
||||
buildBody(body, close);
|
||||
audio.click();
|
||||
}
|
||||
export function closeDialog() { $('#dialog').classList.add('hidden'); }
|
||||
|
||||
export function fmtMoney(n: number): string {
|
||||
return n >= 1e6 ? `${(n / 1e6).toFixed(2)}M` : n.toLocaleString('en-US');
|
||||
}
|
||||
|
||||
/** emoji icon per furniture category */
|
||||
export const CAT_EMOJI: Record<string, string> = {
|
||||
bed: '🛏️', sofa: '🛋️', table: '🪵', chair: '🪑', tv: '📺', pc: '🖥️',
|
||||
fridge: '🧊', stove: '🍳', shower: '🚿', toilet: '🚽', sink: '🚰',
|
||||
wardrobe: '🗄️', shelf: '📚', plant: '🪴', decor: '🕯️', light: '💡',
|
||||
rug: '🧶', paint: '🖼️', elec: '🎮',
|
||||
};
|
||||
@@ -0,0 +1,218 @@
|
||||
// LifeTown Online — character creator with live 3D preview.
|
||||
import * as THREE from 'three';
|
||||
import { Avatar } from '../gfx/avatar';
|
||||
import { TRAITS } from '../../../shared/data.mjs';
|
||||
import { $, el } from './common';
|
||||
|
||||
const SKINS = ['#f7d7c4', '#f2c09a', '#eab98f', '#c98a5b', '#9c6644', '#71472c', '#5d3a21'];
|
||||
const HAIRC = ['#0f0d0b', '#2e1e12', '#4a2f1d', '#6b4226', '#8a5a37', '#b08968', '#d9b08c', '#c7b8ae', '#b53f3f', '#33539e', '#3f7d5a', '#d977c1'];
|
||||
const EYEC = ['#3a5a40', '#3d5a80', '#6b4226', '#2f2f2f', '#3f7d5a', '#7a3b8f'];
|
||||
const TOPC = ['#4f8fd9', '#ef6f91', '#67b56a', '#ffcf6b', '#8a63b8', '#f2f4f8', '#33384a', '#ef8a5f', '#54b8d9', '#b85c6e'];
|
||||
|
||||
export interface CreatorResult { name: string; appearance: any }
|
||||
|
||||
export class Creator {
|
||||
private app: any = {
|
||||
bodyType: 'average', height: 0.97, skin: SKINS[2], faceShape: 'round',
|
||||
hair: 'short', hairColor: HAIRC[3], eyes: 'round', eyeColor: EYEC[1],
|
||||
brows: 'natural', nose: 'button', mouth: 'smile',
|
||||
outfitTop: 'tee', outfitBottom: 'jeans', shoes: 'sneakers', accessory: 'none',
|
||||
topColor: TOPC[0], bottomColor: '#33415c',
|
||||
personality: ['Friendly'], voice: 'warm', age: 25,
|
||||
};
|
||||
private renderer!: THREE.WebGLRenderer;
|
||||
private scene = new THREE.Scene();
|
||||
private camera!: THREE.PerspectiveCamera;
|
||||
private avatarHolder = new THREE.Group();
|
||||
private avatar: Avatar | null = null;
|
||||
private raf = 0;
|
||||
private rebuildQueued = false;
|
||||
|
||||
constructor(private onDone: (r: CreatorResult) => void) {
|
||||
this.buildUI();
|
||||
this.buildPreview();
|
||||
}
|
||||
|
||||
// ---------------- preview ----------------
|
||||
private buildPreview() {
|
||||
const host = $('#creator-preview');
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.style.cssText = 'position:absolute;inset:0;width:100%;height:100%;';
|
||||
host.appendChild(canvas);
|
||||
this.renderer = new THREE.WebGLRenderer({ canvas, antialias: true, alpha: true });
|
||||
this.renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
|
||||
this.renderer.outputColorSpace = THREE.SRGBColorSpace;
|
||||
this.camera = new THREE.PerspectiveCamera(40, 1, 0.1, 50);
|
||||
|
||||
this.scene.add(new THREE.HemisphereLight(0xfff4e0, 0x8898c8, 1.15));
|
||||
const key = new THREE.DirectionalLight(0xffffff, 1.6); key.position.set(2.5, 4, 3);
|
||||
this.scene.add(key);
|
||||
const rim = new THREE.DirectionalLight(0xffd9a8, 0.8); rim.position.set(-3, 2.5, -2.5);
|
||||
this.scene.add(rim);
|
||||
// pedestal
|
||||
const disc = new THREE.Mesh(new THREE.CylinderGeometry(1.05, 1.15, 0.16, 36),
|
||||
new THREE.MeshStandardMaterial({ color: 0x35c4b5, roughness: .6 }));
|
||||
disc.position.y = -0.08;
|
||||
this.scene.add(disc);
|
||||
this.scene.add(this.avatarHolder);
|
||||
|
||||
const resize = () => {
|
||||
const w = host.clientWidth || 400, h = host.clientHeight || 500;
|
||||
this.renderer.setSize(w, h, false);
|
||||
this.camera.aspect = w / h; this.camera.updateProjectionMatrix();
|
||||
};
|
||||
window.addEventListener('resize', resize);
|
||||
resize();
|
||||
|
||||
let dragX = 0;
|
||||
canvas.addEventListener('pointerdown', e => { dragX = e.clientX; });
|
||||
canvas.addEventListener('pointermove', e => {
|
||||
if (e.buttons === 1) { this.avatarHolder.rotation.y += (e.clientX - dragX) * 0.011; dragX = e.clientX; }
|
||||
});
|
||||
|
||||
this.rebuildAvatar();
|
||||
const clock = new THREE.Clock();
|
||||
const loop = () => {
|
||||
this.raf = requestAnimationFrame(loop);
|
||||
if (!this.rebuildQueued && this.avatar) this.avatar.update(clock.getDelta());
|
||||
if (!this.rebuildQueued) this.avatarHolder.position.y = 0;
|
||||
this.renderer.render(this.scene, this.camera);
|
||||
};
|
||||
loop();
|
||||
}
|
||||
|
||||
private rebuildAvatar() {
|
||||
this.rebuildQueued = false;
|
||||
if (this.avatar) { this.avatarHolder.remove(this.avatar.group); this.avatar.dispose(); }
|
||||
this.avatar = new Avatar({ ...this.app, height: 1 }, undefined);
|
||||
// creator uses fixed display scale; height slider shown as body proportion
|
||||
this.avatar.group.scale.setScalar(0.98 + (this.app.height - 0.97) * 0.25);
|
||||
this.avatar.setAnim('idle');
|
||||
this.avatarHolder.add(this.avatar.group);
|
||||
}
|
||||
|
||||
private queueRebuild() {
|
||||
if (this.rebuildQueued) return;
|
||||
this.rebuildQueued = true;
|
||||
setTimeout(() => this.rebuildAvatar(), 30);
|
||||
}
|
||||
|
||||
// ---------------- UI ----------------
|
||||
private buildUI() {
|
||||
const set = (k: string, v: any, rebuild = true) => {
|
||||
this.app[k] = v;
|
||||
if (rebuild) this.queueRebuild(); else this.refreshChips();
|
||||
};
|
||||
|
||||
const chipsFor = (containerSel: string, options: [string, string][], key: string, cb?: () => void) => {
|
||||
const box = document.querySelector(containerSel) as HTMLElement;
|
||||
box.innerHTML = '';
|
||||
for (const [val, label] of options) {
|
||||
const c = el('div', 'chip', label);
|
||||
c.dataset.val = val;
|
||||
c.addEventListener('click', () => { set(key, val); cb?.(); audioClick(); });
|
||||
box.appendChild(c);
|
||||
}
|
||||
this.chipBoxes.push({ box, key });
|
||||
};
|
||||
this.chipBoxes = [];
|
||||
|
||||
$('#cr-name').addEventListener('input', () => { this.app.name = ($('#cr-name') as HTMLInputElement).value; });
|
||||
$('#cr-age').addEventListener('input', () => {
|
||||
this.app.age = +($('#cr-age') as HTMLInputElement).value;
|
||||
$('#cr-age-val').textContent = String(this.app.age);
|
||||
});
|
||||
$('#cr-height').addEventListener('input', () => {
|
||||
this.app.height = +($('#cr-height') as HTMLInputElement).value / 100;
|
||||
if (this.avatar) this.avatar.group.scale.setScalar(0.98 + (this.app.height - 0.97) * 0.25);
|
||||
});
|
||||
|
||||
chipsFor('[data-cr="bodyType"]', [['slim', 'Slim'], ['average', 'Average'], ['plump', 'Plump']], 'bodyType');
|
||||
chipsFor('[data-cr="hair"]', [['short', 'Short'], ['long', 'Long'], ['ponytail', 'Ponytail'], ['curly', 'Curly'], ['bob', 'Bob'], ['spiky', 'Spiky'], ['bald', 'Bald']], 'hair');
|
||||
chipsFor('[data-cr="eyes"]', [['round', 'Round'], ['wide', 'Wide'], ['sleepy', 'Sleepy']], 'eyes');
|
||||
chipsFor('[data-cr="outfitTop"]', [['tee', 'Tee'], ['shirt', 'Shirt'], ['hoodie', 'Hoodie'], ['jacket', 'Jacket']], 'outfitTop');
|
||||
chipsFor('[data-cr="outfitBottom"]', [['jeans', 'Jeans'], ['shorts', 'Shorts'], ['skirt', 'Skirt'], ['trousers', 'Trousers']], 'outfitBottom');
|
||||
chipsFor('[data-cr="shoes"]', [['sneakers', 'Sneakers'], ['boots', 'Boots'], ['loafers', 'Loafers']], 'shoes');
|
||||
chipsFor('[data-cr="accessory"]', [['none', 'None'], ['glasses', 'Glasses'], ['cap', 'Cap'], ['beanie', 'Beanie']], 'accessory');
|
||||
chipsFor('[data-cr="voice"]', [['warm', 'Warm'], ['bright', 'Bright'], ['deep', 'Deep'], ['soft', 'Soft']], 'voice');
|
||||
|
||||
// personality multi-select (max 3)
|
||||
const pbox = document.querySelector('[data-cr="personality"]') as HTMLElement;
|
||||
for (const t of TRAITS) {
|
||||
const c = el('div', 'chip', t);
|
||||
c.addEventListener('click', () => {
|
||||
const has = this.app.personality.includes(t);
|
||||
if (has) this.app.personality = this.app.personality.filter((x: string) => x !== t);
|
||||
else if (this.app.personality.length < 3) this.app.personality.push(t);
|
||||
else return;
|
||||
[...pbox.children].forEach(ch => (ch as HTMLElement).classList.toggle('sel', this.app.personality.includes(((ch as HTMLElement).dataset.trait))));
|
||||
audioClick();
|
||||
});
|
||||
(c as HTMLElement).dataset.trait = t;
|
||||
pbox.appendChild(c);
|
||||
}
|
||||
|
||||
// swatches
|
||||
const swatchFor = (sel: string, colors: string[], key: string) => {
|
||||
const box = document.querySelector(sel) as HTMLElement;
|
||||
for (const col of colors) {
|
||||
const s = el('div', 'swatch');
|
||||
(s as HTMLElement).style.background = col;
|
||||
s.dataset.val = col;
|
||||
s.addEventListener('click', () => { set(key, col); audioClick(); });
|
||||
box.appendChild(s);
|
||||
}
|
||||
this.swatchBoxes.push({ box, key });
|
||||
};
|
||||
this.swatchBoxes = [];
|
||||
swatchFor('[data-cr="skin"]', SKINS, 'skin');
|
||||
swatchFor('[data-cr="hairColor"]', HAIRC, 'hairColor');
|
||||
swatchFor('[data-cr="eyeColor"]', EYEC, 'eyeColor');
|
||||
swatchFor('[data-cr="topColor"]', TOPC, 'topColor');
|
||||
swatchFor('[data-cr="bottomColor"]', ['#33415c', '#4a4038', '#6b4226', '#22252e', '#7a3b8f', '#3f7d5a', '#b85c6e'], 'bottomColor');
|
||||
|
||||
// extra face detail chips
|
||||
chipsFor('[data-cr="brows"]', [['natural', 'Natural'], ['thick', 'Thick'], ['thin', 'Thin']], 'brows');
|
||||
chipsFor('[data-cr="nose"]', [['button', 'Button'], ['round', 'Round'], ['small', 'Small']], 'nose');
|
||||
chipsFor('[data-cr="mouth"]', [['smile', 'Smile'], ['neutral', 'Neutral'], ['grin', 'Grin']], 'mouth');
|
||||
this.refreshChips();
|
||||
|
||||
$('#btn-startlife').addEventListener('click', () => {
|
||||
const name = (($('#cr-name') as HTMLInputElement).value || '').trim() ||
|
||||
`Resident${Math.floor(Math.random() * 900 + 100)}`;
|
||||
cancelAnimationFrame(this.raf);
|
||||
this.onDone({
|
||||
name,
|
||||
appearance: {
|
||||
...this.app,
|
||||
faceShape: 'round',
|
||||
outfit: {
|
||||
top: this.app.outfitTop, bottom: this.app.outfitBottom, dress: null,
|
||||
shoes: this.app.shoes, accessory: this.app.accessory,
|
||||
topColor: this.app.topColor, bottomColor: this.app.bottomColor,
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private chipBoxes: { box: HTMLElement; key: string }[] = [];
|
||||
private swatchBoxes: { box: HTMLElement; key: string }[] = [];
|
||||
|
||||
private refreshChips() {
|
||||
for (const { box, key } of this.chipBoxes) {
|
||||
for (const ch of [...box.children] as HTMLElement[]) {
|
||||
ch.classList.toggle('sel', ch.dataset.val === String(this.app[key]));
|
||||
}
|
||||
}
|
||||
for (const { box, key } of this.swatchBoxes) {
|
||||
for (const ch of [...box.children] as HTMLElement[]) {
|
||||
ch.classList.toggle('sel', ch.dataset.val === String(this.app[key]));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function audioClick() {
|
||||
import('../audio').then(({ audio }) => audio.click());
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
// LifeTown Online — interaction wheel, minigames, work & sleep overlays.
|
||||
import { S, relLabel } from '../state';
|
||||
import { $, el, openDialog, closeDialog, toast } from './common';
|
||||
import { net } from '../net';
|
||||
import { INTERACTIONS, INTERACTION_BY_ID, JOBS } from '../../../shared/data.mjs';
|
||||
import { audio } from '../audio';
|
||||
|
||||
// ---------------- social wheel ----------------
|
||||
export function openSocialWheel(target: { type: 'npc' | 'player'; id: string; name: string }) {
|
||||
const key = target.type === 'npc' ? `npc:${target.id}` : `p:${target.name.toLowerCase()}`;
|
||||
const rel = S.profile?.relationships[key] || { f: 0, r: 0 };
|
||||
const wheel = $('#wheel');
|
||||
wheel.innerHTML = `<div style="font-weight:900;margin-bottom:6px">${target.name} <span class="dim" style="font-size:.78rem">· ${relLabel(rel.f)}${rel.partner ? ' 💞' : ''}</span></div>`;
|
||||
const groups = ['Friendly', 'Romantic', 'Funny', 'Negative'];
|
||||
for (const gname of groups) {
|
||||
const items = INTERACTIONS.filter(i => i.group === gname);
|
||||
if (!items.length) continue;
|
||||
wheel.appendChild(el('div', 'wheel-group', gname));
|
||||
for (const it of items) {
|
||||
const locked = rel.f < (it.req.f || 0) || rel.r < (it.req.r || 0);
|
||||
const row = el('div', 'wheel-item' + (locked ? ' locked' : ''),
|
||||
`<span>${it.label}${it.cost ? ` <small>◉${it.cost}</small>` : ''}</span><small>${locked ? needsLabel(it) : ''}</small>`);
|
||||
if (!locked) row.addEventListener('click', () => {
|
||||
wheel.classList.add('hidden');
|
||||
net.send({ t: 'social', targetType: target.type, targetId: target.id, action: it.id });
|
||||
audio.click();
|
||||
});
|
||||
wheel.appendChild(row);
|
||||
}
|
||||
}
|
||||
// position near center-bottom of screen
|
||||
wheel.classList.remove('hidden');
|
||||
const wr = wheel.getBoundingClientRect();
|
||||
wheel.style.left = Math.max(10, window.innerWidth / 2 - wr.width / 2) + 'px';
|
||||
wheel.style.top = Math.max(70, window.innerHeight * 0.3 - wr.height / 2) + 'px';
|
||||
}
|
||||
function needsLabel(it: any) {
|
||||
const f = it.req.f || 0, r = it.req.r || 0;
|
||||
return f > 0 ? `${f} friendship` : `${r} romance`;
|
||||
}
|
||||
export function closeWheel() { $('#wheel').classList.add('hidden'); }
|
||||
|
||||
// click outside closes
|
||||
window.addEventListener('pointerdown', (e) => {
|
||||
const w = $('#wheel');
|
||||
if (!w.classList.contains('hidden') && !w.contains(e.target as Node)) closeWheel();
|
||||
});
|
||||
|
||||
// ---------------- work overlay ----------------
|
||||
export function runWorkOverlay(onDone: () => void) {
|
||||
const p = S.profile;
|
||||
if (!p?.job) return toast('No job', 'Get hired at City Hall first.', '💼');
|
||||
const job = JOBS.find(j => j.id === p.job!.id)!;
|
||||
const ov = el('div');
|
||||
ov.style.cssText = 'position:absolute;inset:0;z-index:65;background:rgba(10,12,24,.55);display:flex;flex-direction:column;align-items:center;justify-content:center;gap:14px;backdrop-filter:blur(2px)';
|
||||
ov.innerHTML = `
|
||||
<div class="glass" style="padding:26px 40px;text-align:center">
|
||||
<div style="font-size:2.4rem">${jobEmoji(job.id)}</div>
|
||||
<b style="font-size:1.1rem">Working as ${job.ranks[p.job.rank]}…</b>
|
||||
<div style="width:260px;height:10px;border-radius:99px;background:rgba(255,255,255,.14);margin-top:12px;overflow:hidden">
|
||||
<i id="work-fill" style="display:block;height:100%;width:0%;background:linear-gradient(90deg,#35c4b5,#7fe3d7);transition:width .1s linear"></i>
|
||||
</div>
|
||||
<div class="dim" id="work-tip" style="margin-top:8px;font-size:.85rem">Stay focused!</div>
|
||||
</div>`;
|
||||
document.body.appendChild(ov);
|
||||
let t = 0;
|
||||
const total = 6;
|
||||
const iv = setInterval(() => {
|
||||
t += 0.1;
|
||||
(ov.querySelector('#work-fill') as HTMLElement).style.width = Math.min(100, (t / total) * 100) + '%';
|
||||
if (Math.random() < .06) (ov.querySelector('#work-tip') as HTMLElement).textContent =
|
||||
['Almost there…', 'Coffee break soon?', 'Nice rhythm!', 'The boss is watching 👀', 'You got this!'][Math.floor(Math.random() * 5)];
|
||||
if (t >= total) { clearInterval(iv); ov.remove(); onDone(); }
|
||||
}, 100);
|
||||
}
|
||||
export function jobEmoji(id: string) {
|
||||
return ({ barista: '☕', chef: '🍳', programmer: '💻', designer: '🎨', doctor: '🩺', teacher: '📚',
|
||||
police: '👮', engineer: '🔧', musician: '🎸', influencer: '📱', athlete: '🏅', artist: '🖌️' } as any)[id] || '💼';
|
||||
}
|
||||
|
||||
// ---------------- sleep ----------------
|
||||
export function sleepSequence(wakeText: string, hoursSlept: number, onWake: () => void) {
|
||||
const ov = el('div');
|
||||
ov.style.cssText = 'position:absolute;inset:0;z-index:66;background:#05060d;opacity:0;transition:opacity 1.2s ease;display:flex;align-items:center;justify-content:center;color:#9aa4c8;font-weight:800;font-size:1.1rem';
|
||||
ov.textContent = '😴 Sleeping…';
|
||||
document.body.appendChild(ov);
|
||||
requestAnimationFrame(() => ov.style.opacity = '1');
|
||||
setTimeout(() => {
|
||||
ov.innerHTML = `🌅 ${wakeText}`;
|
||||
ov.style.opacity = '0.92';
|
||||
setTimeout(() => {
|
||||
ov.style.opacity = '0';
|
||||
setTimeout(() => ov.remove(), 1100);
|
||||
onWake();
|
||||
}, 1400);
|
||||
}, 1200 + hoursSlept * 60);
|
||||
void hoursSlept;
|
||||
}
|
||||
|
||||
// ---------------- fishing minigame ----------------
|
||||
export function fishingGame(onFinish: (success: boolean, reward: number) => void) {
|
||||
openDialog('🎣 Fishing at Willow Pond', (body, close) => {
|
||||
body.innerHTML = `
|
||||
<p class="dim" style="margin-bottom:10px;font-size:.9rem">Tap STOP when the marker is in the green zone! 3 catches to win big.</p>
|
||||
<div id="fish-bar" style="position:relative;height:34px;border-radius:99px;background:rgba(255,255,255,.08);overflow:hidden;margin-bottom:14px">
|
||||
<div id="fish-zone" style="position:absolute;top:0;bottom:0;background:rgba(103,181,106,.5)"></div>
|
||||
<div id="fish-mark" style="position:absolute;top:-3px;width:7px;height:40px;border-radius:4px;background:#ffcf6b"></div>
|
||||
</div>
|
||||
<button class="btn btn-primary" id="fish-stop" style="width:100%">STOP!</button>
|
||||
<div class="dim" id="fish-score" style="text-align:center;margin-top:10px">Catches: 0 / 3</div>`;
|
||||
const bar = body.querySelector('#fish-bar') as HTMLElement;
|
||||
const zone = body.querySelector('#fish-zone') as HTMLElement;
|
||||
const mark = body.querySelector('#fish-mark') as HTMLElement;
|
||||
let pos = 0, dir = 1, speed = 62, score = 0, round = 0, running = true;
|
||||
const zw = 18 + Math.random() * 8;
|
||||
let zx = Math.random() * (100 - zw);
|
||||
zone.style.left = zx + '%'; zone.style.width = zw + '%';
|
||||
let last = performance.now();
|
||||
const tick = () => {
|
||||
if (!running) return;
|
||||
const now = performance.now(); const dt = (now - last) / 1000; last = now;
|
||||
pos += dir * speed * dt;
|
||||
if (pos > 100) { pos = 100; dir = -1; }
|
||||
if (pos < 0) { pos = 0; dir = 1; }
|
||||
mark.style.left = `calc(${pos}% - 3px)`;
|
||||
requestAnimationFrame(tick);
|
||||
};
|
||||
tick();
|
||||
(body.querySelector('#fish-stop') as HTMLElement).addEventListener('click', () => {
|
||||
const inZone = pos >= zx && pos <= zx + zw;
|
||||
round++;
|
||||
if (inZone) { score++; audio.cash(); } else audio.err();
|
||||
speed += 16; zw > 8 && (zx = Math.random() * (100 - zw));
|
||||
zone.style.width = Math.max(9, zw - round * 2) + '%';
|
||||
(body.querySelector('#fish-score') as HTMLElement).textContent = `Catches: ${score} / 3`;
|
||||
if (round >= 3) {
|
||||
running = false;
|
||||
const success = score >= 2;
|
||||
const reward = success ? 25 + score * 12 : 5;
|
||||
setTimeout(() => { close(); onFinish(success, reward); }, 500);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------- cooking minigame ----------------
|
||||
export function cookingGame(onDone: (good: boolean) => void) {
|
||||
const ingredients = ['🍅', '🧄', '🧅', '🫑', '🍄', '🧀'];
|
||||
const order = [0, 1, 2, 3].map(() => Math.floor(Math.random() * ingredients.length));
|
||||
openDialog('🍳 Cooking a Meal', (body, close) => {
|
||||
body.innerHTML = `
|
||||
<p class="dim" style="margin-bottom:10px;font-size:.9rem">Memorise the recipe order, then repeat it!</p>
|
||||
<div id="cook-show" style="display:flex;gap:8px;justify-content:center;font-size:2rem;margin-bottom:14px;min-height:56px"></div>
|
||||
<div id="cook-pads" style="display:grid;grid-template-columns:repeat(3,1fr);gap:8px"></div>
|
||||
<div class="dim" id="cook-msg" style="text-align:center;margin-top:10px">Watch closely…</div>`;
|
||||
const show = body.querySelector('#cook-show') as HTMLElement;
|
||||
show.textContent = order.map(i => ingredients[i]).join(' ');
|
||||
setTimeout(() => { show.textContent = '?'.repeat(order.length); (body.querySelector('#cook-msg') as HTMLElement).textContent = 'Now repeat it!'; }, 1800);
|
||||
const pads = body.querySelector('#cook-pads') as HTMLElement;
|
||||
const inputSeq: number[] = [];
|
||||
ingredients.forEach((ing, idx) => {
|
||||
const b = el('button', 'btn btn-ghost', ing);
|
||||
b.style.fontSize = '1.6rem';
|
||||
b.addEventListener('click', () => {
|
||||
inputSeq.push(idx);
|
||||
show.textContent = inputSeq.map(i => ingredients[i]).join(' ');
|
||||
audio.click();
|
||||
if (inputSeq.length === order.length) {
|
||||
const good = inputSeq.every((v, i) => v === order[i]);
|
||||
(body.querySelector('#cook-msg') as HTMLElement).textContent = good ? 'Delicious! 👨🍳' : 'Burnt… 🥴';
|
||||
setTimeout(() => { close(); onDone(good); }, 700);
|
||||
}
|
||||
});
|
||||
pads.appendChild(b);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------- confirm ----------------
|
||||
export function confirmBox(title: string, text: string, yesLabel: string, onYes: () => void) {
|
||||
openDialog(title, (body, close) => {
|
||||
body.appendChild(el('p', '', text)).style.marginBottom = '14px';
|
||||
const row = el('div'); row.style.cssText = 'display:flex;gap:8px';
|
||||
const y = el('button', 'btn btn-primary', yesLabel);
|
||||
y.addEventListener('click', () => { close(); onYes(); });
|
||||
const n = el('button', 'btn btn-ghost', 'Cancel');
|
||||
n.addEventListener('click', close);
|
||||
row.append(n, y);
|
||||
body.appendChild(row);
|
||||
});
|
||||
}
|
||||
void INTERACTION_BY_ID;
|
||||
@@ -0,0 +1,169 @@
|
||||
// LifeTown Online — HUD: top bar, need rings, mood, context panel, minimap, prompt.
|
||||
import { S, bus } from '../state';
|
||||
import { $, el, fmtMoney } from './common';
|
||||
import { NeedsSys } from '../systems';
|
||||
import { BUILDINGS, POIS, WEATHER_EMOJI, VENUES } from '../../../shared/data.mjs';
|
||||
|
||||
const NEED_META: [string, string, string][] = [
|
||||
['hunger', '🍔', '#ff8a5c'],
|
||||
['energy', '⚡', '#ffd166'],
|
||||
['hygiene', '🫧', '#6fd3e8'],
|
||||
['fun', '🎉', '#ef6f91'],
|
||||
['social', '💬', '#7f9df7'],
|
||||
['comfort', '🛋️', '#67b56a'],
|
||||
];
|
||||
|
||||
export function initHud() {
|
||||
// ---- need rings ----
|
||||
const rings = $('#need-rings');
|
||||
const ringEls: Record<string, { fg: SVGCircleElement; val: number }> = {};
|
||||
for (const [id, emoji, color] of NEED_META) {
|
||||
const d = el('div', 'ring');
|
||||
d.title = id;
|
||||
d.innerHTML = `
|
||||
<svg width="44" height="44" viewBox="0 0 44 44">
|
||||
<circle class="r-bg" cx="22" cy="22" r="18" fill="rgba(16,20,36,.65)" stroke-width="5"></circle>
|
||||
<circle class="r-fg" cx="22" cy="22" r="18" fill="none" stroke="${color}" stroke-width="5"
|
||||
stroke-dasharray="${2 * Math.PI * 18}" stroke-dashoffset="0"></circle>
|
||||
</svg>
|
||||
<span class="r-emoji">${emoji}</span>`;
|
||||
rings.appendChild(d);
|
||||
ringEls[id] = { fg: d.querySelector('.r-fg')!, val: 100 };
|
||||
}
|
||||
bus.on('needs:update', () => {
|
||||
for (const [id] of NEED_META) {
|
||||
const v = (S.needs as any)[id] ?? 50;
|
||||
const r = ringEls[id];
|
||||
if (Math.abs(r.val - v) > 1) {
|
||||
r.val = v;
|
||||
const c = 2 * Math.PI * 18;
|
||||
r.fg.style.strokeDashoffset = String(c * (1 - v / 100));
|
||||
r.fg.style.stroke = v < 22 ? '#ff5d5d' : '';
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// ---- mood chip ----
|
||||
bus.on('mood:update', (m: string) => {
|
||||
$('#mood-emoji').textContent = NeedsSys.emojiFor(m);
|
||||
$('#mood-label').textContent = m;
|
||||
});
|
||||
// initial paint
|
||||
setTimeout(() => bus.emit('mood:update', NeedsSys.mood()), 400);
|
||||
|
||||
// ---- top bar tickers ----
|
||||
const paintTop = () => {
|
||||
$('#tb-money').textContent = fmtMoney(S.money);
|
||||
const mins = Math.floor(S.time.minutes);
|
||||
const hh = String(Math.floor(mins / 60)).padStart(2, '0');
|
||||
const mm = String(mins % 60).padStart(2, '0');
|
||||
$('#tb-clock').textContent = `${hh}:${mm}`;
|
||||
$('#tb-day').textContent = `Day ${S.time.day}`;
|
||||
( $('#tb-weather') as HTMLElement).textContent = (WEATHER_EMOJI as any)[S.weather] || '☀️';
|
||||
$('#tb-zone').textContent = zoneLabel(S.zone);
|
||||
$('#tb-online').textContent = String(S.online);
|
||||
};
|
||||
bus.on('world:time', paintTop);
|
||||
bus.on('eco:money', () => { paintTop(); });
|
||||
bus.on('profile:update', paintTop);
|
||||
setInterval(paintTop, 2000);
|
||||
|
||||
// ---- context panel ----
|
||||
const renderContext = () => {
|
||||
const box = $('#cp-content');
|
||||
box.innerHTML = '';
|
||||
const items: [string, string, string][] = [];
|
||||
const p = S.profile;
|
||||
if (p?.job) {
|
||||
items.push(['💼', `Career: ${p.job.id}`, 'Tap Phone → Jobs to check progress']);
|
||||
} else {
|
||||
items.push(['💼', 'No job yet', 'Visit City Hall desk in town']);
|
||||
}
|
||||
const mins = S.time.minutes;
|
||||
if (mins >= 1200 && mins < 1320) items.push(['🎶', 'Plaza Dance Night', 'Until 22:00 · at the stage']);
|
||||
else if (mins >= 540 && mins < 660) items.push(['🍎', 'Morning Market', 'Until 11:00 · MapleMart']);
|
||||
const lowNeed = NEED_META.find(([id]) => (S.needs as any)[id] < 25);
|
||||
if (lowNeed) items.push([lowNeed[1], `Low ${lowNeed[0]}!`, 'Take care of yourself']);
|
||||
const friendsNearby = Object.entries(p?.relationships || {}).filter(([k, v]) => k.startsWith('npc:') && v.f >= 25).length;
|
||||
items.push(['🌱', `${friendsNearby} townsfolk friendships`, 'Chat daily to grow bonds']);
|
||||
for (const [ico, t, sub] of items.slice(0, 4)) {
|
||||
box.appendChild(el('div', 'ctx-item', `<span class="ci-ico">${ico}</span><div>${t}<small>${sub}</small></div>`));
|
||||
}
|
||||
};
|
||||
setInterval(renderContext, 5000);
|
||||
setTimeout(renderContext, 800);
|
||||
|
||||
// ---- minimap ----
|
||||
const canvas = $('#minimap') as HTMLCanvasElement;
|
||||
const g = canvas.getContext('2d')!;
|
||||
const W2M = 180 / 160; // world -80..80 → px
|
||||
const drawMini = (players: { x: number; z: number; me?: boolean }[]) => {
|
||||
if (S.zone !== 'town') { drawHomeMini(g); return; }
|
||||
g.clearRect(0, 0, 180, 180);
|
||||
g.fillStyle = '#1b2438'; g.fillRect(0, 0, 180, 180);
|
||||
const X = (x: number) => 90 + x * W2M;
|
||||
const Z = (z: number) => 96 + z * W2M;
|
||||
// road
|
||||
g.fillStyle = '#39415a'; g.fillRect(0, Z(-4), 180, 8 * W2M);
|
||||
// plaza
|
||||
g.fillStyle = '#4a4550'; g.beginPath(); g.arc(X(0), Z(-26), 14 * W2M, 0, 7); g.fill();
|
||||
// park
|
||||
g.fillStyle = '#2e5231'; g.fillRect(X(42 - 22), Z(-30 - 20), 44 * W2M, 40 * W2M);
|
||||
g.fillStyle = '#3a6ea5'; g.beginPath(); g.arc(X(46), Z(-40), 6 * W2M, 0, 7); g.fill();
|
||||
// buildings
|
||||
g.fillStyle = '#8a7a5f';
|
||||
for (const b of BUILDINGS) g.fillRect(X(b.x - b.w / 2), Z(b.z - b.d / 2), b.w * W2M, b.d * W2M);
|
||||
// POIs
|
||||
g.fillStyle = '#35c4b5';
|
||||
for (const p of POIS) { g.beginPath(); g.arc(X(p.x), Z(p.z), 2.2, 0, 7); g.fill(); }
|
||||
// players
|
||||
for (const pl of players) {
|
||||
g.fillStyle = pl.me ? '#ffd166' : '#ff8a5c';
|
||||
g.beginPath(); g.arc(X(pl.x), Z(pl.z), pl.me ? 3.4 : 2.6, 0, 7); g.fill();
|
||||
}
|
||||
};
|
||||
let lastPlayers: any[] = [];
|
||||
bus.on('mini:positions', (ps: any[]) => { lastPlayers = ps; });
|
||||
setInterval(() => drawMini(lastPlayers), 450);
|
||||
|
||||
// audio + fullscreen buttons
|
||||
$('#btn-audio').addEventListener('click', () => {
|
||||
import('../audio').then(({ audio }) => {
|
||||
audio.setEnabled(!audio.enabled);
|
||||
($('#btn-audio') as HTMLElement).textContent = audio.enabled ? '🔊' : '🔇';
|
||||
});
|
||||
});
|
||||
$('#btn-fullscreen').addEventListener('click', () => {
|
||||
if (document.fullscreenElement) document.exitFullscreen();
|
||||
else document.documentElement.requestFullscreen?.();
|
||||
});
|
||||
}
|
||||
|
||||
function drawHomeMini(g: CanvasRenderingContext2D) {
|
||||
g.clearRect(0, 0, 180, 180);
|
||||
g.fillStyle = '#241f33'; g.fillRect(0, 0, 180, 180);
|
||||
g.fillStyle = '#4a3f56'; g.fillRect(30, 30, 120, 110);
|
||||
const label = zoneLabel(S.zone).toUpperCase();
|
||||
g.fillStyle = '#35c4b5'; g.font = `700 ${label.length > 12 ? 11 : 13}px Nunito`; g.textAlign = 'center';
|
||||
g.fillText(label.slice(0, 22), 90, 92);
|
||||
}
|
||||
|
||||
/** Friendly name for the current zone id */
|
||||
export function zoneLabel(zone: string): string {
|
||||
if (zone === 'town') return 'Maple Court';
|
||||
if (zone.startsWith('venue:')) return VENUES[zone]?.name || 'Venue';
|
||||
return zone === `home:${S.name.toLowerCase()}` ? 'Your Home' : "Friend's Home";
|
||||
}
|
||||
|
||||
// prompt helpers used by main
|
||||
let promptHideTimer: any;
|
||||
export function showPrompt(label: string, key = 'E') {
|
||||
clearTimeout(promptHideTimer);
|
||||
$('#prompt').classList.remove('hidden');
|
||||
$('#prompt-key').textContent = key;
|
||||
$('#prompt-text').textContent = label;
|
||||
}
|
||||
export function hidePrompt() {
|
||||
clearTimeout(promptHideTimer);
|
||||
promptHideTimer = setTimeout(() => $('#prompt').classList.add('hidden'), 60);
|
||||
}
|
||||
@@ -0,0 +1,334 @@
|
||||
// LifeTown Online — in-game smartphone: Messages, Contacts, Map, Jobs, Bank,
|
||||
// Social feed, Calendar, Home. Original app design.
|
||||
import { S, bus, relLabel } from '../state';
|
||||
import { $, el } from './common';
|
||||
import { net } from '../net';
|
||||
import { NPCS, JOBS, HOUSES, XP_PER_RANK, AREA_POINTS } from '../../../shared/data.mjs';
|
||||
import { BUILDINGS, POIS } from '../../../shared/data.mjs';
|
||||
import { escapeHtml } from './chat';
|
||||
|
||||
type Msg = { from: string; text: string; channel: string; ts: number };
|
||||
const history: Msg[] = [];
|
||||
bus.on('srv:chat', (m: any) => { history.push(m as Msg); if (history.length > 120) history.shift(); });
|
||||
|
||||
interface Tx { label: string; amt: number; ts: number }
|
||||
const txs: Tx[] = [];
|
||||
let lastMoney: number | null = null;
|
||||
setInterval(() => {
|
||||
if (lastMoney != null && S.profile && S.money !== lastMoney) {
|
||||
txs.unshift({ label: S.money > lastMoney ? 'Income' : 'Purchase', amt: S.money - lastMoney, ts: Date.now() });
|
||||
if (txs.length > 30) txs.pop();
|
||||
}
|
||||
lastMoney = S.money;
|
||||
}, 900);
|
||||
|
||||
export const phoneState = { open: false, app: 'messages' };
|
||||
|
||||
export function initPhone(openWorkCheck: () => boolean) {
|
||||
const dock = $('#phone-dock');
|
||||
const apps: [string, string, () => void][] = [
|
||||
['messages', '💬', () => renderMessages()],
|
||||
['contacts', '👥', () => renderContacts()],
|
||||
['map', '🗺️', () => renderMap()],
|
||||
['jobs', '💼', () => renderJobs(openWorkCheck)],
|
||||
['bank', '🏦', () => renderBank()],
|
||||
['social', '📸', () => renderSocial()],
|
||||
['calendar', '📅', () => renderCalendar()],
|
||||
['home', '🏠', () => renderHomeApp()],
|
||||
];
|
||||
for (const [id, icon, open] of apps) {
|
||||
const b = el('button', 'papp-btn', icon);
|
||||
b.dataset.app = id;
|
||||
b.addEventListener('click', () => { setApp(id); open(); audioClick(); });
|
||||
dock.appendChild(b);
|
||||
}
|
||||
|
||||
// top bar clock
|
||||
setInterval(() => {
|
||||
const mins = Math.floor(S.time.minutes);
|
||||
$('#phone-clock').textContent =
|
||||
`${String(Math.floor(mins / 60)).padStart(2, '0')}:${String(mins % 60).padStart(2, '0')} · D${S.time.day}`;
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
export function togglePhone(force?: boolean) {
|
||||
const p = $('#phone');
|
||||
const show = force ?? p.classList.contains('hidden');
|
||||
p.classList.toggle('hidden', !show);
|
||||
phoneState.open = show;
|
||||
if (show) {
|
||||
(document.querySelector(`.papp-btn[data-app="${phoneState.app}"]`) as HTMLElement)?.click();
|
||||
if (!phoneState.app) renderMessages();
|
||||
}
|
||||
}
|
||||
|
||||
function setApp(id: string) {
|
||||
phoneState.app = id;
|
||||
document.querySelectorAll('.papp-btn').forEach(b => b.classList.toggle('on', (b as HTMLElement).dataset.app === id));
|
||||
}
|
||||
|
||||
function screen() { return $('#phone-screen'); }
|
||||
|
||||
// ---------------- apps ----------------
|
||||
function renderMessages() {
|
||||
const box = screen();
|
||||
box.innerHTML = `<div class="papp-title">💬 Messages</div>`;
|
||||
const list = el('div');
|
||||
if (!history.length) list.appendChild(el('div', 'dim', 'No messages yet — say hi in town or on Global!'));
|
||||
for (const m of [...history].reverse().slice(0, 40)) {
|
||||
list.appendChild(el('div', 'list-card',
|
||||
`<div class="contact-face" style="background:#31405e">${m.from.slice(0, 1).toUpperCase()}</div>
|
||||
<div><b>${escapeHtml(m.from)}</b> <span class="dim">${m.channel === 'global' ? '🌐' : ''}</span><br>${escapeHtml(m.text)}</div>`));
|
||||
}
|
||||
box.appendChild(list);
|
||||
}
|
||||
|
||||
function relEntryOf(key: string) { return S.profile?.relationships[key]; }
|
||||
|
||||
function renderContacts() {
|
||||
const box = screen();
|
||||
box.innerHTML = `<div class="papp-title">👥 Contacts</div>`;
|
||||
for (const npc of NPCS) {
|
||||
const r = relEntryOf(`npc:${npc.id}`) || { f: 0, r: 0 };
|
||||
const face = `#${THREEless(npc.color)}`;
|
||||
box.appendChild(el('div', 'list-card', `
|
||||
<div class="contact-face" style="background:${face}">${npc.name.slice(0, 1)}</div>
|
||||
<div style="flex:1">
|
||||
<b>${npc.name}</b> <span class="dim">· ${npc.role}</span><br>
|
||||
<span class="dim">${relLabel(r.f)}${r.partner ? ' 💞 Partner' : ''}</span>
|
||||
<div class="rel-bar"><i style="width:${r.f}%"></i></div>
|
||||
${r.r > 5 ? `<div class="rel-bar"><i class="rom" style="width:${r.r}%"></i></div>` : ''}
|
||||
</div>`));
|
||||
}
|
||||
// online players
|
||||
const players = Object.entries(S.profile?.relationships || {}).filter(([k]) => k.startsWith('p:'));
|
||||
if (players.length) {
|
||||
box.appendChild(el('div', 'papp-title', '🌐 Players met'));
|
||||
for (const [k, v] of players) {
|
||||
const nm = k.slice(2);
|
||||
box.appendChild(el('div', 'list-card', `
|
||||
<div class="contact-face" style="background:#4a5a8a">${nm.slice(0, 1).toUpperCase()}</div>
|
||||
<div style="flex:1"><b>${escapeHtml(nm)}</b><br><span class="dim">${relLabel(v.f)}${v.partner ? ' 💞' : ''}</span>
|
||||
<div class="rel-bar"><i style="width:${v.f}%"></i></div></div>
|
||||
<button class="btn btn-ghost" data-invite="${nm}" style="padding:6px 12px;font-size:.75rem">Invite</button>`));
|
||||
}
|
||||
box.querySelectorAll('[data-invite]').forEach(b => b.addEventListener('click', () => {
|
||||
net.send({ t: 'invite', to: (b as HTMLElement).dataset.invite });
|
||||
}));
|
||||
}
|
||||
}
|
||||
// tiny helper to hex a number without importing three
|
||||
function THREEless(n: number) { return n.toString(16).padStart(6, '0'); }
|
||||
|
||||
let mapCanvasDraw: (() => void) | null = null;
|
||||
function renderMap() {
|
||||
const box = screen();
|
||||
box.innerHTML = `<div class="papp-title">🗺️ Town Map</div>`;
|
||||
const c = document.createElement('canvas');
|
||||
c.width = 300; c.height = 300; c.style.cssText = 'width:100%;border-radius:16px;background:#141c30';
|
||||
box.appendChild(c);
|
||||
const legend = el('div', 'dim', '');
|
||||
legend.style.cssText = 'font-size:.78rem;margin-top:8px;line-height:1.6';
|
||||
legend.innerHTML = POIS.map(p => `${poiIcon(p.kind)} ${p.name}`).join('<br>');
|
||||
box.appendChild(legend);
|
||||
mapCanvasDraw = () => drawTownMap(c);
|
||||
mapCanvasDraw();
|
||||
}
|
||||
|
||||
export function refreshMapIfOpen() { mapCanvasDraw?.(); }
|
||||
|
||||
function poiIcon(kind: string) {
|
||||
return ({ eat: '🍽️', jobs: '🏛️', exercise: '🏋️', shop: '🛒', fish: '🎣', stage: '🎶', sit: '🪑', fountain: '⛲', swim: '🏊' } as any)[kind] || '📍';
|
||||
}
|
||||
|
||||
function drawTownMap(c: HTMLCanvasElement) {
|
||||
const g = c.getContext('2d')!;
|
||||
g.clearRect(0, 0, 300, 300);
|
||||
g.fillStyle = '#16203a'; g.fillRect(0, 0, 300, 300);
|
||||
const X = (x: number) => 150 + x * 1.75;
|
||||
const Z = (z: number) => 158 + z * 1.75;
|
||||
g.fillStyle = '#3a4462'; g.fillRect(10, Z(-4), 280, 14);
|
||||
g.fillStyle = '#454f70'; g.beginPath(); g.arc(X(0), Z(-26), 25, 0, 7); g.fill();
|
||||
g.fillStyle = '#2e5231'; g.fillRect(X(20), Z(-50), 44 * 1.75 * 0.9, 40 * 1.75 * 0.85);
|
||||
g.fillStyle = '#3a6ea5'; g.beginPath(); g.arc(X(46), Z(-40), 11, 0, 7); g.fill();
|
||||
for (const b of BUILDINGS) {
|
||||
g.fillStyle = '#' + b.color.toString(16).padStart(6, '0');
|
||||
g.fillRect(X(b.x - b.w / 2), Z(b.z - b.d / 2), b.w * 1.6, b.d * 1.6);
|
||||
}
|
||||
g.font = '700 11px Nunito'; g.fillStyle = '#fff';
|
||||
for (const p of POIS) {
|
||||
g.beginPath(); g.fillStyle = '#35c4b5'; g.arc(X(p.x), Z(p.z), 5, 0, 7); g.fill();
|
||||
g.fillStyle = '#dbe7ff';
|
||||
const short = p.name.split(' ')[0];
|
||||
g.fillText(short, X(p.x) - g.measureText(short).width / 2, Z(p.z) - 9);
|
||||
}
|
||||
// me
|
||||
if ((window as any).__myPosForMap) {
|
||||
const mp = (window as any).__myPosForMap;
|
||||
g.fillStyle = '#ffd166'; g.beginPath(); g.arc(X(mp.x), Z(mp.z), 7, 0, 7); g.fill();
|
||||
g.strokeStyle = '#ffd166aa'; g.lineWidth = 3; g.beginPath(); g.arc(X(mp.x), Z(mp.z), 12, 0, 7); g.stroke();
|
||||
}
|
||||
}
|
||||
|
||||
function renderJobs(canWork: () => boolean) {
|
||||
const box = screen();
|
||||
box.innerHTML = `<div class="papp-title">💼 Careers</div>`;
|
||||
const job = S.profile?.job;
|
||||
if (job) {
|
||||
const def = JOBS.find(j => j.id === job.id)!;
|
||||
const nextXp = XP_PER_RANK[Math.min(job.rank + 1, XP_PER_RANK.length - 1)];
|
||||
const prevXp = XP_PER_RANK[job.rank] ?? 0;
|
||||
const pct = Math.min(100, ((job.xp - prevXp) / Math.max(1, nextXp - prevXp)) * 100);
|
||||
const card = el('div', 'post-card', `
|
||||
<b>${def.ranks[job.rank]}</b> <span class="dim">· ${def.title}</span><br>
|
||||
<span class="dim">Pay ≈ ${Math.round(def.basePay * (1 + job.rank * .55))} TC / shift · Hours ${def.hours[0]}:00–${def.hours[1]}:00</span>
|
||||
<div class="rel-bar"><i style="width:${pct}%"></i></div>
|
||||
<div class="dim" style="font-size:.72rem;margin-top:3px">Next: ${def.ranks[Math.min(job.rank + 1, def.ranks.length - 1)]}</div>`);
|
||||
box.appendChild(card);
|
||||
const workBtn = el('button', canWork() ? 'btn btn-accent' : 'btn btn-ghost', canWork() ? '🔨 Work a shift now' : '🔨 Work (go to your workplace)');
|
||||
workBtn.style.cssText = 'width:100%;margin-bottom:14px';
|
||||
workBtn.addEventListener('click', async () => {
|
||||
if (!canWork()) {
|
||||
const { toast } = await import('./common');
|
||||
return toast('Wrong place', `Head to the ${def.title}'s workplace on the map, then work.`, '📍', 'coral');
|
||||
}
|
||||
const { runWorkOverlay } = await import('./dialogs');
|
||||
togglePhone(false);
|
||||
runWorkOverlay(() => net.send({ t: 'job_work' }));
|
||||
});
|
||||
box.appendChild(workBtn);
|
||||
} else {
|
||||
box.appendChild(el('div', 'dim', 'You are unemployed. Visit the City Hall desk in Maple Court, or pick any career below:'));
|
||||
}
|
||||
for (const j of JOBS) {
|
||||
const active = job?.id === j.id;
|
||||
const c = el('div', 'list-card', `
|
||||
<div class="contact-face" style="background:#31405e">${j.title.slice(0, 1)}</div>
|
||||
<div style="flex:1"><b>${j.title}</b><br><span class="dim">${j.ranks[0]} → ${j.ranks[j.ranks.length - 1]} · base ${j.basePay} TC</span></div>
|
||||
${active ? '<span class="tx-in">Current</span>' : `<button class="btn btn-ghost" style="padding:6px 12px;font-size:.75rem">Apply</button>`}`);
|
||||
if (!active) {
|
||||
(c.querySelector('button') as HTMLElement).addEventListener('click', () => {
|
||||
net.send({ t: 'job_select', jobId: j.id });
|
||||
setTimeout(renderJobs.bind(null, canWork), 400);
|
||||
});
|
||||
}
|
||||
box.appendChild(c);
|
||||
}
|
||||
}
|
||||
|
||||
function renderBank() {
|
||||
const box = screen();
|
||||
box.innerHTML = `
|
||||
<div class="papp-title">🏦 TownBank</div>
|
||||
<div class="post-card" style="text-align:center">
|
||||
<div class="dim" style="font-size:.78rem">BALANCE</div>
|
||||
<div style="font-size:1.9rem;font-weight:900;color:#ffcf6b" id="bank-balance">${S.money.toLocaleString()} ◉</div>
|
||||
<div class="dim" style="font-size:.76rem">Level ${S.profile?.level ?? 1} resident</div>
|
||||
</div>`;
|
||||
const list = el('div');
|
||||
if (!txs.length) list.appendChild(el('div', 'dim', 'No transactions yet.'));
|
||||
for (const t of txs.slice(0, 20)) {
|
||||
list.appendChild(el('div', 'stat-row',
|
||||
`<span>${t.label}<br><span class="dim" style="font-size:.72rem">${new Date(t.ts).toLocaleTimeString()}</span></span>
|
||||
<span class="${t.amt >= 0 ? 'tx-in' : 'tx-out'}">${t.amt >= 0 ? '+' : ''}${t.amt} ◉</span>`));
|
||||
}
|
||||
box.appendChild(list);
|
||||
bus.once('eco:money', () => { if (phoneState.open && phoneState.app === 'bank') renderBank(); });
|
||||
}
|
||||
|
||||
function renderSocial() {
|
||||
const box = screen();
|
||||
box.innerHTML = `<div class="papp-title">📸 LifeFeed</div>`;
|
||||
const composer = el('div', 'post-card');
|
||||
composer.innerHTML = `<input id="post-text" placeholder="Share something about your life…" maxlength="140"/>
|
||||
<div style="display:flex;gap:8px;margin-top:8px">
|
||||
<button class="btn btn-accent" id="btn-post" style="flex:1;padding:9px">Post</button>
|
||||
<button class="btn btn-ghost" id="btn-snap" style="padding:9px">📷 Screenshot</button>
|
||||
</div>`;
|
||||
box.appendChild(composer);
|
||||
const feedKey = 'lt.posts';
|
||||
const getPosts = (): any[] => { try { return JSON.parse(localStorage.getItem(feedKey) || '[]'); } catch { return []; } };
|
||||
const addPost = (p: any) => { const arr = getPosts(); arr.unshift(p); localStorage.setItem(feedKey, JSON.stringify(arr.slice(0, 30))); };
|
||||
composer.querySelector('#btn-post')!.addEventListener('click', () => {
|
||||
const txt = (composer.querySelector('#post-text') as HTMLInputElement).value.trim();
|
||||
if (!txt) return;
|
||||
addPost({ name: S.name, text: txt, ts: Date.now(), likes: 0 });
|
||||
(composer.querySelector('#post-text') as HTMLInputElement).value = '';
|
||||
renderSocial();
|
||||
});
|
||||
composer.querySelector('#btn-snap')!.addEventListener('click', () => {
|
||||
try {
|
||||
const gl = document.querySelector('#gl') as HTMLCanvasElement;
|
||||
const img = gl.toDataURL('image/jpeg', 0.6);
|
||||
addPost({ name: S.name, text: '📸 Snapshot from ' + (S.zone === 'town' ? 'Maple Court' : 'my home'), img, ts: Date.now(), likes: 0 });
|
||||
renderSocial();
|
||||
} catch { alertMsg('Screenshot failed — try again.'); }
|
||||
});
|
||||
for (const p of getPosts()) {
|
||||
const card = el('div', 'post-card', `
|
||||
<div class="post-head"><div class="contact-face" style="background:#7a5a8a">${escapeHtml((p.name || '?').slice(0, 1))}</div>
|
||||
<div>${escapeHtml(p.name)}<br><span class="dim" style="font-weight:600;font-size:.7rem">${timeAgo(p.ts)}</span></div></div>
|
||||
<div style="font-size:.9rem">${escapeHtml(p.text || '')}</div>
|
||||
${p.img ? `<img class="post-img" src="${p.img}"/>` : ''}
|
||||
<div class="post-actions"><span>❤️ ${p.likes}</span><span>💬 Reply</span><span>↗ Share</span></div>`);
|
||||
(card.querySelector('.post-actions span') as HTMLElement).addEventListener('click', () => { p.likes++; card.querySelector('.post-actions span')!.textContent = `❤️ ${p.likes}`; });
|
||||
box.appendChild(card);
|
||||
}
|
||||
}
|
||||
|
||||
function renderCalendar() {
|
||||
const box = screen();
|
||||
box.innerHTML = `<div class="papp-title">📅 Town Calendar</div>`;
|
||||
const events: [number, string, string, string][] = [
|
||||
[540, 'Morning Market', 'MapleMart plaza stalls — fresh deals daily', '🍎'],
|
||||
[720, 'Lunch Rush at Taco Comet', 'Chef Rosa cooks up specials', '🌮'],
|
||||
[1200, 'Plaza Dance Night', 'Meet at the stage — everyone dances!', '🎶'],
|
||||
];
|
||||
const mins = S.time.minutes;
|
||||
for (const [startHm, title, desc, ico] of events) {
|
||||
const active = mins >= startHm && mins < startHm + 120;
|
||||
box.appendChild(el('div', 'list-card' + (active ? '' : ''), `
|
||||
<div class="contact-face" style="background:${active ? '#35c4b5' : '#31405e'};opacity:${active ? 1 : .65}">${ico}</div>
|
||||
<div style="flex:1"><b>${title}</b> <span class="dim">@ ${String(Math.floor(startHm / 60)).padStart(2, '0')}:00</span><br><span class="dim">${desc}</span></div>
|
||||
${active ? '<span class="tx-in">NOW</span>' : ''}`));
|
||||
}
|
||||
box.appendChild(el('div', 'dim', `Day ${S.time.day} in LifeTown · seasons & festivals coming soon`));
|
||||
}
|
||||
|
||||
function renderHomeApp() {
|
||||
const box = screen();
|
||||
box.innerHTML = `<div class="papp-title">🏠 Home</div>`;
|
||||
const tier = HOUSES.find(h => h.id === S.profile?.houseTier) || HOUSES[0];
|
||||
box.appendChild(el('div', 'post-card', `<b>${tier.name}</b><br><span class="dim">Your current home · ${tier.size[0]}×${tier.size[1]} m</span>`));
|
||||
const row = el('div', '', ''); row.style.cssText = 'display:flex;gap:8px;margin-bottom:12px';
|
||||
const goHome = el('button', 'btn btn-accent', '🏠 Teleport Home'); goHome.style.flex = '1';
|
||||
goHome.addEventListener('click', () => net.send({ t: 'zone', id: `home:${S.name.toLowerCase()}` }));
|
||||
const goOut = el('button', 'btn btn-primary', '🌆 To Town'); goOut.style.flex = '1';
|
||||
goOut.addEventListener('click', () => net.send({ t: 'zone', id: 'town' }));
|
||||
row.append(goHome, goOut);
|
||||
box.appendChild(row);
|
||||
|
||||
box.appendChild(el('div', 'papp-title', '🏡 Property Market'));
|
||||
for (const h of HOUSES) {
|
||||
const owned = S.profile?.housesOwned.includes(h.id);
|
||||
const locked = (S.profile?.level ?? 1) < h.lvl;
|
||||
const c = el('div', 'list-card', `
|
||||
<div class="contact-face" style="background:#31405e">🏘️</div>
|
||||
<div style="flex:1"><b>${h.name}</b><br><span class="dim">${h.price === 0 ? 'Starter home' : h.price.toLocaleString() + ' ◉ · Level ' + h.lvl + '+'}</span></div>
|
||||
${owned ? '<span class="tx-in">Owned ✓</span>' : locked ? '<span class="dim">🔒</span>' : `<button class="btn btn-accent" style="padding:7px 13px;font-size:.78rem">Buy</button>`}`);
|
||||
if (!owned && !locked) {
|
||||
(c.querySelector('button') as HTMLElement).addEventListener('click', () => net.send({ t: 'buy_house', houseId: h.id }));
|
||||
}
|
||||
box.appendChild(c);
|
||||
}
|
||||
}
|
||||
|
||||
function timeAgo(ts: number) {
|
||||
const d = Math.floor((Date.now() - ts) / 1000);
|
||||
if (d < 60) return 'just now';
|
||||
if (d < 3600) return Math.floor(d / 60) + ' min ago';
|
||||
return Math.floor(d / 3600) + ' h ago';
|
||||
}
|
||||
function alertMsg(t: string) { import('./common').then(({ toast }) => toast('Oops', t, '⚠️')); }
|
||||
function audioClick() { import('../audio').then(({ audio }) => audio.click()); }
|
||||
void AREA_POINTS;
|
||||
@@ -0,0 +1,301 @@
|
||||
// LifeTown Online — world/actor manager: local player, remote players, NPCs,
|
||||
// interpolation, name-tags and speech bubbles.
|
||||
import * as THREE from 'three';
|
||||
import { Avatar, npcAppearance } from './gfx/avatar';
|
||||
import { Engine } from './gfx/engine';
|
||||
import { Town } from './gfx/town';
|
||||
import { Interior } from './gfx/interior';
|
||||
import { S, bus } from './state';
|
||||
import { net } from './net';
|
||||
import { NPCS, VENUES } from '../../shared/data.mjs';
|
||||
import { audio } from './audio';
|
||||
|
||||
interface Actor {
|
||||
id: string; name: string; appearance: any;
|
||||
avatar: Avatar; isNpc: boolean;
|
||||
tx: number; tz: number; tRy: number;
|
||||
overrideAnimUntil?: number; overrideAnim?: string;
|
||||
tagEl: HTMLElement;
|
||||
}
|
||||
|
||||
const V = new THREE.Vector3();
|
||||
|
||||
export class World {
|
||||
actors = new Map<string, Actor>();
|
||||
town: Town | null = null;
|
||||
interior: Interior | null = null;
|
||||
interiorChangedHook: (() => void) | null = null;
|
||||
|
||||
constructor(public engine: Engine) {
|
||||
bus.on('srv:playerJoin', (m: any) => this.join(m.id, m.name, m.appearance));
|
||||
bus.on('srv:roster', (m: any) => {
|
||||
for (const p of m.players || []) this.join(p.id, p.name, p.appearance);
|
||||
});
|
||||
bus.on('srv:playerLeave', (m: any) => this.remove(m.id));
|
||||
bus.on('srv:snapshot', (m: any) => this.snapshot(m));
|
||||
bus.on('srv:bubble', (m: any) => this.bubble(m.id, m.text));
|
||||
bus.on('srv:emote', (m: any) => this.emote(m.id, m.emote));
|
||||
bus.on('srv:socialFx', (m: any) => this.socialFx(m));
|
||||
bus.on('world:clearActors', () => this.clearRemote());
|
||||
}
|
||||
|
||||
// ---------------- lifecycle ----------------
|
||||
spawnMe(appearance: any) {
|
||||
this.remove('me');
|
||||
const avatar = new Avatar(appearance);
|
||||
avatar.group.position.set(0, 0, 12);
|
||||
this.engine.actorGroup.add(avatar.group);
|
||||
const tagEl = makeTag(appearance.name || S.name, '#ffd98a');
|
||||
this.actors.set('me', { id: 'me', name: appearance.name || S.name, appearance, avatar, isNpc: false, tx: 0, tz: 12, tRy: 0, tagEl });
|
||||
}
|
||||
get me() { return this.actors.get('me'); }
|
||||
|
||||
join(id: string, name: string, appearance: any) {
|
||||
if (!appearance) return;
|
||||
if (id === S.yourId && this.actors.has('me')) return;
|
||||
if (this.actors.has(id)) { // refresh appearance
|
||||
const a = this.actors.get(id)!;
|
||||
a.name = name; refreshTag(a.tagEl, name);
|
||||
return;
|
||||
}
|
||||
const avatar = new Avatar({ ...appearance, name });
|
||||
avatar.group.position.set((Math.random() - .5) * 8, 0, 12);
|
||||
this.engine.actorGroup.add(avatar.group);
|
||||
const tagEl = makeTag(name);
|
||||
this.actors.set(id, { id, name, appearance, avatar, isNpc: false, tx: 0, tz: 12, tRy: 0, tagEl });
|
||||
}
|
||||
|
||||
spawnNpcs() {
|
||||
NPCS.forEach((def, i) => {
|
||||
const app = npcAppearance(def, i);
|
||||
const avatar = new Avatar(app, i);
|
||||
avatar.group.position.set(def.home === 'cafe' ? -48 : 0, 0, 12);
|
||||
this.engine.actorGroup.add(avatar.group);
|
||||
const tagEl = makeTag(def.name, '#a7f3e3');
|
||||
this.actors.set(def.id, {
|
||||
id: def.id, name: def.name, appearance: app, avatar, isNpc: true,
|
||||
tx: def.home === 'cafe' ? -48 : 0, tz: 12, tRy: 0, tagEl,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
remove(id: string) {
|
||||
const a = this.actors.get(id);
|
||||
if (!a) return;
|
||||
this.engine.actorGroup.remove(a.avatar.group);
|
||||
a.avatar.dispose();
|
||||
a.tagEl.remove();
|
||||
this.actors.delete(id);
|
||||
}
|
||||
|
||||
clearRemote() {
|
||||
for (const id of [...this.actors.keys()]) if (id !== 'me') this.remove(id);
|
||||
}
|
||||
|
||||
// ---------------- networking ----------------
|
||||
private snapshot(m: any) {
|
||||
for (const p of m.players) {
|
||||
const a = this.actors.get(p.id);
|
||||
if (!a) continue;
|
||||
a.tx = p.x; a.tz = p.z; a.tRy = p.ry;
|
||||
if (!a.overrideAnimUntil || performance.now() > a.overrideAnimUntil) a.avatar.setAnim(p.anim);
|
||||
}
|
||||
for (const n of m.npcs || []) {
|
||||
const a = this.actors.get(n.id);
|
||||
if (!a) continue;
|
||||
a.tx = n.x; a.tz = n.z; a.tRy = n.ry;
|
||||
if (!a.overrideAnimUntil || performance.now() > a.overrideAnimUntil) a.avatar.setAnim(n.anim);
|
||||
}
|
||||
}
|
||||
|
||||
emote(id: string, emote: string) {
|
||||
const a = this.actors.get(id);
|
||||
if (!a) return;
|
||||
const map: Record<string, string> = { wave: 'wave', dance: 'dance', laugh: 'laugh', angry: 'argue', sleep: 'sleep', sit: 'sit', sad: 'talk', eat: 'eat', work: 'work', read: 'talk' };
|
||||
a.avatar.setAnim(map[emote] || 'wave');
|
||||
if (emote !== 'sit' && emote !== 'sleep') a.overrideAnimUntil = performance.now() + 4000;
|
||||
a.overrideAnim = map[emote];
|
||||
}
|
||||
|
||||
socialFx(m: any) {
|
||||
const from = this.actors.get(m.from);
|
||||
const to = this.actors.get(m.to);
|
||||
const dur = 3000;
|
||||
if (from) { from.avatar.setAnim(m.anim); from.overrideAnimUntil = performance.now() + dur; }
|
||||
if (to) { to.avatar.setAnim(m.anim); to.overrideAnimUntil = performance.now() + dur; }
|
||||
for (const line of m.lines || []) this.bubble(m.from, line, true);
|
||||
// emoji pop
|
||||
if ((m.emoji || '') !== '') {
|
||||
const anchor = from ? from : to;
|
||||
if (anchor) popEmoji(this.engine, anchor.avatar.group.position, m.emoji);
|
||||
}
|
||||
// gentle camera focus when I'm involved or nearby
|
||||
if (from && (m.from === 'me' || m.to === 'me')) {
|
||||
const mid = new THREE.Vector3();
|
||||
if (from && to) mid.copy(from.avatar.group.position).add(to.avatar.group.position).multiplyScalar(0.5);
|
||||
else mid.copy(from ? from.avatar.group.position : to!.avatar.group.position);
|
||||
(this as any)._focusCb?.(mid);
|
||||
}
|
||||
audio.pop();
|
||||
}
|
||||
|
||||
onFocus(cb: (pos: THREE.Vector3) => void) { (this as any)._focusCb = cb; }
|
||||
|
||||
bubble(id: string, text: string, long = false) {
|
||||
const a = this.actors.get(id);
|
||||
if (!a || !text) return;
|
||||
const el = document.createElement('div');
|
||||
el.className = 'bubble fade' + (a.isNpc ? ' npc' : '');
|
||||
el.textContent = text;
|
||||
document.getElementById('bubble-layer')!.appendChild(el);
|
||||
const life = long ? 5200 : 4400;
|
||||
(el as any)._anchor = a;
|
||||
bubbles.push({ el, until: performance.now() + life });
|
||||
setTimeout(() => { el.remove(); const i = bubbles.findIndex(b => b.el === el); if (i >= 0) bubbles.splice(i, 1); }, life);
|
||||
}
|
||||
|
||||
nearestActor(excludeMe = true, maxDist = 4.2): Actor | null {
|
||||
const me = this.me;
|
||||
if (!me) return null;
|
||||
let best: Actor | null = null, bd = maxDist;
|
||||
for (const a of this.actors.values()) {
|
||||
if (excludeMe && a.id === 'me') continue;
|
||||
const d = me.avatar.group.position.distanceTo(a.avatar.group.position);
|
||||
if (d < bd) { bd = d; best = a; }
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
// ---------------- per-frame ----------------
|
||||
update(dt: number) {
|
||||
const now = performance.now();
|
||||
for (const a of this.actors.values()) {
|
||||
const g = a.avatar.group;
|
||||
if (a.id === 'me') {
|
||||
a.avatar.update(dt);
|
||||
continue;
|
||||
}
|
||||
// interpolate
|
||||
const k = Math.min(1, dt * 9);
|
||||
const dx = a.tx - g.position.x, dz = a.tz - g.position.z;
|
||||
g.position.x += dx * k; g.position.z += dz * k;
|
||||
let dr = a.tRy - g.rotation.y;
|
||||
while (dr > Math.PI) dr -= Math.PI * 2;
|
||||
while (dr < -Math.PI) dr += Math.PI * 2;
|
||||
g.rotation.y += dr * k;
|
||||
a.avatar.update(dt);
|
||||
void now;
|
||||
}
|
||||
this.projectOverlays();
|
||||
}
|
||||
|
||||
private projectOverlays() {
|
||||
const cam = this.engine.camera;
|
||||
const w = window.innerWidth, h = window.innerHeight;
|
||||
for (const a of this.actors.values()) {
|
||||
a.avatar.headWorldPos(V);
|
||||
V.y += 0.25;
|
||||
const dist = V.distanceTo(cam.position);
|
||||
const sp = V.clone().project(cam);
|
||||
const visible = sp.z < 1 && dist < 46;
|
||||
if (!visible) { a.tagEl.style.display = 'none'; continue; }
|
||||
a.tagEl.style.display = '';
|
||||
a.tagEl.style.left = ((sp.x * 0.5 + 0.5) * w) + 'px';
|
||||
a.tagEl.style.top = ((-sp.y * 0.5 + 0.5) * h - 14) + 'px';
|
||||
}
|
||||
for (const b of bubbles) {
|
||||
const a = (b.el as any)._anchor as Actor;
|
||||
if (!a) continue;
|
||||
a.avatar.headWorldPos(V); V.y += 0.55;
|
||||
const sp = V.clone().project(cam);
|
||||
b.el.style.left = ((sp.x * 0.5 + 0.5) * window.innerWidth) + 'px';
|
||||
b.el.style.top = ((-sp.y * 0.5 + 0.5) * window.innerHeight) + 'px';
|
||||
}
|
||||
}
|
||||
|
||||
/** Build the requested zone's static scene content; returns spawn point */
|
||||
async enterZone(zone: string, homeTier?: string, layout?: any[]): Promise<{ x: number; z: number }> {
|
||||
// clear previous (and free GPU resources)
|
||||
if (this.town) { this.engine.worldGroup.remove(this.town.group); disposeGroup(this.town.group); this.town = null; }
|
||||
if (this.interior) { this.engine.worldGroup.remove(this.interior.group); disposeGroup(this.interior.group); this.interior = null; }
|
||||
this.clearRemote();
|
||||
|
||||
if (zone === 'town') {
|
||||
this.town = new Town();
|
||||
this.engine.worldGroup.add(this.town.group);
|
||||
this.spawnNpcs();
|
||||
audio.setTrack('town');
|
||||
return { x: 0, z: 12 };
|
||||
}
|
||||
|
||||
if (zone.startsWith('venue:')) {
|
||||
const v = VENUES[zone];
|
||||
if (!v) return { x: 0, z: 4 };
|
||||
this.interior = new Interior([v.size[0], v.size[1]]);
|
||||
this.interior.label = v.name;
|
||||
this.interior.setLayout(v.items || []);
|
||||
this.interior.onFurnitureChanged = () => this.interiorChangedHook?.();
|
||||
this.engine.worldGroup.add(this.interior.group);
|
||||
audio.setTrack('town');
|
||||
return { x: 0, z: v.size[1] / 2 - 1.4 };
|
||||
}
|
||||
|
||||
// otherwise: a personal home zone
|
||||
this.interior = new Interior(homeTier || 'studio');
|
||||
this.interior.label = 'Your Home';
|
||||
this.interior.setLayout(layout || []);
|
||||
this.interior.onFurnitureChanged = () => this.interiorChangedHook?.();
|
||||
this.engine.worldGroup.add(this.interior.group);
|
||||
audio.setTrack('home');
|
||||
return { x: 0, z: (this.interior.size[1] / 2) - 1.2 };
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------- DOM helpers ----------------
|
||||
const bubbles: { el: HTMLElement; until: number }[] = [];
|
||||
|
||||
function makeTag(name: string, color?: string): HTMLElement {
|
||||
const el = document.createElement('div');
|
||||
el.className = 'name-tag';
|
||||
if (color) el.style.color = color;
|
||||
el.textContent = name;
|
||||
document.getElementById('bubble-layer')!.appendChild(el);
|
||||
return el;
|
||||
}
|
||||
function refreshTag(el: HTMLElement, name: string) { el.textContent = name; }
|
||||
|
||||
function disposeGroup(root: THREE.Object3D) {
|
||||
root.traverse((o) => {
|
||||
const m = o as THREE.Mesh;
|
||||
if (m.geometry) m.geometry.dispose();
|
||||
if (m.material) {
|
||||
const mats = Array.isArray(m.material) ? m.material : [m.material];
|
||||
for (const mm of mats) {
|
||||
const std = mm as THREE.MeshStandardMaterial;
|
||||
if (std.map) std.map.dispose();
|
||||
mm.dispose();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function popEmoji(engine: Engine, pos: THREE.Vector3, emoji: string) {
|
||||
const c = document.createElement('canvas');
|
||||
c.width = c.height = 96;
|
||||
const g = c.getContext('2d')!;
|
||||
g.font = '64px serif'; g.textAlign = 'center'; g.textBaseline = 'middle';
|
||||
g.fillText(emoji, 48, 52);
|
||||
const tex = new THREE.CanvasTexture(c);
|
||||
const sp = new THREE.Sprite(new THREE.SpriteMaterial({ map: tex, transparent: true, depthWrite: false }));
|
||||
sp.scale.setScalar(0.9);
|
||||
sp.position.set(pos.x, pos.y + 2.6, pos.z);
|
||||
engine.fxGroup.add(sp);
|
||||
const t0 = performance.now();
|
||||
const anim = () => {
|
||||
const t = (performance.now() - t0) / 1000;
|
||||
sp.position.y = pos.y + 2.6 + t * 1.2;
|
||||
(sp.material as THREE.SpriteMaterial).opacity = Math.max(0, 1 - t / 1.4);
|
||||
if (t < 1.4) requestAnimationFrame(anim); else { engine.fxGroup.remove(sp); tex.dispose(); }
|
||||
};
|
||||
anim();
|
||||
}
|
||||
Reference in New Issue
Block a user