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,5 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
saves/
|
||||||
|
*.log
|
||||||
|
.DS_Store
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
# LifeTown Online 🏙️
|
||||||
|
|
||||||
|
An **original browser-based multiplayer life simulation** — create a character, move into
|
||||||
|
Maple Court, get a job, furnish your home, befriend the townsfolk and other players, and
|
||||||
|
live an ongoing simulated life. No installation, no plugins: it runs in a modern desktop
|
||||||
|
or mobile browser.
|
||||||
|
|
||||||
|
> All characters, world design, UI, code, audio and visuals are original to this project.
|
||||||
|
> No copyrighted assets, names, logos or UI from any existing game are used.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Quick start
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install # three, ws, vite
|
||||||
|
npm run build # bundles the client into client/dist
|
||||||
|
npm start # serves game + WebSocket on http://127.0.0.1:7788
|
||||||
|
```
|
||||||
|
|
||||||
|
Open **http://127.0.0.1:7788** in two browser windows to play multiplayer.
|
||||||
|
|
||||||
|
Environment overrides: `GAME_PORT` (default `7788`). Player saves are JSON files in `saves/`.
|
||||||
|
|
||||||
|
## Controls
|
||||||
|
|
||||||
|
| Action | Desktop | Mobile |
|
||||||
|
|---|---|---|
|
||||||
|
| Move | WASD / arrows | virtual joystick (bottom-left) |
|
||||||
|
| Run | hold Shift | joystick edge / 🏃 button |
|
||||||
|
| Interact | E or 👋 Act | ✋ button |
|
||||||
|
| Dance emote | F or 💃 | — |
|
||||||
|
| Orbit camera | left-drag | drag on scene |
|
||||||
|
| Zoom | wheel | pinch area |
|
||||||
|
| Chat focus | Enter | tap chat box |
|
||||||
|
|
||||||
|
Walk up to glowing POI rings (café, gym, pond, stage…) or furniture at home and press **E**.
|
||||||
|
|
||||||
|
## What's in the MVP
|
||||||
|
|
||||||
|
- **Character creator** — live 3D preview; body type, height, skin tones, hair styles &
|
||||||
|
colours, eyes, brows, nose, mouth, outfits, shoes, accessories, voice, age and up to 3
|
||||||
|
personality traits.
|
||||||
|
- **One living district — Maple Court** — main street with café, City Hall, gym, home-goods
|
||||||
|
store and arcade. **Every venue is enterable**: walk to a street-facing door and press E to
|
||||||
|
step into a shared furnished interior with activity spots (order coffee, hit the gym mats,
|
||||||
|
browse the shop catalogue, play the arcade); apartment doors lead straight into your home.
|
||||||
|
store, apartments, arcade façade, central plaza with fountain & stage, park with pond,
|
||||||
|
food truck, bus stop, benches, lamps and trees.
|
||||||
|
- **Needs & mood** — hunger, energy, hygiene, fun, social, comfort decay over time;
|
||||||
|
weighted mood engine with 13 moods + transient moodlets drive facial expressions and HUD.
|
||||||
|
- **NPCs with routines** — 10 original townsfolk walk POI-to-POI on daily schedules,
|
||||||
|
chatter ambiently and react to your interactions.
|
||||||
|
- **Jobs & economy** — 12 careers × 5 ranks with pay, XP and promotions; shifts worked at
|
||||||
|
real workplaces; TownCoins earned/spent across shops, homes, food and gifts.
|
||||||
|
- **Housing & build mode** — studio apartment instanced per player (5 more tiers purchasable),
|
||||||
|
drag-to-place furniture on a snap grid with rotate/duplicate/sell tools, 58-item catalogue
|
||||||
|
whose items boost needs recovery, cooking speed and productivity.
|
||||||
|
- **Friendship & romance** — contextual interaction wheels (Friendly/Romantic/Funny/Negative)
|
||||||
|
gated by two independent relationship meters with tier labels and partner status.
|
||||||
|
- **Multiplayer** — server-authoritative sessions/rosters/economy/relationships over one
|
||||||
|
WebSocket; 12 Hz snapshots with client-side interpolation; zone instances for town and
|
||||||
|
every home; invitations to visit; global + local chat with speech bubbles.
|
||||||
|
- **Day/night & weather** — full day cycle (24 min/day) with dawn/dusk skies, sun/moon
|
||||||
|
lighting, window glow and street lamps; sunny/cloudy/rain/storm/fog with rain particles,
|
||||||
|
puddles that soak in, fog banks and lightning flashes.
|
||||||
|
- **Mini-games** — fishing (timing bar) and cooking (memory) feeding back into needs & fun.
|
||||||
|
- **Phone** — Messages, Contacts (relationship bars), Map, Careers, Bank (transaction log),
|
||||||
|
LifeFeed social posts with in-game screenshots, Calendar of recurring town events, Home
|
||||||
|
(teleport, property market, invites).
|
||||||
|
- **Progression** — player levels gate catalog/homes; 8 achievements incl. First Home,
|
||||||
|
Social Butterfly, Millionaire, Interior Designer.
|
||||||
|
- **Save/load** — profiles autosave server-side (money, job, house, layout, inventory,
|
||||||
|
relationships, achievements, needs); clients resume via token.
|
||||||
|
- **Audio** — fully procedural WebAudio: location-based music (home/town/night), rain beds,
|
||||||
|
footsteps, door/cash/UI blips, achievement jingles.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
lifetown/
|
||||||
|
├─ shared/data.mjs # single source of truth: jobs, catalog, NPCs, interactions…
|
||||||
|
├─ server/
|
||||||
|
│ ├─ index.mjs # Node http static server + ws upgrade (/ws)
|
||||||
|
│ ├─ game.mjs # GameServer: sessions, zones, snapshots, NPCs, clock/weather,
|
||||||
|
│ │ # economy, relationships, achievements, persistence
|
||||||
|
│ └─ persist.mjs # atomic JSON profile storage
|
||||||
|
├─ client/
|
||||||
|
│ ├─ index.html # shell: title, creator, HUD, phone, build bar, modals
|
||||||
|
│ └─ src/
|
||||||
|
│ ├─ gfx/engine.ts # renderer, ACES tone-mapping, day/night lighting rig
|
||||||
|
│ ├─ gfx/avatar.ts # modular stylized characters + procedural animation set
|
||||||
|
│ ├─ gfx/town.ts # procedural district geometry
|
||||||
|
│ ├─ gfx/interior.ts # instanced home shells + grid scaffolding
|
||||||
|
│ ├─ gfx/furniture.ts # 58 procedural furniture builders
|
||||||
|
│ ├─ gfx/weatherfx.ts # rain particles, puddles, lightning hooks
|
||||||
|
│ ├─ controller.ts # input, collision, camera rig, network transform sync
|
||||||
|
│ ├─ world.ts # actor manager, interpolation, bubbles/name-tags
|
||||||
|
│ ├─ systems.ts # needs simulation + mood derivation
|
||||||
|
│ ├─ audio.ts # WebAudio sequencer & SFX synth
|
||||||
|
│ └─ ui/* # HUD, chat, phone apps, dialogs/minigames, build UI, creator
|
||||||
|
└─ scripts/
|
||||||
|
├─ e2e.mjs # 20-check two-client protocol test
|
||||||
|
└─ shot.mjs # headless-browser walkthrough of every screen
|
||||||
|
```
|
||||||
|
|
||||||
|
**Performance notes:** static geometry is modest-draw-call by construction, shadows come
|
||||||
|
from a single tight sun cascade, remote players interpolate between 12 Hz snapshots, and
|
||||||
|
interiors are separate zone scenes so only one district is ever loaded.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
```bash
|
||||||
|
node scripts/e2e.mjs # exercises auth, movement sync, chat, social gates, jobs,
|
||||||
|
# cooldowns, shopping, housing, zones, invites, persistence
|
||||||
|
node scripts/shot.mjs # drives the real client headless through all major screens
|
||||||
|
```
|
||||||
|
|
||||||
|
## Roadmap beyond MVP
|
||||||
|
|
||||||
|
Beach zone & swimming, nightclub with DJ events, vehicles (bicycle → sports car),
|
||||||
|
weddings & festivals, group activities, Postgres/Redis backing stores, WebRTC voice,
|
||||||
|
seasonal events, pets, and expanded careers with skill mini-courses at the university.
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
Generated
+1165
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"name": "lifetown-online",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"description": "LifeTown Online - original browser multiplayer life simulation (MVP)",
|
||||||
|
"scripts": {
|
||||||
|
"build": "vite build",
|
||||||
|
"dev": "vite",
|
||||||
|
"start": "node server/index.mjs"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"three": "^0.170.0",
|
||||||
|
"ws": "^8.18.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/three": "^0.185.4",
|
||||||
|
"playwright-core": "^1.62.1",
|
||||||
|
"typescript": "^5.6.3",
|
||||||
|
"vite": "^5.4.10"
|
||||||
|
}
|
||||||
|
}
|
||||||
+171
@@ -0,0 +1,171 @@
|
|||||||
|
// LifeTown Online — end-to-end server verification with two simulated clients.
|
||||||
|
import WebSocket from 'ws';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
|
||||||
|
// fresh fixtures: remove previous test saves so character creation is exercised
|
||||||
|
for (const f of ['e2e_a', 'e2e_b']) {
|
||||||
|
try { fs.unlinkSync(new globalThis.URL(`../saves/${f}.json`, import.meta.url)); } catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
const WS_URL = 'ws://127.0.0.1:7788/ws';
|
||||||
|
let failures = 0;
|
||||||
|
function ok(name, cond, extra = '') {
|
||||||
|
console.log(`${cond ? '✅' : '❌'} ${name}${extra ? ' — ' + extra : ''}`);
|
||||||
|
if (!cond) failures++;
|
||||||
|
}
|
||||||
|
const wait = (ms) => new Promise(r => setTimeout(r, ms));
|
||||||
|
|
||||||
|
class Client {
|
||||||
|
constructor(name) {
|
||||||
|
this.name = name;
|
||||||
|
this.inbox = [];
|
||||||
|
this.ws = new WebSocket(WS_URL);
|
||||||
|
this.ws.on('message', (d) => {
|
||||||
|
const m = JSON.parse(d.toString());
|
||||||
|
this.inbox.push(m);
|
||||||
|
});
|
||||||
|
this.opened = new Promise(res => this.ws.on('open', res));
|
||||||
|
}
|
||||||
|
async send(msg) { await this.opened; this.ws.send(JSON.stringify(msg)); }
|
||||||
|
async until(pred, timeout = 4000, label = '') {
|
||||||
|
const t0 = Date.now();
|
||||||
|
while (Date.now() - t0 < timeout) {
|
||||||
|
const hit = this.inbox.find(pred);
|
||||||
|
if (hit) return hit;
|
||||||
|
await wait(40);
|
||||||
|
}
|
||||||
|
throw new Error(`timeout waiting for ${label || pred}`);
|
||||||
|
}
|
||||||
|
clear() { this.inbox.length = 0; }
|
||||||
|
}
|
||||||
|
|
||||||
|
const appearanceA = {
|
||||||
|
bodyType: 'average', height: 1, skin: '#eab98f', hair: 'short', hairColor: '#4a2f1d',
|
||||||
|
eyes: 'round', eyeColor: '#3a5a40', outfitTop: 'tee', topColor: '#4f8fd9', bottomColor: '#33415c',
|
||||||
|
};
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const a = new Client('E2E_A');
|
||||||
|
const b = new Client('E2E_B');
|
||||||
|
|
||||||
|
// ---- create characters ----
|
||||||
|
await a.send({ t: 'hello', name: 'E2E_A', appearance: appearanceA });
|
||||||
|
const wa = await a.until(m => m.t === 'welcome' || m.t === 'error', 5000, 'welcome A');
|
||||||
|
ok('A welcome', wa.t === 'welcome', `money=${wa.profile?.money} token=${!!wa.token}`);
|
||||||
|
|
||||||
|
await b.send({ t: 'hello', name: 'E2E_B', appearance: {} });
|
||||||
|
const wb = await b.until(m => m.t === 'welcome' || m.t === 'error', 5000, 'welcome B');
|
||||||
|
ok('B welcome', wb.t === 'welcome');
|
||||||
|
|
||||||
|
// join visibility: A was already in town -> B gets roster; B joining -> A gets playerJoin
|
||||||
|
const rosterB = await b.until(m => m.t === 'roster' && m.players.some(p => p.id === wa.yourId), 4000, 'roster with A');
|
||||||
|
ok('B received roster including A', !!rosterB);
|
||||||
|
const joinA = await a.until(m => m.t === 'playerJoin' && m.id === wb.yourId, 4000, 'playerJoin B');
|
||||||
|
ok('A notified of B joining (playerJoin)', !!joinA);
|
||||||
|
|
||||||
|
// duplicate name protection
|
||||||
|
const c = new Client('E2E_A');
|
||||||
|
await c.send({ t: 'hello', name: 'E2E_A' });
|
||||||
|
const wc = await c.until(m => m.t === 'error', 4000, 'dup-name error');
|
||||||
|
ok('duplicate name rejected', wc.code === 'taken');
|
||||||
|
c.ws.close();
|
||||||
|
|
||||||
|
// ---- movement sync ----
|
||||||
|
await wait(300);
|
||||||
|
a.clear(); b.clear();
|
||||||
|
await a.send({ t: 'move', x: 3.5, z: 11, ry: 1, anim: 'walk' });
|
||||||
|
await wait(300);
|
||||||
|
const snapB = b.inbox.filter(m => m.t === 'snapshot').find(s => s.players.some(p => p.id === wa.yourId));
|
||||||
|
ok('B sees A moving in snapshot', !!snapB);
|
||||||
|
|
||||||
|
// ---- chat ----
|
||||||
|
b.clear();
|
||||||
|
await a.send({ t: 'chat', text: 'Hello from A!', channel: 'global' });
|
||||||
|
const chatB = await b.until(m => m.t === 'chat' && m.from === 'E2E_A', 3000, 'chat relay');
|
||||||
|
ok('global chat relayed', chatB.text.includes('Hello from A'));
|
||||||
|
|
||||||
|
// ---- player-player social ----
|
||||||
|
a.clear();
|
||||||
|
await a.send({ t: 'social', targetType: 'player', targetId: wb.yourId, action: 'hello' });
|
||||||
|
const relA = await a.until(m => m.t === 'relUpdate', 3000, 'relUpdate A');
|
||||||
|
const relAB = relA.relationships['p:e2e_b'];
|
||||||
|
ok('social hello raises friendship', relAB && relAB.f >= 2, `f=${relAB?.f}`);
|
||||||
|
|
||||||
|
// locked interaction (flirt needs romance gates fine at r0? flirt req.r=0 so allowed; use holdhands req.r=25)
|
||||||
|
a.clear();
|
||||||
|
await a.send({ t: 'social', targetType: 'player', targetId: wb.yourId, action: 'holdhands' });
|
||||||
|
const errLocked = await a.until(m => m.t === 'error', 3000, 'locked error');
|
||||||
|
ok('gated interaction blocked', errLocked.code === 'locked', errLocked.message);
|
||||||
|
|
||||||
|
// ---- jobs & economy ----
|
||||||
|
a.clear();
|
||||||
|
await a.send({ t: 'job_select', jobId: 'barista' });
|
||||||
|
const ju = await a.until(m => m.t === 'jobUpdate', 3000, 'jobUpdate');
|
||||||
|
ok('job selected', ju.job?.id === 'barista');
|
||||||
|
|
||||||
|
a.clear();
|
||||||
|
await a.send({ t: 'job_work' });
|
||||||
|
const wr = await a.until(m => m.t === 'workResult', 3000, 'workResult');
|
||||||
|
ok('work shift pays', wr.pay > 0, `pay=${wr.pay} money=${wr.money}`);
|
||||||
|
|
||||||
|
a.clear();
|
||||||
|
await a.send({ t: 'job_work' });
|
||||||
|
const cdErr = await a.until(m => m.t === 'error', 3000, 'cooldown error');
|
||||||
|
ok('work cooldown enforced', cdErr.code === 'cooldown');
|
||||||
|
|
||||||
|
// ---- shopping ----
|
||||||
|
a.clear();
|
||||||
|
await a.send({ t: 'buy_item', itemId: 'bed_basic' });
|
||||||
|
const inv = await a.until(m => m.t === 'inventory', 3000, 'inventory update');
|
||||||
|
ok('item purchased into inventory', inv.inventory.some(i => i.itemId === 'bed_basic'));
|
||||||
|
|
||||||
|
// house purchase should fail on funds/level
|
||||||
|
a.clear();
|
||||||
|
await a.send({ t: 'buy_house', houseId: 'mansion' });
|
||||||
|
const houseErr = await a.until(m => m.t === 'error', 3000, 'house locked');
|
||||||
|
ok('mansion gated', houseErr.code === 'level' || houseErr.code === 'poor');
|
||||||
|
|
||||||
|
// ---- home layout persistence ----
|
||||||
|
a.clear();
|
||||||
|
await a.send({ t: 'place_home', layout: [{ itemId: 'bed_basic', x: -2, z: -2, rot: 0 }, { itemId: 'plant_fern', x: 2.5, z: 1.5, rot: 1 }] });
|
||||||
|
const ls = await a.until(m => m.t === 'layoutSaved', 3000, 'layoutSaved');
|
||||||
|
ok('home layout saved', ls.count === 2);
|
||||||
|
|
||||||
|
// ---- zone switching ----
|
||||||
|
a.clear();
|
||||||
|
await a.send({ t: 'zone', id: `home:e2e_a` });
|
||||||
|
const zo = await a.until(m => m.t === 'zoneOk', 3000, 'zoneOk home');
|
||||||
|
ok('entered own home', zo.zone === 'home:e2e_a');
|
||||||
|
|
||||||
|
// B cannot enter A's home uninvited
|
||||||
|
b.clear();
|
||||||
|
await b.send({ t: 'zone', id: 'home:e2e_a' });
|
||||||
|
const lockHome = await b.until(m => m.t === 'error', 3000, 'home locked');
|
||||||
|
ok('stranger blocked from home', lockHome.code === 'locked');
|
||||||
|
|
||||||
|
// invitation flow
|
||||||
|
a.clear(); b.clear();
|
||||||
|
await a.send({ t: 'invite', to: 'E2E_B' });
|
||||||
|
const notifB = await b.until(m => m.t === 'notify' && m.action?.type === 'visit', 3000, 'invite notify');
|
||||||
|
await b.send({ t: 'zone', id: notifB.action.home });
|
||||||
|
const zoB = await b.until(m => m.t === 'zoneOk', 3000, 'zoneOk visit');
|
||||||
|
ok('invited friend can visit home', zoB.zone === 'home:e2e_a');
|
||||||
|
|
||||||
|
// back to town
|
||||||
|
b.clear();
|
||||||
|
await b.send({ t: 'zone', id: 'town' });
|
||||||
|
const zoT = await b.until(m => m.t === 'zoneOk', 3000, 'back to town');
|
||||||
|
ok('returned to town', zoT.zone === 'town');
|
||||||
|
|
||||||
|
// time & weather broadcast
|
||||||
|
const timeMsg = await a.until(m => m.t === 'time', 4000, 'time broadcast');
|
||||||
|
ok('server clock ticking', typeof timeMsg.minutes === 'number', `${timeMsg.day} day, ${Math.round(timeMsg.minutes)} min`);
|
||||||
|
|
||||||
|
a.ws.close(); b.ws.close();
|
||||||
|
await wait(200);
|
||||||
|
|
||||||
|
console.log(failures ? `\n${failures} FAILURES` : '\n🎉 ALL E2E CHECKS PASSED');
|
||||||
|
process.exit(failures ? 1 : 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch(e => { console.error('E2E crashed:', e.message); process.exit(1); });
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
// LifeTown Online - headless verification: one short browser session per screen
|
||||||
|
import { chromium } from 'playwright-core';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
|
||||||
|
const EXE = '/root/.cache/ms-playwright/chromium_headless_shell-1148/chrome-linux/headless_shell';
|
||||||
|
const URL = 'http://127.0.0.1:7788';
|
||||||
|
const OUT = '/tmp/lifetown-shots';
|
||||||
|
fs.mkdirSync(OUT, { recursive: true });
|
||||||
|
const ARGS = ['--use-gl=swiftshader', '--enable-unsafe-swiftshader', '--no-sandbox', '--disable-dev-shm-usage', '--disable-gpu-sandbox'];
|
||||||
|
|
||||||
|
const allErrors = [];
|
||||||
|
|
||||||
|
async function withPage(fn, label) {
|
||||||
|
let browser;
|
||||||
|
try {
|
||||||
|
browser = await chromium.launch({ executablePath: EXE, args: ARGS, timeout: 30000 });
|
||||||
|
const page = await browser.newPage({ viewport: { width: 960, height: 600 } });
|
||||||
|
page.on('pageerror', (e) => allErrors.push(`[${label}] ${String(e).slice(0, 250)}`));
|
||||||
|
page.on('console', (m) => { if (m.type() === 'error') allErrors.push(`[${label}] CONSOLE ${m.text().slice(0, 200)}`); });
|
||||||
|
await page.goto(URL, { waitUntil: 'domcontentloaded', timeout: 20000 });
|
||||||
|
await fn(page);
|
||||||
|
console.log(`[shot] OK ${label}`);
|
||||||
|
} catch (e) {
|
||||||
|
console.log(`[shot] FAIL ${label}: ${String(e).slice(0, 140)}`);
|
||||||
|
} finally {
|
||||||
|
try { await browser?.close(); } catch {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function enterGame(page) {
|
||||||
|
const uname = 'Sunny' + Math.floor(Math.random() * 900000 + 100000);
|
||||||
|
await page.waitForTimeout(700);
|
||||||
|
await page.click('#btn-newchar', { force: true });
|
||||||
|
await page.fill('#cr-name', uname);
|
||||||
|
await page.click('#btn-startlife', { force: true });
|
||||||
|
await page.waitForTimeout(4200);
|
||||||
|
}
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
await withPage(async (page) => {
|
||||||
|
await page.waitForTimeout(1100);
|
||||||
|
await page.screenshot({ path: `${OUT}/1-title.png` });
|
||||||
|
}, 'title');
|
||||||
|
|
||||||
|
await withPage(async (page) => {
|
||||||
|
await page.waitForTimeout(700);
|
||||||
|
await page.click('#btn-newchar', { force: true });
|
||||||
|
await page.fill('#cr-name', 'Sunny' + Math.floor(Math.random() * 900000 + 100000));
|
||||||
|
await page.waitForTimeout(1200);
|
||||||
|
await page.screenshot({ path: `${OUT}/2-creator.png` });
|
||||||
|
}, 'creator');
|
||||||
|
|
||||||
|
await withPage(async (page) => {
|
||||||
|
await enterGame(page);
|
||||||
|
await page.screenshot({ path: `${OUT}/3-town.png` });
|
||||||
|
await page.keyboard.down('KeyW');
|
||||||
|
await page.waitForTimeout(1200);
|
||||||
|
await page.keyboard.up('KeyW');
|
||||||
|
await page.waitForTimeout(400);
|
||||||
|
await page.screenshot({ path: `${OUT}/4-walked.png` });
|
||||||
|
}, 'town');
|
||||||
|
|
||||||
|
await withPage(async (page) => {
|
||||||
|
await enterGame(page);
|
||||||
|
await page.click('[data-act="phone"]', { force: true });
|
||||||
|
await page.waitForTimeout(900);
|
||||||
|
await page.screenshot({ path: `${OUT}/5-phone.png` });
|
||||||
|
}, 'phone');
|
||||||
|
|
||||||
|
await withPage(async (page) => {
|
||||||
|
await enterGame(page);
|
||||||
|
await page.click('[data-act="home"]', { force: true });
|
||||||
|
await page.waitForTimeout(600);
|
||||||
|
await page.screenshot({ path: `${OUT}/6-homemenu.png` });
|
||||||
|
const btns = await page.$$('#dialog-body button');
|
||||||
|
if (btns[1]) await btns[1].click({ force: true });
|
||||||
|
await page.waitForTimeout(3200);
|
||||||
|
await page.screenshot({ path: `${OUT}/7-home.png` });
|
||||||
|
}, 'home');
|
||||||
|
|
||||||
|
await withPage(async (page) => {
|
||||||
|
await enterGame(page);
|
||||||
|
await page.click('[data-act="home"]', { force: true });
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
const btns = await page.$$('#dialog-body button');
|
||||||
|
if (btns[1]) await btns[1].click({ force: true });
|
||||||
|
await page.waitForTimeout(3000);
|
||||||
|
await page.click('[data-act="build"]', { force: true });
|
||||||
|
await page.waitForTimeout(700);
|
||||||
|
await page.click('#btn-shop', { force: true });
|
||||||
|
await page.waitForTimeout(900);
|
||||||
|
await page.screenshot({ path: `${OUT}/8-catalog.png` });
|
||||||
|
await page.click('#catalog-close', { force: true });
|
||||||
|
await page.waitForTimeout(400);
|
||||||
|
await page.screenshot({ path: `${OUT}/9-buildmode.png` });
|
||||||
|
}, 'build');
|
||||||
|
|
||||||
|
console.log(allErrors.length ? `PAGE ERRORS (${allErrors.length}):\n` + allErrors.slice(0, 10).join('\n') : 'NO PAGE ERRORS');
|
||||||
|
})();
|
||||||
+636
@@ -0,0 +1,636 @@
|
|||||||
|
// LifeTown Online — authoritative game server: sessions, zones, snapshots,
|
||||||
|
// NPC simulation, world clock/weather, economy, relationships, persistence.
|
||||||
|
import { WebSocket } from 'ws';
|
||||||
|
import {
|
||||||
|
TIME_SCALE, DAY_MINUTES, JOBS, XP_PER_RANK, WORK_SECONDS, WORK_COOLDOWN,
|
||||||
|
NPCS, AREA_POINTS, INTERACTION_BY_ID, REL_MAX, ROMANCE_PARTNER_AT,
|
||||||
|
CATALOG_BY_ID, HOUSES, MAX_CHAT_LEN, WEATHER_STATES,
|
||||||
|
FRIEND_TIERS, NEEDS, VENUES,
|
||||||
|
} from '../shared/data.mjs';
|
||||||
|
import { loadProfile, saveProfile, newProfile, findProfileByToken, listProfileNames, sanitizeName } from './persist.mjs';
|
||||||
|
|
||||||
|
const TICK_MS = 1000 / 12; // snapshot rate
|
||||||
|
const MOVE_RATE_MS = 66; // expected client movement packet interval
|
||||||
|
const MAX_SPEED = 6.5; // metres/sec sanity cap
|
||||||
|
|
||||||
|
const clamp = (v, a, b) => Math.max(a, Math.min(b, v));
|
||||||
|
const dist2d = (a, b) => Math.hypot(a.x - b.x, a.z - b.z);
|
||||||
|
|
||||||
|
function relKeyOf(targetType, id) { return targetType === 'npc' ? `npc:${id}` : `p:${String(id).toLowerCase()}`; }
|
||||||
|
|
||||||
|
function friendLabel(f) {
|
||||||
|
let label = FRIEND_TIERS[0].label;
|
||||||
|
for (const t of FRIEND_TIERS) if (f >= t.at) label = t.label;
|
||||||
|
return label;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class GameServer {
|
||||||
|
constructor(wss) {
|
||||||
|
this.wss = wss;
|
||||||
|
this.sessions = new Set(); // all live sessions
|
||||||
|
this.byZone = new Map(); // zoneId -> Set<session>
|
||||||
|
this.npcs = NPCS.map(def => ({
|
||||||
|
id: def.id, name: def.name, role: def.role, color: def.color,
|
||||||
|
x: 0, z: 12, ry: 0, anim: 'idle',
|
||||||
|
target: null, waitUntil: 0, lineAt: Date.now() + 8000 + Math.random() * 20000,
|
||||||
|
}));
|
||||||
|
// start the world at 07:30 day 1 (1 real second = TIME_SCALE game-seconds)
|
||||||
|
const startGameMin = 7 * 60 + 30;
|
||||||
|
const elapsedRealSec = startGameMin * 60 / TIME_SCALE;
|
||||||
|
this.t0 = Date.now() - elapsedRealSec * 1000;
|
||||||
|
this.day = 1;
|
||||||
|
this.weather = 'sunny';
|
||||||
|
this.nextWeatherRoll = Date.now() + 30000;
|
||||||
|
|
||||||
|
this.interval = setInterval(() => this.tick(), TICK_MS);
|
||||||
|
this.slowInterval = setInterval(() => this.slowTick(), 2000);
|
||||||
|
console.log(`[LifeTown] world initialised — ${this.npcs.length} townsfolk ready`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------- world clock ---------------------------
|
||||||
|
gameTime() {
|
||||||
|
const totalGameSec = Math.floor((Date.now() - this.t0) / 1000) * TIME_SCALE;
|
||||||
|
const totalMin = Math.floor(totalGameSec / 60);
|
||||||
|
return { day: Math.floor(totalMin / DAY_MINUTES) + 1, minutes: totalMin % DAY_MINUTES };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------- connection lifecycle ------------------
|
||||||
|
onConnection(ws) {
|
||||||
|
ws.on('message', (data) => this.onMessage(ws, data));
|
||||||
|
ws.on('close', () => this.onClose(ws));
|
||||||
|
ws.on('error', () => {});
|
||||||
|
ws.isAlive = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
onClose(ws) {
|
||||||
|
const s = ws.__session;
|
||||||
|
if (!s) return;
|
||||||
|
this.sessions.delete(s);
|
||||||
|
this.leaveZone(s);
|
||||||
|
try { saveProfile(s.profile); } catch {}
|
||||||
|
this.broadcastToZone(s.zone, 'playerLeave', { id: s.id }, s);
|
||||||
|
console.log(`[LifeTown] ${s.profile.name} left the town`);
|
||||||
|
}
|
||||||
|
|
||||||
|
leaveZone(s) {
|
||||||
|
const set = this.byZone.get(s.zone);
|
||||||
|
if (set) { set.delete(s); if (!set.size) this.byZone.delete(s.zone); }
|
||||||
|
}
|
||||||
|
joinZone(s, zoneId) {
|
||||||
|
this.leaveZone(s);
|
||||||
|
s.zone = zoneId;
|
||||||
|
if (!this.byZone.has(zoneId)) this.byZone.set(zoneId, new Set());
|
||||||
|
this.byZone.get(zoneId).add(s);
|
||||||
|
// tell zone about the newcomer (full appearance so clients build the avatar)
|
||||||
|
this.broadcastToZone(zoneId, 'playerJoin', { id: s.id, name: s.profile.name, appearance: s.publicAppearance(), zone: zoneId }, s);
|
||||||
|
// roster reply to mover: everyone currently here
|
||||||
|
const roster = [...(this.byZone.get(zoneId) || [])].filter(o => o !== s)
|
||||||
|
.map(o => ({ id: o.id, name: o.profile.name, appearance: o.publicAppearance() }));
|
||||||
|
this.send(s.ws, 'roster', { zone: zoneId, players: roster });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------- messaging primitives -------------------
|
||||||
|
send(ws, t, data) { if (ws && ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ t, ...data })); }
|
||||||
|
sendSession(s, t, data) { this.send(s.ws, t, data); }
|
||||||
|
broadcast(t, data) { for (const s of this.sessions) this.sendSession(s, t, data); }
|
||||||
|
broadcastToZone(zoneId, t, data, exclude) {
|
||||||
|
const set = this.byZone.get(zoneId);
|
||||||
|
if (!set) return;
|
||||||
|
for (const s of set) if (s !== exclude) this.sendSession(s, t, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
onMessage(ws, raw) {
|
||||||
|
let msg;
|
||||||
|
try { msg = JSON.parse(raw.toString()); } catch { return; }
|
||||||
|
if (!msg || typeof msg.t !== 'string') return;
|
||||||
|
const s = ws.__session;
|
||||||
|
|
||||||
|
switch (msg.t) {
|
||||||
|
case 'hello': return this.hello(ws, msg);
|
||||||
|
case 'ping': return this.send(ws, 'pong', {});
|
||||||
|
}
|
||||||
|
if (!s) return;
|
||||||
|
switch (msg.t) {
|
||||||
|
case 'move': return this.move(s, msg);
|
||||||
|
case 'chat': return this.chat(s, msg);
|
||||||
|
case 'emote': return this.emote(s, msg);
|
||||||
|
case 'social': return this.social(s, msg);
|
||||||
|
case 'zone': return this.switchZone(s, msg);
|
||||||
|
case 'invite': return this.invite(s, msg);
|
||||||
|
case 'job_select': return this.jobSelect(s, msg);
|
||||||
|
case 'job_work': return this.jobWork(s, msg);
|
||||||
|
case 'buy_item': return this.buyItem(s, msg);
|
||||||
|
case 'buy_house': return this.buyHouse(s, msg);
|
||||||
|
case 'place_home': return this.placeHome(s, msg);
|
||||||
|
case 'sell_item': return this.sellItem(s, msg);
|
||||||
|
case 'sell_placed': return this.sellPlaced(s, msg);
|
||||||
|
case 'needs_sync': return this.needsSync(s, msg);
|
||||||
|
case 'gift_player': return this.giftPlayer(s, msg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------- hello / character creation -------------
|
||||||
|
hello(ws, msg) {
|
||||||
|
const name = sanitizeName(msg.name);
|
||||||
|
if (name.length < 2) return this.send(ws, 'error', { code: 'name', message: 'Name must be 2-20 letters.' });
|
||||||
|
|
||||||
|
let profile = null;
|
||||||
|
if (msg.token) profile = findProfileByToken(msg.token);
|
||||||
|
if (!profile) {
|
||||||
|
profile = loadProfile(name);
|
||||||
|
if (profile && (!msg.token || profile.token !== msg.token)) {
|
||||||
|
// name belongs to another save; refuse politely
|
||||||
|
return this.send(ws, 'error', { code: 'taken', message: `The name "${name}" already lives in LifeTown. Pick another.` });
|
||||||
|
}
|
||||||
|
if (!profile) {
|
||||||
|
profile = newProfile(name, msg.appearance || {});
|
||||||
|
saveProfile(profile);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (this.sessions.size >= 64) return this.send(ws, 'error', { code: 'full', message: 'Town is full right now (64 residents online).' });
|
||||||
|
|
||||||
|
const s = {
|
||||||
|
id: `${profile.name.toLowerCase()}#${Math.random().toString(36).slice(2, 7)}`,
|
||||||
|
ws, profile,
|
||||||
|
zone: 'town', x: 0, z: 12, ry: 0, anim: 'idle',
|
||||||
|
lastMoveAt: Date.now(), socialCooldownAt: {}, lastNeedsSync: 0,
|
||||||
|
publicAppearance: () => ({
|
||||||
|
bodyType: profile.appearance?.bodyType ?? 'average',
|
||||||
|
height: profile.appearance?.height ?? 1,
|
||||||
|
skin: profile.appearance?.skin ?? '#eab98f',
|
||||||
|
hair: profile.appearance?.hair ?? 'short',
|
||||||
|
hairColor: profile.appearance?.hairColor ?? '#4a2f1d',
|
||||||
|
eyeColor: profile.appearance?.eyeColor ?? '#3a5a40',
|
||||||
|
topColor: profile.appearance?.outfit?.topColor ?? '#4f8fd9',
|
||||||
|
bottomColor: profile.appearance?.outfit?.bottomColor ?? '#33415c',
|
||||||
|
outfitTop: profile.appearance?.outfit?.top ?? 'tee',
|
||||||
|
outfitBottom: profile.appearance?.outfit?.bottom ?? 'jeans',
|
||||||
|
dress: profile.appearance?.outfit?.dress ?? null,
|
||||||
|
shoes: profile.appearance?.outfit?.shoes ?? 'sneakers',
|
||||||
|
accessory: profile.appearance?.outfit?.accessory ?? 'none',
|
||||||
|
name: profile.name,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
ws.__session = s;
|
||||||
|
this.sessions.add(s);
|
||||||
|
|
||||||
|
const time = this.gameTime();
|
||||||
|
this.joinZone(s, 'town');
|
||||||
|
this.send(ws, 'welcome', {
|
||||||
|
yourId: s.id, token: profile.token, zone: 'town',
|
||||||
|
profile: this.clientProfile(profile),
|
||||||
|
time: { ...time, scale: TIME_SCALE },
|
||||||
|
weather: this.weather,
|
||||||
|
spawn: { x: 0, z: 12 },
|
||||||
|
online: [...this.sessions].filter(o => o !== s).map(o => ({ id: o.id, name: o.profile.name })),
|
||||||
|
residentsOnline: this.sessions.size,
|
||||||
|
});
|
||||||
|
this.broadcastToZone('town', 'notify', { title: 'Welcome!', text: `${profile.name} just arrived in LifeTown.`, icon: '👋' }, s);
|
||||||
|
console.log(`[LifeTown] ${profile.name} joined (${this.sessions.size} online)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
clientProfile(p) {
|
||||||
|
return {
|
||||||
|
name: p.name, money: p.money, level: p.level, xp: p.xp,
|
||||||
|
job: p.job, houseTier: p.houseTier, housesOwned: p.housesOwned,
|
||||||
|
homeLayout: p.homeLayout, inventory: p.inventory,
|
||||||
|
relationships: p.relationships, achievements: p.achievements,
|
||||||
|
stats: p.stats, appearance: p.appearance, needs: p.needs, moodlets: p.moodlets || [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------- movement -------------------------------
|
||||||
|
move(s, msg) {
|
||||||
|
const now = Date.now();
|
||||||
|
const dt = Math.min((now - s.lastMoveAt) / 1000, 1);
|
||||||
|
const nx = Number(msg.x), nz = Number(msg.z);
|
||||||
|
if (!Number.isFinite(nx) || !Number.isFinite(nz)) return;
|
||||||
|
const d = Math.hypot(nx - s.x, nz - s.z);
|
||||||
|
if (d > MAX_SPEED * dt + 2.5) {
|
||||||
|
// implausible jump: clamp toward previous position but stay responsive
|
||||||
|
const k = (MAX_SPEED * dt + 2.5) / d;
|
||||||
|
s.x += (nx - s.x) * k; s.z += (nz - s.z) * k;
|
||||||
|
} else { s.x = clamp(nx, -72, 72); s.z = clamp(nz, -46, 46); }
|
||||||
|
s.ry = Number(msg.ry) || s.ry;
|
||||||
|
s.anim = typeof msg.anim === 'string' ? String(msg.anim).slice(0, 16) : s.anim;
|
||||||
|
s.lastMoveAt = now;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------- chat & emotes --------------------------
|
||||||
|
chat(s, msg) {
|
||||||
|
const text = String(msg.text || '').slice(0, MAX_CHAT_LEN).trim();
|
||||||
|
if (!text) return;
|
||||||
|
if (msg.channel === 'global') {
|
||||||
|
this.broadcast('chat', { from: s.profile.name, text, channel: 'global', ts: Date.now() });
|
||||||
|
} else {
|
||||||
|
this.broadcastToZone(s.zone, 'chat', { from: s.profile.name, text, channel: 'say', ts: Date.now() });
|
||||||
|
this.broadcastToZone(s.zone, 'bubble', { id: s.id, text });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
emote(s, msg) {
|
||||||
|
const id = String(msg.id || '').slice(0, 16);
|
||||||
|
this.broadcastToZone(s.zone, 'emote', { id: s.id, emote: id });
|
||||||
|
if (id === 'dance') {
|
||||||
|
s.profile.stats.dances = (s.profile.stats.dances || 0) + 1;
|
||||||
|
this.checkAchievements(s);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------- zones ---------------------------------
|
||||||
|
switchZone(s, msg) {
|
||||||
|
const want = String(msg.id || '');
|
||||||
|
if (want === 'town') {
|
||||||
|
s.x = 0; s.z = 12; s.anim = 'idle';
|
||||||
|
this.joinZone(s, 'town');
|
||||||
|
this.sendSession(s, 'zoneOk', { zone: 'town', spawn: { x: 0, z: 12 } });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (want.startsWith('venue:')) {
|
||||||
|
if (!VENUES[want]) return this.sendSession(s, 'error', { code: 'nozone', message: 'That place does not exist.' });
|
||||||
|
s.x = 0; s.z = 4; s.anim = 'idle';
|
||||||
|
this.joinZone(s, want);
|
||||||
|
this.sendSession(s, 'zoneOk', { zone: want, spawn: { x: 0, z: 4 } });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (want.startsWith('home:')) {
|
||||||
|
const owner = want.slice(5);
|
||||||
|
const isSelf = owner === s.profile.name.toLowerCase();
|
||||||
|
const rel = s.profile.relationships[`p:${owner}`];
|
||||||
|
const allowed = isSelf || (rel && rel.f >= 25) || (s.invitedTo === want);
|
||||||
|
if (!allowed) return this.sendSession(s, 'error', { code: 'locked', message: 'You need an invitation (or friendship ≥ Friend) to visit that home.' });
|
||||||
|
s.x = 0; s.z = 6; s.anim = 'idle';
|
||||||
|
this.joinZone(s, want);
|
||||||
|
const ownerProf = loadProfile(owner);
|
||||||
|
this.sendSession(s, 'zoneOk', {
|
||||||
|
zone: want, spawn: { x: 0, z: 6 },
|
||||||
|
tier: ownerProf?.houseTier || 'studio',
|
||||||
|
layout: ownerProf?.homeLayout || [],
|
||||||
|
own: isSelf,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
invite(s, msg) {
|
||||||
|
const targetName = sanitizeName(msg.to).toLowerCase();
|
||||||
|
const target = [...this.sessions].find(o => o.profile.name.toLowerCase() === targetName);
|
||||||
|
if (!target) return this.sendSession(s, 'error', { code: 'offline', message: 'That resident is not online.' });
|
||||||
|
target.invitedTo = `home:${s.profile.name.toLowerCase()}`;
|
||||||
|
this.sendSession(target, 'notify', { title: 'Invitation', text: `${s.profile.name} invited you to their home!`, icon: '🏠', action: { type: 'visit', home: `home:${s.profile.name.toLowerCase()}` } });
|
||||||
|
this.sendSession(s, 'notify', { title: 'Invite sent', text: `${target.profile.name} can now visit your home.`, icon: '📨' });
|
||||||
|
}
|
||||||
|
|
||||||
|
giftPlayer(s, msg) {
|
||||||
|
const targetName = sanitizeName(msg.to).toLowerCase();
|
||||||
|
const target = [...this.sessions].find(o => o.profile.name.toLowerCase() === targetName);
|
||||||
|
if (!target || target.zone !== s.zone) return this.sendSession(s, 'error', { code: 'range', message: 'They are not nearby.' });
|
||||||
|
if (dist2d(s, target) > 6) return this.sendSession(s, 'error', { code: 'range', message: 'Walk closer to hand over the gift.' });
|
||||||
|
const cost = 40;
|
||||||
|
if (s.profile.money < cost) return this.sendSession(s, 'error', { code: 'poor', message: 'Not enough TownCoins (need 40).' });
|
||||||
|
s.profile.money -= cost;
|
||||||
|
s.profile.stats.giftsSent++;
|
||||||
|
this.applyRelDelta(s.profile, 'p', target.profile.name, { df: 2, dr: 6 });
|
||||||
|
this.applyRelDelta(target.profile, 'p', s.profile.name, { df: 3, dr: 4 });
|
||||||
|
target.profile.money += 20; // small thank-you kickback keeps economy friendly
|
||||||
|
this.broadcastToZone(s.zone, 'socialFx', {
|
||||||
|
from: s.id, to: target.id, anim: 'gift', emoji: '🎁',
|
||||||
|
lines: [`${s.profile.name} gives ${target.profile.name} a present!`],
|
||||||
|
});
|
||||||
|
this.sendSession(s, 'money', { amount: s.profile.money });
|
||||||
|
this.sendSession(target, 'money', { amount: target.profile.money });
|
||||||
|
this.pushRelUpdate(s); this.pushRelUpdate(target);
|
||||||
|
this.checkAchievements(s);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------- social interactions --------------------
|
||||||
|
social(s, msg) {
|
||||||
|
const action = INTERACTION_BY_ID[String(msg.action || '')];
|
||||||
|
if (!action) return;
|
||||||
|
const type = msg.targetType === 'player' ? 'p' : 'npc';
|
||||||
|
const now = Date.now();
|
||||||
|
if ((s.socialCooldownAt[action.id] || 0) > now) return this.sendSession(s, 'error', { code: 'cooldown', message: 'Give it a moment…' });
|
||||||
|
|
||||||
|
// locate target
|
||||||
|
let target = null, targetName = '', targetWs = null;
|
||||||
|
if (type === 'npc') {
|
||||||
|
const zset = this.byZone.get('town');
|
||||||
|
if (s.zone !== 'town') return this.sendSession(s, 'error', { code: 'range', message: 'Townsfolk are out in the district.' });
|
||||||
|
target = this.npcs.find(n => n.id === msg.targetId);
|
||||||
|
targetName = target ? target.name : '';
|
||||||
|
if (!zset || !zset.has(s)) return;
|
||||||
|
} else {
|
||||||
|
target = [...this.sessions].find(o => o.id === msg.targetId && o.zone === s.zone);
|
||||||
|
if (!target) return this.sendSession(s, 'error', { code: 'range', message: 'They are not here anymore.' });
|
||||||
|
targetName = target.profile.name; targetWs = target;
|
||||||
|
}
|
||||||
|
if (!target) return;
|
||||||
|
if (dist2d(s, target) > 4.5) return this.sendSession(s, 'error', { code: 'range', message: 'Too far away — walk closer first.' });
|
||||||
|
|
||||||
|
const rel = s.profile.relationships[relKeyOf(type, type === 'npc' ? msg.targetId : targetName)] || { f: 0, r: 0 };
|
||||||
|
if (rel.f < (action.req.f || 0) || rel.r < (action.req.r || 0))
|
||||||
|
return this.sendSession(s, 'error', { code: 'locked', message: `Your bond isn't strong enough yet (need ${Math.max(action.req.f || 0, action.req.r || 0)}).` });
|
||||||
|
|
||||||
|
if (action.cost) {
|
||||||
|
if (s.profile.money < action.cost) return this.sendSession(s, 'error', { code: 'poor', message: `Costs ${action.cost} TC.` });
|
||||||
|
s.profile.money -= action.cost;
|
||||||
|
this.sendSession(s, 'money', { amount: s.profile.money });
|
||||||
|
}
|
||||||
|
|
||||||
|
// apply relationship deltas (initiator gains slightly less romance than target loses none — both grow)
|
||||||
|
const df = action.df | 0, dr = action.dr | 0;
|
||||||
|
const relA = this.applyRelDelta(s.profile, type, type === 'npc' ? msg.targetId : targetName, { df, dr });
|
||||||
|
let relB = null;
|
||||||
|
if (type === 'p') relB = this.applyRelDelta(target.profile, 'p', s.profile.name, { df: Math.ceil(df * 0.8), dr });
|
||||||
|
|
||||||
|
s.socialCooldownAt[action.id] = now + 1500;
|
||||||
|
|
||||||
|
// visible outcome
|
||||||
|
const line = (action.lines && action.lines.length)
|
||||||
|
? action.lines[Math.floor(Math.random() * action.lines.length)].replace('{name}', targetName)
|
||||||
|
: '';
|
||||||
|
this.broadcastToZone(s.zone, 'socialFx', {
|
||||||
|
from: s.id, to: target.id ?? msg.targetId, anim: action.anim, group: action.group, emoji: action.group === 'Negative' ? '💢' : '💬',
|
||||||
|
lines: line ? [`${s.profile.name}: ${line}`] : [],
|
||||||
|
});
|
||||||
|
if (action.anim === 'dance') { s.profile.stats.dances++; }
|
||||||
|
|
||||||
|
// NPC reaction: chance to answer with a friendly/negative line based on resulting friendship
|
||||||
|
if (type === 'npc') {
|
||||||
|
const willRespond = Math.random() < 0.8;
|
||||||
|
if (willRespond) {
|
||||||
|
const good = (df + dr) >= 0 && relA.f >= 10;
|
||||||
|
const replies = good
|
||||||
|
? ['That made my day!', 'You are sweet, you know that?', 'LifeTown suits you.', 'Ha! Good one.', 'Same time tomorrow?', 'You always know what to say.']
|
||||||
|
: ['Wow. Just… wow.', 'I did NOT need that today.', 'Please be kinder.'];
|
||||||
|
const delay = 900 + Math.random() * 1200;
|
||||||
|
setTimeout(() => {
|
||||||
|
if (target) this.broadcastToZone('town', 'bubble', { id: target.id, text: replies[Math.floor(Math.random() * replies.length)] });
|
||||||
|
}, delay);
|
||||||
|
}
|
||||||
|
} else if (targetWs) {
|
||||||
|
this.pushRelUpdate(target);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.pushRelUpdate(s);
|
||||||
|
this.checkAchievements(s);
|
||||||
|
}
|
||||||
|
|
||||||
|
applyRelDelta(profile, type, id, { df, dr }) {
|
||||||
|
const key = relKeyOf(type, id);
|
||||||
|
const rel = profile.relationships[key] || { f: 0, r: 0 };
|
||||||
|
rel.f = clamp(rel.f + df, 0, REL_MAX);
|
||||||
|
rel.r = clamp(rel.r + dr, 0, REL_MAX);
|
||||||
|
const wasPartner = !!rel.partner;
|
||||||
|
if (!wasPartner && rel.r >= ROMANCE_PARTNER_AT) {
|
||||||
|
rel.partner = true;
|
||||||
|
profile.stats.partners = (profile.stats.partners || 0) + 1;
|
||||||
|
this.send(this.wsOf(profile), 'notify', { title: 'It\'s official!', text: `You and ${id} are partners now! 💞`, icon: '💞' });
|
||||||
|
}
|
||||||
|
if (wasPartner && rel.r < ROMANCE_PARTNER_AT - 15) rel.partner = false; // breakup by decay
|
||||||
|
profile.relationships[key] = rel;
|
||||||
|
return rel;
|
||||||
|
}
|
||||||
|
|
||||||
|
pushRelUpdate(s) {
|
||||||
|
this.sendSession(s, 'relUpdate', { relationships: s.profile.relationships });
|
||||||
|
}
|
||||||
|
|
||||||
|
wsOf(profile) {
|
||||||
|
const found = [...this.sessions].find(x => x.profile === profile);
|
||||||
|
return found ? found.ws : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------- jobs & economy -------------------------
|
||||||
|
jobSelect(s, msg) {
|
||||||
|
const job = JOBS.find(j => j.id === msg.jobId);
|
||||||
|
if (!job) return;
|
||||||
|
if (s.profile.job && s.profile.job.id === job.id) return;
|
||||||
|
const wasEmployed = !!s.profile.job;
|
||||||
|
s.profile.job = { id: job.id, rank: wasEmployed ? 0 : 0, xp: 0 };
|
||||||
|
this.sendSession(s, 'jobUpdate', { job: s.profile.job });
|
||||||
|
this.sendSession(s, 'notify', { title: 'New career!', text: `You are now a ${job.ranks[0]} at ${job.title}.`, icon: '💼' });
|
||||||
|
this.checkAchievements(s);
|
||||||
|
saveProfile(s.profile);
|
||||||
|
}
|
||||||
|
|
||||||
|
jobWork(s, msg) {
|
||||||
|
const p = s.profile;
|
||||||
|
if (!p.job) return this.sendSession(s, 'error', { code: 'nojob', message: 'Get a job at City Hall first!' });
|
||||||
|
const now = Date.now();
|
||||||
|
if (now - (p.workLastAt || 0) < WORK_COOLDOWN * 1000) {
|
||||||
|
const wait = Math.ceil((WORK_COOLDOWN * 1000 - (now - p.workLastAt)) / 1000);
|
||||||
|
return this.sendSession(s, 'error', { code: 'cooldown', message: `Next shift available in ${wait}s.` });
|
||||||
|
}
|
||||||
|
p.workLastAt = now;
|
||||||
|
const job = JOBS.find(j => j.id === p.job.id);
|
||||||
|
const rank = clamp(p.job.rank, 0, job.ranks.length - 1);
|
||||||
|
const pay = Math.round(job.basePay * (1 + rank * 0.55) * (0.95 + Math.random() * 0.1));
|
||||||
|
const xpGain = 12 + rank * 4;
|
||||||
|
p.job.xp += xpGain;
|
||||||
|
p.money += pay;
|
||||||
|
p.stats.shifts++;
|
||||||
|
let promoted = false;
|
||||||
|
while (p.job.rank < job.ranks.length - 1 && p.job.xp >= XP_PER_RANK[p.job.rank + 1]) {
|
||||||
|
p.job.rank++; promoted = true;
|
||||||
|
}
|
||||||
|
this.sendSession(s, 'workResult', { pay, xp: xpGain, promoted, rank: p.job.rank, ranks: job.ranks, money: p.money });
|
||||||
|
this.sendSession(s, 'money', { amount: p.money });
|
||||||
|
if (promoted) {
|
||||||
|
this.sendSession(s, 'notify', { title: 'Promotion!', text: `You are now ${job.ranks[p.job.rank]}!`, icon: '📈' });
|
||||||
|
p.level = Math.min(40, p.level + 1);
|
||||||
|
}
|
||||||
|
this.checkAchievements(s);
|
||||||
|
saveProfile(p);
|
||||||
|
}
|
||||||
|
|
||||||
|
buyItem(s, msg) {
|
||||||
|
const item = CATALOG_BY_ID[msg.itemId];
|
||||||
|
const p = s.profile;
|
||||||
|
if (!item) return;
|
||||||
|
if (p.level < item.lvl) return this.sendSession(s, 'error', { code: 'level', message: `Unlocks at level ${item.lvl}.` });
|
||||||
|
if (p.money < item.price) return this.sendSession(s, 'error', { code: 'poor', message: 'Not enough TownCoins.' });
|
||||||
|
p.money -= item.price;
|
||||||
|
p.inventory.push({ itemId: item.id });
|
||||||
|
this.sendSession(s, 'inventory', { inventory: p.inventory });
|
||||||
|
this.sendSession(s, 'money', { amount: p.money });
|
||||||
|
this.sendSession(s, 'notify', { title: 'Purchased', text: `${item.name} added to your inventory.`, icon: '🛒' });
|
||||||
|
saveProfile(p);
|
||||||
|
}
|
||||||
|
|
||||||
|
sellItem(s, msg) {
|
||||||
|
const p = s.profile;
|
||||||
|
const idx = p.inventory.findIndex(it => it.itemId === msg.itemId);
|
||||||
|
if (idx === -1) return;
|
||||||
|
const item = CATALOG_BY_ID[msg.itemId];
|
||||||
|
const refund = Math.round(item.price * 0.6);
|
||||||
|
p.inventory.splice(idx, 1);
|
||||||
|
p.money += refund;
|
||||||
|
this.sendSession(s, 'inventory', { inventory: p.inventory });
|
||||||
|
this.sendSession(s, 'money', { amount: p.money });
|
||||||
|
this.sendSession(s, 'notify', { title: 'Sold', text: `Sold ${item.name} for ${refund} TC.`, icon: '💰' });
|
||||||
|
saveProfile(p);
|
||||||
|
}
|
||||||
|
|
||||||
|
sellPlaced(s, msg) {
|
||||||
|
const item = CATALOG_BY_ID[msg.itemId];
|
||||||
|
if (!item) return;
|
||||||
|
const refund = Math.round(item.price * 0.6);
|
||||||
|
s.profile.money += refund;
|
||||||
|
this.sendSession(s, 'money', { amount: s.profile.money });
|
||||||
|
this.sendSession(s, 'notify', { title: 'Sold furniture', text: `Removed ${item.name} — refunded ${refund} TC.`, icon: '🗑' });
|
||||||
|
this.saveLayoutDebounced(s);
|
||||||
|
}
|
||||||
|
|
||||||
|
buyHouse(s, msg) {
|
||||||
|
const house = HOUSES.find(h => h.id === msg.houseId);
|
||||||
|
const p = s.profile;
|
||||||
|
if (!house) return;
|
||||||
|
if (p.housesOwned.includes(house.id)) return;
|
||||||
|
if (p.level < house.lvl) return this.sendSession(s, 'error', { code: 'level', message: `Unlocks at level ${house.lvl}.` });
|
||||||
|
if (p.money < house.price) return this.sendSession(s, 'error', { code: 'poor', message: 'Not enough TownCoins.' });
|
||||||
|
p.money -= house.price;
|
||||||
|
p.housesOwned.push(house.id);
|
||||||
|
p.houseTier = house.id;
|
||||||
|
if (!p.achievements.includes('first_home')) p.achievements.push('first_home');
|
||||||
|
this.sendSession(s, 'profile', { profile: this.clientProfile(p) });
|
||||||
|
this.sendSession(s, 'money', { amount: p.money });
|
||||||
|
this.sendSession(s, 'notify', { title: 'New home!', text: `You bought the ${house.name}! 🏡`, icon: '🏡' });
|
||||||
|
this.checkAchievements(s);
|
||||||
|
saveProfile(p);
|
||||||
|
}
|
||||||
|
|
||||||
|
placeHome(s, msg) {
|
||||||
|
const p = s.profile;
|
||||||
|
if (!Array.isArray(msg.layout)) return;
|
||||||
|
// validate: each placed item must exist in catalog; cap count; positions sane
|
||||||
|
const layout = msg.layout.slice(0, 120).filter(it =>
|
||||||
|
CATALOG_BY_ID[it?.itemId] &&
|
||||||
|
Number.isFinite(it.x) && Number.isFinite(it.z) &&
|
||||||
|
Math.abs(it.x) < 30 && Math.abs(it.z) < 30
|
||||||
|
).map(it => ({ itemId: it.itemId, x: +it.x.toFixed(2), z: +it.z.toFixed(2), rot: (+it.rot || 0) }));
|
||||||
|
p.homeLayout = layout;
|
||||||
|
this.sendSession(s, 'layoutSaved', { count: layout.length });
|
||||||
|
this.saveLayoutDebounced(s);
|
||||||
|
}
|
||||||
|
|
||||||
|
saveLayoutDebounced(s) {
|
||||||
|
clearTimeout(s._layoutSaveTimer);
|
||||||
|
s._layoutSaveTimer = setTimeout(() => { try { saveProfile(s.profile); } catch {} }, 2000);
|
||||||
|
this.checkAchievements(s);
|
||||||
|
}
|
||||||
|
|
||||||
|
needsSync(s, msg) {
|
||||||
|
const p = s.profile;
|
||||||
|
if (msg.needs && typeof msg.needs === 'object') {
|
||||||
|
p.needs = Object.fromEntries(NEEDS.map(k => [k, clamp(Number(msg.needs[k]) || 50, 0, 100)]));
|
||||||
|
}
|
||||||
|
if (Array.isArray(msg.moodlets)) p.moodlets = msg.moodlets.slice(0, 12);
|
||||||
|
if (Date.now() - s.lastNeedsSync > 25000) { s.lastNeedsSync = Date.now(); saveProfile(p); }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------- achievements ---------------------------
|
||||||
|
checkAchievements(s) {
|
||||||
|
const p = s.profile;
|
||||||
|
const grant = (id, title, text) => {
|
||||||
|
if (p.achievements.includes(id)) return;
|
||||||
|
p.achievements.push(id);
|
||||||
|
this.sendSession(s, 'achievement', { id, name: title, desc: text });
|
||||||
|
this.sendSession(s, 'profile', { profile: this.clientProfile(p) });
|
||||||
|
};
|
||||||
|
const defs = {
|
||||||
|
first_home: ['First Home', 'Buy your first house.'],
|
||||||
|
social_butterfly: ['Social Butterfly', 'Make 20 friends.'],
|
||||||
|
millionaire: ['Millionaire', 'Hold 1,000,000 TownCoins.'],
|
||||||
|
heartbreaker: ['Heartbreaker', 'Have 5 partners.'],
|
||||||
|
interior_designer: ['Interior Designer', 'Place 25 furniture items.'],
|
||||||
|
employed: ['Day One', 'Get your first job.'],
|
||||||
|
promoted: ['Moving On Up', 'Earn any promotion.'],
|
||||||
|
party_animal: ['Party Animal', 'Dance at 3 events.'],
|
||||||
|
};
|
||||||
|
if (p.housesOwned.length >= 1 && p.houseTier !== 'studio') grant('first_home', ...defs.first_home);
|
||||||
|
if (Object.values(p.relationships).filter(r => r.f >= 25).length >= 20) grant('social_butterfly', ...defs.social_butterfly);
|
||||||
|
if (p.money >= 1000000) grant('millionaire', ...defs.millionaire);
|
||||||
|
if ((p.stats.partners || 0) >= 5) grant('heartbreaker', ...defs.heartbreaker);
|
||||||
|
if ((p.homeLayout || []).length >= 25) grant('interior_designer', ...defs.interior_designer);
|
||||||
|
if (!!p.job) grant('employed', ...defs.employed);
|
||||||
|
if (p.job && p.job.rank >= 1) grant('promoted', ...defs.promoted);
|
||||||
|
if ((p.stats.dances || 0) >= 3) grant('party_animal', ...defs.party_animal);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------- main loops -----------------------------
|
||||||
|
tick() {
|
||||||
|
// NPC brains
|
||||||
|
const now = Date.now();
|
||||||
|
const { minutes } = this.gameTime();
|
||||||
|
const hour = minutes / 60;
|
||||||
|
for (const n of this.npcs) {
|
||||||
|
const def = NPCS.find(d => d.id === n.id);
|
||||||
|
// choose schedule stop for current hour
|
||||||
|
let stopId = def.schedule[0][1];
|
||||||
|
for (const [startH, poi] of def.schedule) if (hour >= startH) stopId = poi;
|
||||||
|
const anchor = AREA_POINTS[stopId] || { x: 0, z: -26 };
|
||||||
|
// deterministic per-npc offset so they don't stack
|
||||||
|
const seed = (n.id.charCodeAt(0) * 7 + n.id.length * 13) % 100 / 100;
|
||||||
|
const ox = Math.sin(now / 9000 + seed * 6.28) * 3.2;
|
||||||
|
const oz = Math.cos(now / 11000 + seed * 6.28) * 3.2;
|
||||||
|
const tx = anchor.x + ox, tz = anchor.z + oz;
|
||||||
|
|
||||||
|
if (!n.target || Math.hypot(n.tx - tx, n.tz - tz) > 2) { n.tx = tx; n.tz = tz; n.target = true; }
|
||||||
|
const dx = n.tx - n.x, dz = n.tz - n.z;
|
||||||
|
const d = Math.hypot(dx, dz);
|
||||||
|
if (d > 0.35) {
|
||||||
|
const sp = 1.9;
|
||||||
|
n.x += dx / d * sp * (TICK_MS / 1000);
|
||||||
|
n.z += dz / d * sp * (TICK_MS / 1000);
|
||||||
|
n.ry = Math.atan2(dx, dz);
|
||||||
|
n.anim = 'walk';
|
||||||
|
} else {
|
||||||
|
n.anim = now > n.waitUntil ? (Math.random() < 0.25 ? 'talk' : 'idle') : n.anim;
|
||||||
|
if (now > n.waitUntil) n.waitUntil = now + 4000 + Math.random() * 6000;
|
||||||
|
}
|
||||||
|
// ambient chatter
|
||||||
|
if (now > n.lineAt && n.anim !== 'walk') {
|
||||||
|
n.lineAt = now + 24000 + Math.random() * 26000;
|
||||||
|
const lines = [
|
||||||
|
'Lovely day in LifeTown!', 'Have you tried the taco truck?', 'I should redecorate my place…',
|
||||||
|
'The fountain sparkles at night.', 'Anyone up for dancing later?', 'Work, work, work…',
|
||||||
|
'I love the smell of café roast.', 'Rain soon, I can feel it.',
|
||||||
|
];
|
||||||
|
this.broadcastToZone('town', 'bubble', { id: n.id, text: lines[Math.floor(Math.random() * lines.length)] });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// snapshots per zone
|
||||||
|
const npcSnap = this.npcs.map(n => ({ id: n.id, x: +n.x.toFixed(2), z: +n.z.toFixed(2), ry: +n.ry.toFixed(2), anim: n.anim }));
|
||||||
|
for (const [zoneId, set] of this.byZone) {
|
||||||
|
const players = [...set].map(pl => ({ id: pl.id, x: +pl.x.toFixed(2), z: +pl.z.toFixed(2), ry: +(pl.ry || 0).toFixed(2), anim: pl.anim }));
|
||||||
|
const payload = JSON.stringify({
|
||||||
|
t: 'snapshot', zone: zoneId,
|
||||||
|
players,
|
||||||
|
npcs: zoneId === 'town' ? npcSnap : [],
|
||||||
|
});
|
||||||
|
for (const pl of set) if (pl.ws.readyState === WebSocket.OPEN) pl.ws.send(payload);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
slowTick() {
|
||||||
|
const time = this.gameTime();
|
||||||
|
// weather machine
|
||||||
|
if (Date.now() > this.nextWeatherRoll) {
|
||||||
|
this.nextWeatherRoll = Date.now() + 45000 + Math.random() * 75000;
|
||||||
|
if (Math.random() < 0.55) {
|
||||||
|
const weights = { sunny: 0.42, cloudy: 0.24, rain: 0.16, storm: 0.06, fog: 0.12 };
|
||||||
|
const roll = Math.random();
|
||||||
|
let acc = 0, next = this.weather;
|
||||||
|
for (const w of WEATHER_STATES) { acc += weights[w]; if (roll <= acc) { next = w; break; } }
|
||||||
|
if (next !== this.weather) {
|
||||||
|
this.weather = next;
|
||||||
|
this.broadcast('weather', { weather: next });
|
||||||
|
const labels = { sunny: 'The sun breaks through over LifeTown!', cloudy: 'Clouds drift in…', rain: 'Rain patters across Maple Court ☔', storm: 'A storm rolls in — stay cozy!', fog: 'A soft fog wraps the town.' };
|
||||||
|
this.broadcast('notify', { title: 'Weather', text: labels[next], icon: '🌤️' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const s of this.sessions) this.sendSession(s, 'time', { ...time, weather: this.weather });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
// LifeTown Online — entry point: static file server + WebSocket upgrade.
|
||||||
|
import http from 'node:http';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { WebSocketServer } from 'ws';
|
||||||
|
import { GameServer } from './game.mjs';
|
||||||
|
|
||||||
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
const DIST = path.join(__dirname, '..', 'client', 'dist');
|
||||||
|
const PORT = Number(process.env.GAME_PORT || 7788);
|
||||||
|
|
||||||
|
const MIME = {
|
||||||
|
'.html': 'text/html; charset=utf-8', '.js': 'text/javascript', '.mjs': 'text/javascript',
|
||||||
|
'.css': 'text/css', '.json': 'application/json', '.png': 'image/png', '.jpg': 'image/jpeg',
|
||||||
|
'.svg': 'image/svg+xml', '.ico': 'image/x-icon', '.wasm': 'application/wasm',
|
||||||
|
'.woff2': 'font/woff2', '.mp3': 'audio/mpeg', '.ogg': 'audio/ogg', '.webp': 'image/webp',
|
||||||
|
};
|
||||||
|
|
||||||
|
function serveStatic(req, res) {
|
||||||
|
let urlPath = decodeURIComponent(new URL(req.url, 'http://x').pathname);
|
||||||
|
if (urlPath === '/') urlPath = '/index.html';
|
||||||
|
let filePath = path.normalize(path.join(DIST, urlPath));
|
||||||
|
if (!filePath.startsWith(DIST)) { res.writeHead(403); res.end('Forbidden'); return; }
|
||||||
|
if (!fs.existsSync(filePath) || !fs.statSync(filePath).isFile()) {
|
||||||
|
// SPA fallback
|
||||||
|
filePath = path.join(DIST, 'index.html');
|
||||||
|
if (!fs.existsSync(filePath)) { res.writeHead(503); res.end('LifeTown client is not built yet. Run: npm run build'); return; }
|
||||||
|
}
|
||||||
|
const ext = path.extname(filePath).toLowerCase();
|
||||||
|
res.writeHead(200, {
|
||||||
|
'Content-Type': MIME[ext] || 'application/octet-stream',
|
||||||
|
'Cache-Control': ext === '.html' ? 'no-cache' : 'public, max-age=3600',
|
||||||
|
});
|
||||||
|
fs.createReadStream(filePath).pipe(res);
|
||||||
|
}
|
||||||
|
|
||||||
|
const server = http.createServer(serveStatic);
|
||||||
|
|
||||||
|
// Single WebSocket endpoint: /ws
|
||||||
|
const wss = new WebSocketServer({ noServer: true });
|
||||||
|
server.on('upgrade', (req, socket, head) => {
|
||||||
|
const { pathname } = new URL(req.url, 'http://x');
|
||||||
|
if (pathname === '/ws') {
|
||||||
|
wss.handleUpgrade(req, socket, head, (ws) => game.onConnection(ws));
|
||||||
|
} else {
|
||||||
|
socket.destroy();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const game = new GameServer(wss);
|
||||||
|
|
||||||
|
server.listen(PORT, () => {
|
||||||
|
console.log(`[LifeTown] server listening on http://127.0.0.1:${PORT}`);
|
||||||
|
});
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
// LifeTown Online — JSON-file persistence for player profiles.
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import crypto from 'node:crypto';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { START_MONEY, HOUSES } from '../shared/data.mjs';
|
||||||
|
|
||||||
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
const SAVE_DIR = path.join(__dirname, '..', 'saves');
|
||||||
|
fs.mkdirSync(SAVE_DIR, { recursive: true });
|
||||||
|
|
||||||
|
function sanitizeName(name) {
|
||||||
|
return String(name || '').trim().replace(/[^A-Za-z0-9_\- ]/g, '').slice(0, 20);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function newProfile(name, appearance) {
|
||||||
|
const safe = sanitizeName(name);
|
||||||
|
return {
|
||||||
|
name: safe,
|
||||||
|
token: crypto.randomBytes(16).toString('hex'),
|
||||||
|
createdAt: Date.now(),
|
||||||
|
appearance, // character creator output (client-defined schema)
|
||||||
|
money: START_MONEY,
|
||||||
|
level: 1, xp: 0, // player level
|
||||||
|
job: null, // { id, rank, xp }
|
||||||
|
workLastAt: 0,
|
||||||
|
houseTier: 'studio', // owned house tier id
|
||||||
|
housesOwned: ['studio'],
|
||||||
|
homeLayout: [], // [{itemId,x,z,rot}]
|
||||||
|
inventory: [], // [{itemId}] purchased, not yet placed
|
||||||
|
relationships: {}, // npcId|playerName -> {f,r,status}
|
||||||
|
achievements: [],
|
||||||
|
stats: { shifts: 0, dances: 0, giftsSent: 0 },
|
||||||
|
needs: null, // client-simulated; stored for continuity
|
||||||
|
moodlets: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadProfile(name) {
|
||||||
|
const safe = sanitizeName(name);
|
||||||
|
const file = path.join(SAVE_DIR, `${safe.toLowerCase()}.json`);
|
||||||
|
try {
|
||||||
|
if (!fs.existsSync(file)) return null;
|
||||||
|
const raw = JSON.parse(fs.readFileSync(file, 'utf8'));
|
||||||
|
if (!raw || raw.name !== safe) return null;
|
||||||
|
// migrate defaults
|
||||||
|
const base = newProfile(safe, raw.appearance || defaultAppearance());
|
||||||
|
const merged = { ...base, ...raw, name: safe };
|
||||||
|
if (!Array.isArray(merged.homeLayout)) merged.homeLayout = [];
|
||||||
|
if (!Array.isArray(merged.inventory)) merged.inventory = [];
|
||||||
|
if (!Array.isArray(merged.achievements)) merged.achievements = [];
|
||||||
|
if (typeof merged.relationships !== 'object' || !merged.relationships) merged.relationships = {};
|
||||||
|
if (typeof merged.stats !== 'object' || !merged.stats) merged.stats = { shifts: 0, dances: 0, giftsSent: 0 };
|
||||||
|
return merged;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveProfile(profile) {
|
||||||
|
const file = path.join(SAVE_DIR, `${profile.name.toLowerCase()}.json`);
|
||||||
|
const tmp = file + '.tmp';
|
||||||
|
fs.writeFileSync(tmp, JSON.stringify(profile));
|
||||||
|
fs.renameSync(tmp, file);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function defaultAppearance() {
|
||||||
|
return {
|
||||||
|
bodyType: 'average', height: 1, 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function findProfileByToken(token) {
|
||||||
|
if (!token || typeof token !== 'string') return null;
|
||||||
|
for (const f of fs.readdirSync(SAVE_DIR)) {
|
||||||
|
if (!f.endsWith('.json')) continue;
|
||||||
|
try {
|
||||||
|
const p = JSON.parse(fs.readFileSync(path.join(SAVE_DIR, f), 'utf8'));
|
||||||
|
if (p.token === token) return p;
|
||||||
|
} catch { /* skip corrupt */ }
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listProfileNames() {
|
||||||
|
try {
|
||||||
|
return fs.readdirSync(SAVE_DIR).filter(f => f.endsWith('.json')).map(f => {
|
||||||
|
try { return JSON.parse(fs.readFileSync(path.join(SAVE_DIR, f), 'utf8')).name; } catch { return null; }
|
||||||
|
}).filter(Boolean);
|
||||||
|
} catch { return []; }
|
||||||
|
}
|
||||||
|
|
||||||
|
export { sanitizeName };
|
||||||
+387
@@ -0,0 +1,387 @@
|
|||||||
|
// ============================================================
|
||||||
|
// LifeTown Online — shared game data (single source of truth)
|
||||||
|
// Imported by both the Node server and the browser client.
|
||||||
|
// All content is original to LifeTown Online.
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
export const GAME_NAME = 'LifeTown Online';
|
||||||
|
export const DISTRICT_NAME = 'Maple Court';
|
||||||
|
export const TIME_SCALE = 60; // 1 real second = 1 game minute -> full day = 24 min
|
||||||
|
export const DAY_MINUTES = 1440;
|
||||||
|
export const START_MONEY = 1500;
|
||||||
|
|
||||||
|
// ---------------- World layout (metres) ---------------------
|
||||||
|
export const WORLD = {
|
||||||
|
size: 150, // playable half-extent reference
|
||||||
|
roadX: { from: -70, to: 70, z: 0, width: 8 }, // main street along X
|
||||||
|
sidewalkZ: 6,
|
||||||
|
plaza: { x: 0, z: -26, r: 14 }, // central plaza + fountain
|
||||||
|
park: { x: 42, z: -30, w: 44, d: 40 }, // park with pond
|
||||||
|
spawn: { x: 0, z: 12 },
|
||||||
|
};
|
||||||
|
|
||||||
|
// Points of interest: where players/NPCs can do things.
|
||||||
|
// kind: sit | eat | work | exercise | shop | fish | stage | jobs | fountain | swim
|
||||||
|
export const POIS = [
|
||||||
|
{ id: 'fountain', name: 'Copper Fountain', x: 0, z: -26, kind: 'fountain', radius: 3 },
|
||||||
|
{ id: 'stage', name: 'Plaza Stage', x: -18, z: -34, kind: 'stage', radius: 4 },
|
||||||
|
{ id: 'bench_park', name: 'Park Bench', x: 36, z: -22, kind: 'sit', radius: 2 },
|
||||||
|
{ id: 'bench_2', name: 'Street Bench', x: -30, z: 9, kind: 'sit', radius: 2 },
|
||||||
|
{ id: 'cafe', name: 'Daily Grind Café', x: -48, z: -14, kind: 'eat', radius: 3, jobWorkspot: true },
|
||||||
|
{ id: 'cityhall', name: 'City Hall Desk', x: 24, z: -16, kind: 'jobs', radius: 3 },
|
||||||
|
{ id: 'gym', name: 'Flex Den Gym', x: 54, z: -14, kind: 'exercise', radius: 3, jobWorkspot: true },
|
||||||
|
{ id: 'store', name: 'MapleMart Home Store',x: -66, z: -16, kind: 'shop', radius: 3 },
|
||||||
|
{ id: 'foodtruck', name: 'Taco Comet Truck', x: 12, z: 10, kind: 'eat', radius: 3 },
|
||||||
|
{ id: 'pond', name: 'Willow Pond', x: 46, z: -40, kind: 'fish', radius: 4 },
|
||||||
|
];
|
||||||
|
|
||||||
|
// Buildings placed along the street (visual shells on the client).
|
||||||
|
export const BUILDINGS = [
|
||||||
|
{ id: 'cafe', name: 'Daily Grind Café', x: -48, z: -26, w: 16, d: 14, color: 0xf2b06b, roof: 0xd96c4f, sign: 'CAFÉ' },
|
||||||
|
{ id: 'cityhall', name: 'LifeTown City Hall', x: 24, z: -28, w: 22, d: 16, color: 0xe8e3d5, roof: 0x7fa88b, sign: 'CITY HALL' },
|
||||||
|
{ id: 'gym', name: 'Flex Den Gym', x: 54, z: -26, w: 16, d: 14, color: 0xc9d7f2, roof: 0x5b74a8, sign: 'GYM' },
|
||||||
|
{ id: 'store', name: 'MapleMart', x: -66, z: -28, w: 18, d: 14, color: 0xf7cdd3, roof: 0xb85c6e, sign: 'MAPLEMART' },
|
||||||
|
{ id: 'apt_a', name: 'Birch Apartments', x: -20, z: 22, w: 18, d: 14, color: 0xd9c9a8, roof: 0x8a6f56, sign: 'APARTMENTS' },
|
||||||
|
{ id: 'apt_b', name: 'Rowan Lofts', x: 6, z: 22, w: 18, d: 14, color: 0xcfd8c2, roof: 0x6f7f5a, sign: 'LOFTS' },
|
||||||
|
{ id: 'arcade', name: 'Pixel Porcupine Arcade', x: 38, z: 22, w: 16, d: 14, color: 0xd7c4ef, roof: 0x8a63b8, sign: 'ARCADE' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// ---------------- Building doors & enterable venues ----------------
|
||||||
|
// Doors sit just outside each building's street-facing entrance.
|
||||||
|
export const DOORS = [
|
||||||
|
{ id: 'door_cafe', x: -48, z: -18.3, label: 'Enter Daily Grind Café', zone: 'venue:cafe' },
|
||||||
|
{ id: 'door_cityhall', x: 24, z: -19.2, label: 'Enter City Hall', zone: 'venue:cityhall' },
|
||||||
|
{ id: 'door_gym', x: 54, z: -18.3, label: 'Enter Flex Den Gym', zone: 'venue:gym' },
|
||||||
|
{ id: 'door_store', x: -66, z: -18.3, label: 'Enter MapleMart', zone: 'venue:store' },
|
||||||
|
{ id: 'door_arcade', x: 38, z: 13.9, label: 'Enter Pixel Porcupine Arcade', zone: 'venue:arcade' },
|
||||||
|
{ id: 'door_apt_a', x: -20, z: 13.9, label: 'Enter Your Apartment', zone: 'home:self' },
|
||||||
|
{ id: 'door_apt_b', x: 6, z: 13.9, label: 'Enter Your Apartment', zone: 'home:self' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// Public venue interiors: shared rooms every resident can walk into.
|
||||||
|
export const VENUES = {
|
||||||
|
'venue:cafe': {
|
||||||
|
name: 'Daily Grind Café', size: [14, 10],
|
||||||
|
items: [
|
||||||
|
{ itemId: 'counter_kitchen', x: -2.5, z: -3.6, rot: 0 },
|
||||||
|
{ itemId: 'counter_kitchen', x: -0.5, z: -3.6, rot: 0 },
|
||||||
|
{ itemId: 'coffeemaker', x: 0.6, z: -3.7, rot: 0 },
|
||||||
|
{ itemId: 'microwave', x: -3.4, z: -3.7, rot: 0 },
|
||||||
|
{ itemId: 'table_dining', x: -4, z: 0.5, rot: 0 }, { itemId: 'stool', x: -4, z: 1.6, rot: 0 }, { itemId: 'stool', x: -4, z: -0.6, rot: 0 },
|
||||||
|
{ itemId: 'table_dining', x: 0.5, z: 0.5, rot: 0 }, { itemId: 'stool', x: 0.5, z: 1.6, rot: 0 }, { itemId: 'stool', x: 0.5, z: -0.6, rot: 0 },
|
||||||
|
{ itemId: 'loveseat', x: 5, z: 2.5, rot: Math.PI / 2 },
|
||||||
|
{ itemId: 'plant_monstera', x: 6, z: -3.5, rot: 0 },
|
||||||
|
{ itemId: 'paint_meadow', x: 0, z: -4.85, rot: 0 },
|
||||||
|
{ itemId: 'lamp_floor', x: 6.2, z: 3.8, rot: 0 },
|
||||||
|
{ itemId: 'rug_round', x: 0.25, z: 0.5, rot: 0 },
|
||||||
|
],
|
||||||
|
spots: [
|
||||||
|
{ x: -1.5, z: -2.6, label: 'Order a coffee ☕', act: 'eat' },
|
||||||
|
{ x: 5.5, z: 1.6, label: 'Lounge on the Loveseat', act: 'sit' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
'venue:cityhall': {
|
||||||
|
name: 'LifeTown City Hall', size: [16, 11],
|
||||||
|
items: [
|
||||||
|
{ itemId: 'table_desk', x: -3, z: -3, rot: 0 }, { itemId: 'chair_office', x: -3, z: -2.2, rot: Math.PI },
|
||||||
|
{ itemId: 'table_desk', x: 3, z: -3, rot: 0 }, { itemId: 'chair_office', x: 3, z: -2.2, rot: Math.PI },
|
||||||
|
{ itemId: 'shelf_books', x: -6.5, z: -4.6, rot: 0 },
|
||||||
|
{ itemId: 'shelf_cube', x: 6.5, z: -4.6, rot: 0 },
|
||||||
|
{ itemId: 'plant_tree', x: 0, z: -4.4, rot: 0 },
|
||||||
|
{ itemId: 'decor_clock', x: 0, z: -5.35, rot: 0 },
|
||||||
|
{ itemId: 'rug_long', x: 0, z: 1.5, rot: 0 },
|
||||||
|
{ itemId: 'lamp_arc', x: 0, z: 3.4, rot: 0 },
|
||||||
|
],
|
||||||
|
spots: [
|
||||||
|
{ x: -3, z: -1.6, label: 'Speak to the Career Desk 💼', act: 'jobs' },
|
||||||
|
{ x: 3, z: -1.6, label: 'Browse Property Records 🏡', act: 'property' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
'venue:gym': {
|
||||||
|
name: 'Flex Den Gym', size: [14, 10],
|
||||||
|
items: [
|
||||||
|
{ itemId: 'rug_shag', x: -4, z: -2.5, rot: 0 },
|
||||||
|
{ itemId: 'rug_shag', x: 4, z: -2.5, rot: 0 },
|
||||||
|
{ itemId: 'speaker', x: 0, z: -4.3, rot: 0 },
|
||||||
|
{ itemId: 'fridge_dual', x: -6.2, z: -4, rot: 0 },
|
||||||
|
{ itemId: 'plant_fern', x: 6.2, z: -4.2, rot: 0 },
|
||||||
|
{ itemId: 'tv_oled', x: 0, z: -4.75, rot: 0 },
|
||||||
|
{ itemId: 'sofa_modern', x: 5.5, z: 3.4, rot: Math.PI },
|
||||||
|
],
|
||||||
|
spots: [
|
||||||
|
{ x: -4, z: -1.4, label: 'Hit the Mats 🏋️', act: 'exercise' },
|
||||||
|
{ x: 4, z: -1.4, label: 'Cardio Corner 🏃', act: 'exercise' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
'venue:store': {
|
||||||
|
name: 'MapleMart Home Store', size: [16, 11],
|
||||||
|
items: [
|
||||||
|
{ itemId: 'shelf_cube', x: -5, z: -3.8, rot: 0 }, { itemId: 'shelf_cube', x: -3, z: -3.8, rot: 0 },
|
||||||
|
{ itemId: 'shelf_books', x: 5, z: -3.8, rot: 0 },
|
||||||
|
{ itemId: 'wardrobe_pine', x: 6.4, z: 0.5, rot: -Math.PI / 2 },
|
||||||
|
{ itemId: 'bed_cozy', x: -6, z: 1.5, rot: 0 },
|
||||||
|
{ itemId: 'table_coffee', x: 0.5, z: 0.8, rot: 0 },
|
||||||
|
{ itemId: 'sofa_basic', x: 2.5, z: 2.8, rot: Math.PI },
|
||||||
|
{ itemId: 'light_neon', x: 0, z: -5.2, rot: 0 },
|
||||||
|
{ itemId: 'plant_cactus', x: -6.8, z: -4.4, rot: 0 },
|
||||||
|
{ itemId: 'rug_round', x: 0.5, z: 0.8, rot: 0 },
|
||||||
|
],
|
||||||
|
spots: [
|
||||||
|
{ x: 0, z: -2.6, label: 'Browse the Catalogue 🛒', act: 'shop' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
'venue:arcade': {
|
||||||
|
name: 'Pixel Porcupine Arcade', size: [13, 10],
|
||||||
|
items: [
|
||||||
|
{ itemId: 'pc_tower', x: -4, z: -3.6, rot: 0 },
|
||||||
|
{ itemId: 'console', x: 0, z: -3.6, rot: 0 },
|
||||||
|
{ itemId: 'tv_flat', x: 0, z: -4.2, rot: 0 },
|
||||||
|
{ itemId: 'keyboard_elec', x: 4, z: -3.6, rot: 0 },
|
||||||
|
{ itemId: 'light_neon', x: -2.5, z: -5.05, rot: 0 },
|
||||||
|
{ itemId: 'light_neon', x: 2.5, z: -5.05, rot: 0 },
|
||||||
|
{ itemId: 'speaker', x: -6, z: -4.2, rot: 0 },
|
||||||
|
{ itemId: 'stool', x: -4, z: -2.6, rot: 0 }, { itemId: 'stool', x: 0, z: -2.6, rot: 0 }, { itemId: 'stool', x: 4, z: -2.6, rot: 0 },
|
||||||
|
{ itemId: 'rug_long', x: 0, z: 0.5, rot: 0 },
|
||||||
|
],
|
||||||
|
spots: [
|
||||||
|
{ x: 0, z: -2, label: 'Play the Cabinets 🕹️', act: 'arcade' },
|
||||||
|
{ x: 4.5, z: -2, label: 'Dance Machine 💃', act: 'stage' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------------- Personality traits ------------------------
|
||||||
|
export const TRAITS = ['Friendly','Romantic','Ambitious','Lazy','Creative','Athletic','Intelligent','Funny','Introverted','Extroverted','Generous','Competitive'];
|
||||||
|
|
||||||
|
// ---------------- Jobs / careers ----------------------------
|
||||||
|
// pay is TownCoins per work shift; xp accumulates toward promotion.
|
||||||
|
export const JOBS = [
|
||||||
|
{ id:'barista', title:'Barista', poi:'cafe', basePay: 55, hours:[6,14],
|
||||||
|
ranks:['Trainee Barista','Barista','Head Barista','Café Manager','Coffee Boss'] },
|
||||||
|
{ id:'chef', title:'Chef', poi:'foodtruck', basePay: 70, hours:[10,19],
|
||||||
|
ranks:['Kitchen Hand','Line Cook','Sous Chef','Head Chef','Star Chef'] },
|
||||||
|
{ id:'programmer', title:'Programmer', poi:'cityhall',basePay: 90, hours:[9,17],
|
||||||
|
ranks:['Junior Developer','Software Engineer','Senior Engineer','Lead Architect','Tech Executive'] },
|
||||||
|
{ id:'designer', title:'Designer', poi:'store', basePay: 75, hours:[10,18],
|
||||||
|
ranks:['Design Intern','Junior Designer','Product Designer','Art Director','Creative Director'] },
|
||||||
|
{ id:'doctor', title:'Doctor', poi:'gym', basePay: 110, hours:[8,16],
|
||||||
|
ranks:['Resident','Physician','Surgeon','Chief of Staff','Medical Director'] },
|
||||||
|
{ id:'teacher', title:'Teacher', poi:'cityhall',basePay: 65, hours:[8,15],
|
||||||
|
ranks:['Substitute','Teacher','Senior Teacher','Dean','Principal'] },
|
||||||
|
{ id:'police', title:'Police Officer', poi:'cityhall',basePay: 80, hours:[7,15],
|
||||||
|
ranks:['Cadet','Officer','Sergeant','Lieutenant','Commissioner'] },
|
||||||
|
{ id:'engineer', title:'Engineer', poi:'gym', basePay: 95, hours:[9,17],
|
||||||
|
ranks:['Assistant Engineer','Engineer','Project Engineer','Principal Engineer','Director of Works'] },
|
||||||
|
{ id:'musician', title:'Musician', poi:'stage', basePay: 50, hours:[17,23],
|
||||||
|
ranks:['Street Performer','Gig Musician','Band Leader','Recording Artist','Headliner'] },
|
||||||
|
{ id:'influencer', title:'Influencer', poi:'plaza', basePay: 45, hours:[11,19],
|
||||||
|
ranks:['Newcomer','Micro-Influencer','Trendsetter','Star Creator','Town Icon'] },
|
||||||
|
{ id:'athlete', title:'Athlete', poi:'gym', basePay: 60, hours:[7,13],
|
||||||
|
ranks:['Rookie','Amateur','Pro','Champion','Legend'] },
|
||||||
|
{ id:'artist', title:'Artist', poi:'pond', basePay: 40, hours:[10,16],
|
||||||
|
ranks:['Sketcher','Painter','Gallery Artist','Master Artist','Living Legend'] },
|
||||||
|
];
|
||||||
|
export const XP_PER_RANK = [0, 100, 260, 520, 900]; // cumulative xp needed to reach rank i
|
||||||
|
export const WORK_SECONDS = 6; // real seconds per shift
|
||||||
|
export const WORK_COOLDOWN = 25; // real seconds between shifts (server-enforced)
|
||||||
|
|
||||||
|
// ---------------- Needs ------------------------------------
|
||||||
|
export const NEEDS = ['hunger','energy','hygiene','fun','social','comfort']; // 0..100
|
||||||
|
export const NEED_DECAY_PER_GAME_HOUR = {
|
||||||
|
hunger: 4.2, energy: 3.6, hygiene: 2.4, fun: 3.0, social: 2.6, comfort: 2.0,
|
||||||
|
};
|
||||||
|
export const MOOD_NEEDS = [ // weight of each need into mood (-100..100 scale)
|
||||||
|
['hunger',1.0],['energy',0.8],['hygiene',0.5],['fun',1.0],['social',0.8],['comfort',0.6]
|
||||||
|
];
|
||||||
|
|
||||||
|
// ---------------- Furniture catalogue -----------------------
|
||||||
|
// fx: multipliers/capacities used by interactable objects at home.
|
||||||
|
// cat: bed sofa table chair tv pc fridge stove shower toilet sink wardrobe shelf plant decor light rug paint elec
|
||||||
|
export const CATALOG = [
|
||||||
|
// Beds
|
||||||
|
{ id:'bed_basic', cat:'bed', name:'Dreamer Single Bed', price:180, lvl:1, w:2, d:3, fx:{ energyRate:1.0 } },
|
||||||
|
{ id:'bed_cozy', cat:'bed', name:'Cloudline Double Bed', price:420, lvl:2, w:2.4, d:3, fx:{ energyRate:1.2 } },
|
||||||
|
{ id:'bed_lux', cat:'bed', name:'Aurora Velvet Bed', price:980, lvl:4, w:2.6, d:3.2, fx:{ energyRate:1.45 } },
|
||||||
|
{ id:'bed_kids', cat:'bed', name:'Rocket Bunk Bed', price:260, lvl:1, w:2.2, d:3, fx:{ energyRate:1.05 } },
|
||||||
|
// Sofas
|
||||||
|
{ id:'sofa_basic', cat:'sofa', name:'Comfi Couch', price:240, lvl:1, w:3, d:1.4, fx:{ comfortRate:1.1 } },
|
||||||
|
{ id:'sofa_modern',cat:'sofa', name:'Pebble Modular Sofa', price:520, lvl:3, w:3.4, d:1.6, fx:{ comfortRate:1.25 } },
|
||||||
|
{ id:'loveseat', cat:'sofa', name:'Sweetheart Loveseat', price:300, lvl:2, w:2.2, d:1.3, fx:{ comfortRate:1.15, romanceAura:true } },
|
||||||
|
{ id:'sofa_lux', cat:'sofa', name:'Velvet Cloud Sectional', price:1100, lvl:5, w:3.8, d:1.8, fx:{ comfortRate:1.4 } },
|
||||||
|
// Tables
|
||||||
|
{ id:'table_dining',cat:'table', name:'Oakmeet Dining Table', price:220, lvl:1, w:2.6, d:1.6, fx:{} },
|
||||||
|
{ id:'table_coffee',cat:'table',name:'Roundabout Coffee Table', price:130, lvl:1, w:1.4, d:1.4, fx:{ comfortRate:1.05 } },
|
||||||
|
{ id:'table_desk', cat:'table', name:'Focus Desk', price:190, lvl:1, w:2, d:1, fx:{ productivity:1.1 } },
|
||||||
|
// Chairs
|
||||||
|
{ id:'chair_wood', cat:'chair', name:'Simple Chair', price:60, lvl:1, w:0.9, d:0.9, fx:{} },
|
||||||
|
{ id:'chair_office',cat:'chair',name:'Ergo Glide Chair', price:170, lvl:2, w:1, d:1, fx:{ productivity:1.15 } },
|
||||||
|
{ id:'stool', cat:'chair', name:'Pop Stool', price:45, lvl:1, w:0.7, d:0.7, fx:{} },
|
||||||
|
// TV & electronics
|
||||||
|
{ id:'tv_flat', cat:'tv', name:'SlimView 40"', price:400, lvl:2, w:1.6, d:0.4, fx:{ funRate:1.2 } },
|
||||||
|
{ id:'tv_oled', cat:'tv', name:'CinemaWall 65"', price:1400, lvl:5, w:2.2, d:0.5, fx:{ funRate:1.5 } },
|
||||||
|
{ id:'pc_tower', cat:'pc', name:'Quanta PC Setup', price:800, lvl:3, w:1.8, d:0.8, fx:{ funRate:1.25, productivity:1.3 } },
|
||||||
|
{ id:'console', cat:'elec', name:'PlayBox Console', price:350, lvl:2, w:0.6, d:0.4, fx:{ funRate:1.3 } },
|
||||||
|
{ id:'speaker', cat:'elec', name:'BoomBerry Speaker', price:150, lvl:1, w:0.5, d:0.5, fx:{ funRate:1.1 } },
|
||||||
|
{ id:'fridge_retro',cat:'fridge',name:'Mint Fridge', price:320, lvl:1, w:1.2, d:1, fx:{ hungerRate:1.1 } },
|
||||||
|
{ id:'fridge_dual',cat:'fridge',name:'TwinFrost Deluxe', price:700, lvl:3, w:1.4, d:1, fx:{ hungerRate:1.25 } },
|
||||||
|
{ id:'stove_basic',cat:'stove',name: 'HeatWhirl Oven', price:290, lvl:1, w:1.2, d:0.9, fx:{ cookSpeed:1.0 } },
|
||||||
|
{ id:'stove_pro', cat:'stove',name:'ChefBlaze Range', price:650, lvl:3, w:1.4, d:0.9, fx:{ cookSpeed:1.35 } },
|
||||||
|
// Bathroom
|
||||||
|
{ id:'shower_std', cat:'shower',name:'RainRing Shower', price:260, lvl:1, w:1.2, d:1.2, fx:{ hygieneRate:1.0 } },
|
||||||
|
{ id:'shower_lux', cat:'shower',name:'Falls Spa Shower', price:600, lvl:4, w:1.4, d:1.4, fx:{ hygieneRate:1.35 } },
|
||||||
|
{ id:'toilet_std', cat:'toilet',name:'Porcelain Throne', price:140, lvl:1, w:0.9, d:1.1, fx:{} },
|
||||||
|
{ id:'sink_std', cat:'sink', name:'Bubble Basin', price:110, lvl:1, w:1, d:0.6, fx:{ hygieneRate:1.08 } },
|
||||||
|
{ id:'bath_tub', cat:'shower',name:'Soakstar Tub', price:480, lvl:3, w:1.8, d:1, fx:{ hygieneRate:1.2, comfortRate:1.05 } },
|
||||||
|
// Storage
|
||||||
|
{ id:'wardrobe_pine',cat:'wardrobe',name:'Pinewood Wardrobe', price:210, lvl:1, w:1.6, d:0.8, fx:{} },
|
||||||
|
{ id:'wardrobe_walk',cat:'wardrobe',name:'Walk-In Closet Set', price:850, lvl:5, w:2.6, d:1.2, fx:{ comfortRate:1.1 } },
|
||||||
|
{ id:'shelf_books',cat:'shelf', name:'Storyline Bookshelf', price:160, lvl:1, w:1.6, d:0.5, fx:{ funRate:1.05 } },
|
||||||
|
{ id:'shelf_cube', cat:'shelf', name:'CubeGrid Shelving', price:190, lvl:2, w:1.8, d:0.5, fx:{} },
|
||||||
|
{ id:'dresser', cat:'wardrobe',name:'Morningview Dresser', price:180, lvl:1, w:1.4, d:0.7, fx:{} },
|
||||||
|
// Plants & decor
|
||||||
|
{ id:'plant_fern', cat:'plant', name:'Feather Fern', price:40, lvl:1, w:0.8, d:0.8, fx:{ comfortRate:1.03 } },
|
||||||
|
{ id:'plant_monstera',cat:'plant',name:'Monstera Magnum', price:90, lvl:2, w:1, d:1, fx:{ comfortRate:1.06 } },
|
||||||
|
{ id:'plant_cactus',cat:'plant',name:'Spike Buddy Cactus', price:30, lvl:1, w:0.5, d:0.5, fx:{} },
|
||||||
|
{ id:'plant_tree', cat:'plant', name:'Indoor Fig Tree', price:140, lvl:2, w:1.2, d:1.2, fx:{ comfortRate:1.08 } },
|
||||||
|
{ id:'decor_vase', cat:'decor', name:'Sunset Vase', price:55, lvl:1, w:0.5, d:0.5, fx:{} },
|
||||||
|
{ id:'decor_clock',cat:'decor', name:'Ticktock Wall Clock', price:70, lvl:1, w:0.6, d:0.2, fx:{} },
|
||||||
|
{ id:'decor_mirror',cat:'decor',name:'Glimmer Mirror', price:120, lvl:2, w:1, d:0.2, fx:{ hygieneRate:1.05 } },
|
||||||
|
{ id:'decor_aquarium',cat:'decor',name:'Ripple Aquarium', price:380, lvl:3, w:1.2, d:0.6, fx:{ funRate:1.15, comfortRate:1.05 } },
|
||||||
|
{ id:'rug_round', cat:'rug', name:'Circle Breeze Rug', price:80, lvl:1, w:2.4, d:2.4, fx:{ comfortRate:1.04 } },
|
||||||
|
{ id:'rug_long', cat:'rug', name:'Hallway Runner', price:65, lvl:1, w:3, d:1.2, fx:{} },
|
||||||
|
{ id:'rug_shag', cat:'rug', name:'Cloudpile Shag Rug', price:150, lvl:2, w:2.8, d:2, fx:{ comfortRate:1.08 } },
|
||||||
|
// Lighting
|
||||||
|
{ id:'lamp_floor', cat:'light',name:'Halo Floor Lamp', price:95, lvl:1, w:0.7, d:0.7, fx:{ comfortRate:1.04 } },
|
||||||
|
{ id:'lamp_table', cat:'light',name:'Dusk Table Lamp', price:60, lvl:1, w:0.5, d:0.5, fx:{} },
|
||||||
|
{ id:'lamp_arc', cat:'light',name:'Arcline Lamp', price:230, lvl:3, w:1.2, d:0.6, fx:{ comfortRate:1.08 } },
|
||||||
|
{ id:'light_neon', cat:'light',name:'NeonWave Strip', price:180, lvl:2, w:2, d:0.2, fx:{ funRate:1.1 } },
|
||||||
|
// Paintings
|
||||||
|
{ id:'paint_meadow',cat:'paint',name:'Meadow Morning Print', price:85, lvl:1, w:1.2, d:0.1, fx:{ comfortRate:1.04 } },
|
||||||
|
{ id:'paint_abstract',cat:'paint',name:'Confetti Abstract', price:130, lvl:2, w:1.4, d:0.1, fx:{ funRate:1.05 } },
|
||||||
|
{ id:'paint_portrait',cat:'paint',name:'Ancestral Portrait', price:200, lvl:3, w:1.2, d:0.1, fx:{ comfortRate:1.06 } },
|
||||||
|
// Kitchen extras / misc
|
||||||
|
{ id:'counter_kitchen',cat:'table',name:'Prep Counter', price:240, lvl:2, w:1.8, d:0.9, fx:{ cookSpeed:1.1 } },
|
||||||
|
{ id:'microwave', cat:'elec', name:'ZapWave Microwave', price:130, lvl:1, w:0.8, d:0.6, fx:{ cookSpeed:1.08 } },
|
||||||
|
{ id:'coffeemaker',cat:'elec', name:'BrewBuddy Machine', price:110, lvl:1, w:0.6, d:0.5, fx:{ energyRate:1.05 } },
|
||||||
|
{ id:'guitar', cat:'decor',name:'Starwood Guitar', price:220, lvl:2, w:0.6, d:0.4, fx:{ funRate:1.15 } },
|
||||||
|
{ id:'keyboard_elec',cat:'elec',name:'MelodyKeys Synth', price:260, lvl:2, w:1.4, d:0.5, fx:{ funRate:1.15 } },
|
||||||
|
{ id:'easel', cat:'decor',name:'Vision Easel', price:150, lvl:2, w:1, d:0.8, fx:{ funRate:1.1 } },
|
||||||
|
{ id:'teddy', cat:'decor',name:'Mr. Snugglesworth', price:45, lvl:1, w:0.5, d:0.5, fx:{ comfortRate:1.05, romanceAura:true } },
|
||||||
|
];
|
||||||
|
export const CATALOG_BY_ID = Object.fromEntries(CATALOG.map(c => [c.id, c]));
|
||||||
|
|
||||||
|
// ---------------- Housing tiers -----------------------------
|
||||||
|
export const HOUSES = [
|
||||||
|
{ id:'studio', name:'Willow Studio Apartment', price:0, lvl:1, size:[11,9] },
|
||||||
|
{ id:'small', name:'Clover Small House', price:4500, lvl:3, size:[13,11] },
|
||||||
|
{ id:'medium', name:'Brookside Medium House', price:12000, lvl:6, size:[16,12] },
|
||||||
|
{ id:'large', name:'Hillcrest Large House', price:28000, lvl:10, size:[19,14] },
|
||||||
|
{ id:'luxury', name:'Lakeside Luxury Villa', price:60000, lvl:15, size:[22,16] },
|
||||||
|
{ id:'mansion',name:'Starfall Mansion', price:125000,lvl:22, size:[26,20] },
|
||||||
|
];
|
||||||
|
|
||||||
|
// ---------------- NPCs (all original) -----------------------
|
||||||
|
// schedule entries: [startHour, poiId] — walks to POIs through the day.
|
||||||
|
export const NPCS = [
|
||||||
|
{ id:'maya', name:'Maya Bloom', role:'Barista', color:0xe8875f, hairColor:0x3b2417, traits:['Friendly','Extroverted'],
|
||||||
|
schedule:[[6,'cafe'],[12,'plaza'],[14,'cafe'],[18,'stage'],[21,'fountain']], home:'cafe' },
|
||||||
|
{ id:'felix', name:'Felix Grant', role:'Programmer', color:0x5f86e8, hairColor:0x20180f, traits:['Intelligent','Introverted'],
|
||||||
|
schedule:[[8,'cityhall'],[12,'foodtruck'],[13,'cityhall'],[18,'arcade'],[22,'apt_b']], home:'apt_b' },
|
||||||
|
{ id:'rosa', name:'Rosa Vega', role:'Chef', color:0xd95f8a, hairColor:0x141414, traits:['Creative','Generous'],
|
||||||
|
schedule:[[9,'foodtruck'],[14,'foodtruck'],[18,'cafe'],[21,'plaza']], home:'apt_a' },
|
||||||
|
{ id:'kenji', name:'Kenji Sato', role:'Personal Coach', color:0x67b56a, hairColor:0x101010, traits:['Athletic','Competitive'],
|
||||||
|
schedule:[[6,'gym'],[12,'park'],[14,'gym'],[19,'stage'],[22,'apt_a']], home:'apt_a' },
|
||||||
|
{ id:'nadia', name:'Nadia Sol', role:'Artist', color:0xefcf62, hairColor:0x7a3d1e, traits:['Creative','Lazy'],
|
||||||
|
schedule:[[9,'pond'],[13,'park'],[16,'store'],[19,'plaza'],[22,'apt_b']], home:'apt_b' },
|
||||||
|
{ id:'theo', name:'Theo Marsh', role:'Shop Clerk', color:0x8fb7d9, hairColor:0x59391f, traits:['Funny','Generous'],
|
||||||
|
schedule:[[8,'store'],[13,'foodtruck'],[14,'store'],[19,'arcade'],[22,'apt_a']], home:'apt_a' },
|
||||||
|
{ id:'ivy', name:'Ivy Lane', role:'Musician', color:0xb58fd9, hairColor:0x2e1e47, traits:['Romantic','Creative'],
|
||||||
|
schedule:[[10,'stage'],[14,'cafe'],[17,'pond'],[20,'stage'],[23,'apt_b']], home:'apt_b' },
|
||||||
|
{ id:'bruno', name:'Bruno Steel', role:'Engineer', color:0xd97f4f, hairColor:0x2a2a2a, traits:['Ambitious','Competitive'],
|
||||||
|
schedule:[[7,'gym'],[9,'cityhall'],[12,'foodtruck'],[13,'cityhall'],[19,'arcade'],[22,'apt_a']], home:'apt_a' },
|
||||||
|
{ id:'cleo', name:'Cleo Brooks', role:'Reporter', color:0x7fd9c2, hairColor:0x8a2f2f, traits:['Extroverted','Funny'],
|
||||||
|
schedule:[[8,'plaza'],[11,'cafe'],[14,'cityhall'],[18,'stage'],[21,'fountain']], home:'apt_b' },
|
||||||
|
{ id:'oliver',name:'Oliver Finch', role:'Doctor', color:0xa8d97f, hairColor:0x4a3018, traits:['Intelligent','Generous'],
|
||||||
|
schedule:[[7,'gym'],[9,'cityhall'],[12,'cafe'],[13,'cityhall'],[19,'pond'],[22,'apt_a']], home:'apt_a' },
|
||||||
|
];
|
||||||
|
// POI anchor points for NPC schedule targets that are areas rather than POIs:
|
||||||
|
export const AREA_POINTS = { plaza:{x:0,z:-26}, park:{x:42,z:-26} };
|
||||||
|
|
||||||
|
// ---------------- Social interactions -----------------------
|
||||||
|
// req: minimum friendship(f)/romance(r) level. fx: relationship deltas & mood.
|
||||||
|
// anim: animation key both parties play. line templates may include {name}.
|
||||||
|
export const INTERACTIONS = [
|
||||||
|
{ id:'hello', group:'Friendly', label:'Say Hello', req:{f:0}, df:2, dr:0, anim:'wave',
|
||||||
|
lines:['Hi there, {name}!','Hello hello!','Hey {name}, good to see you!'] },
|
||||||
|
{ id:'chat', group:'Friendly', label:'Chat', req:{f:0}, df:4, dr:0, anim:'talk',
|
||||||
|
lines:['Did you see the plaza fountain? Gorgeous.','I heard the café has a new roast.','Weather has been lovely lately.'] },
|
||||||
|
{ id:'joke', group:'Friendly', label:'Tell Joke', req:{f:5}, df:5, dr:0, anim:'laugh', moodFx:{fun:+8},
|
||||||
|
lines:['Why did the scarecrow move to LifeTown? He heard the fields were outstanding!','I told my fridge a joke… it didn\'t get it, cold shoulder.'] },
|
||||||
|
{ id:'compliment',group:'Friendly',label:'Compliment', req:{f:5}, df:6, dr:1, anim:'talk', moodFx:{social:+6},
|
||||||
|
lines:['{name}, that outfit really suits you!','You always brighten my day, {name}.'] },
|
||||||
|
{ id:'highfive', group:'Friendly', label:'High Five', req:{f:10}, df:5, dr:0, anim:'highfive', moodFx:{fun:+5},
|
||||||
|
lines:['High five!','Up top!'] },
|
||||||
|
{ id:'story', group:'Friendly', label:'Tell Story', req:{f:15}, df:7, dr:0, anim:'talk', moodFx:{fun:+6},
|
||||||
|
lines:['So yesterday, a pigeon stole my sandwich…','Let me tell you about the great taco incident.'] },
|
||||||
|
{ id:'hug', group:'Friendly', label:'Hug', req:{f:25}, df:8, dr:1, anim:'hug', moodFx:{social:+10,comfort:+6},
|
||||||
|
lines:['Come here, you!','Group hug… well, duo hug.'] },
|
||||||
|
{ id:'flirt', group:'Romantic', label:'Flirt', req:{r:0}, df:1, dr:4, anim:'flirt',
|
||||||
|
lines:['Is your name Wi-Fi? Because I feel a connection.','Do you believe in love at first sight, {name}?'] },
|
||||||
|
{ id:'romantic_compliment',group:'Romantic',label:'Romantic Compliment',req:{r:10}, df:1, dr:6, anim:'flirt', moodFx:{social:+8},
|
||||||
|
lines:['Your smile could light up City Hall, {name}.','Every day with you feels like a festival.'] },
|
||||||
|
{ id:'gift_small',group:'Romantic',label:'Give Flowers', req:{r:10}, cost:40, df:2, dr:8, anim:'gift', moodFx:{social:+8},
|
||||||
|
lines:['These made me think of you!','A little something beautiful, for someone beautiful.'] },
|
||||||
|
{ id:'holdhands',group:'Romantic', label:'Hold Hands', req:{r:25}, df:2, dr:7, anim:'holdhands', moodFx:{social:+9,comfort:+5},
|
||||||
|
lines:['Shall we?','Just like this, {name}.'] },
|
||||||
|
{ id:'date', group:'Romantic', label:'Ask on Date', req:{r:35}, df:3, dr:9, anim:'celebrate', moodFx:{fun:+12,social:+12},
|
||||||
|
lines:['Dinner at the café, just us two?','It\'s a date, {name}!'] },
|
||||||
|
{ id:'kiss', group:'Romantic', label:'Kiss', req:{r:50}, df:2, dr:10, anim:'kiss', moodFx:{social:+14,happiness:10},
|
||||||
|
lines:['❤️','Mwah!'] },
|
||||||
|
{ id:'dance', group:'Funny', label:'Dance Together', req:{f:10}, df:4, dr:1, anim:'dance', moodFx:{fun:+14},
|
||||||
|
lines:['Show me your moves!','DJ, drop the beat!'] },
|
||||||
|
{ id:'funnyface',group:'Funny', label:'Make Funny Face', req:{f:5}, df:4, dr:0, anim:'laugh', moodFx:{fun:+8},
|
||||||
|
lines:['>:P','Boing! Look at my face!'] },
|
||||||
|
{ id:'insult', group:'Negative', label:'Insult', req:{f:0}, df:-12,dr:-6, anim:'argue', moodFx:{happiness:-10},
|
||||||
|
lines:['Nice going, clumsy!','Did you even try today?'] },
|
||||||
|
{ id:'argue', group:'Negative', label:'Argue', req:{f:10}, df:-15,dr:-10,anim:'argue', moodFx:{happiness:-14},
|
||||||
|
lines:['That\'s not what we agreed!','You always say that!'] },
|
||||||
|
{ id:'apologize',group:'Negative', label:'Apologize', req:{f:0}, df:10, dr:2, anim:'talk', moodFx:{happiness:6},
|
||||||
|
lines:['I\'m sorry, truly.','Can we start over, {name}?'] },
|
||||||
|
];
|
||||||
|
export const INTERACTION_BY_ID = Object.fromEntries(INTERACTIONS.map(i=>[i.id,i]));
|
||||||
|
|
||||||
|
// Friendship thresholds (spec): 0 stranger, 10 acquaintance, 25 friend, 50 close friend, 75 best friend.
|
||||||
|
export const FRIEND_TIERS = [
|
||||||
|
{ at:0, label:'Stranger' }, { at:10, label:'Acquaintance' }, { at:25, label:'Friend' },
|
||||||
|
{ at:50, label:'Close Friend' }, { at:75, label:'Best Friend' },
|
||||||
|
];
|
||||||
|
export const ROMANCE_PARTNER_AT = 100;
|
||||||
|
export const REL_MAX = 100;
|
||||||
|
|
||||||
|
// ---------------- Moods -------------------------------------
|
||||||
|
export const MOODS = ['Happy','Excited','Relaxed','Confident','Inspired','Romantic','Sad','Angry','Bored','Embarrassed','Lonely','Tired','Stressed'];
|
||||||
|
export const MOOD_EMOJI = { Happy:'😊', Excited:'🤩', Relaxed:'😌', Confident:'😎', Inspired:'💡', Romantic:'😍',
|
||||||
|
Sad:'😢', Angry:'😠', Bored:'😐', Embarrassed:'😳', Lonely:'🥺', Tired:'🥱', Stressed:'😖', Neutral:'🙂' };
|
||||||
|
|
||||||
|
// ---------------- Weather ----------------------------------
|
||||||
|
export const WEATHER_STATES = ['sunny','cloudy','rain','storm','fog'];
|
||||||
|
export const WEATHER_LABEL = { sunny:'Sunny', cloudy:'Cloudy', rain:'Rain', storm:'Storm', fog:'Fog' };
|
||||||
|
export const WEATHER_EMOJI = { sunny:'☀️', cloudy:'⛅', rain:'🌧️', storm:'⛈️', fog:'🌫️' };
|
||||||
|
|
||||||
|
// ---------------- Achievements ------------------------------
|
||||||
|
export const ACHIEVEMENTS = [
|
||||||
|
{ id:'first_home', name:'First Home', desc:'Buy your first house.' },
|
||||||
|
{ id:'social_butterfly', name:'Social Butterfly', desc:'Make 20 friends (friendship ≥ 25).' },
|
||||||
|
{ id:'millionaire', name:'Millionaire', desc:'Hold 1,000,000 TownCoins.' },
|
||||||
|
{ id:'heartbreaker', name:'Heartbreaker', desc:'Reach partner status 5 times.' },
|
||||||
|
{ id:'interior_designer', name:'Interior Designer', desc:'Place 25 furniture items.' },
|
||||||
|
{ id:'employed', name:'Day One', desc:'Get your first job.' },
|
||||||
|
{ id:'promoted', name:'Moving On Up', desc:'Earn any promotion.' },
|
||||||
|
{ id:'party_animal', name:'Party Animal', desc:'Dance at 3 events.' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// ---------------- Emotes -----------------------------------
|
||||||
|
export const EMOTES = ['wave','dance','laugh','sad','angry','sleep','sit'];
|
||||||
|
|
||||||
|
// Chat moderation: basic length cap.
|
||||||
|
export const MAX_CHAT_LEN = 240;
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2020",
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": false,
|
||||||
|
"noUnusedParameters": false,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"noEmit": true
|
||||||
|
},
|
||||||
|
"include": ["client/src"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { defineConfig } from 'vite';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
root: 'client',
|
||||||
|
base: './',
|
||||||
|
build: {
|
||||||
|
target: 'es2020',
|
||||||
|
chunkSizeWarningLimit: 1600,
|
||||||
|
rollupOptions: {
|
||||||
|
output: {
|
||||||
|
manualChunks: { three: ['three'] }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
port: 5199,
|
||||||
|
proxy: {
|
||||||
|
'/ws': { target: 'ws://127.0.0.1:7788', ws: true }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user