The Sims Online 2D — full game: CAS, build mode, needs, AI Mode (whim-driven autonomy), careers+chance cards, neighborhood AI sims, death/ghosts, meal tiers+sickness, paintings/novels, house parties, memories, weather; 38 headless tests green

This commit is contained in:
2026-08-23 07:02:13 +00:00
commit 90bcecbf4b
14 changed files with 6626 additions and 0 deletions
+54
View File
@@ -0,0 +1,54 @@
# The Sims Online — 2D Web Edition 🏡
A browser remake of The Sims / The Sims Online vibes in **plain JavaScript** (no frameworks, no build step) with an **isometric 2D canvas** renderer.
## Run
```bash
python3 -m http.server 8080
# open http://localhost:8080
```
Or any static file server. No dependencies, no bundler.
## Features
- **Create-A-Sim**: gender, skin/hair/outfit, 5 personality sliders, aspirations
- **Isometric house building**: buy/build modes, walls, doors, windows, floor patterns
- **Needs simulation**: hunger, energy, bladder, hygiene, fun, social, comfort, room
- **🤖 AI Mode**: sims autonomously fulfill needs *and* pursue their own whims (whim-driven goals: promotion study, earning via art/novels, flirting, hosting…)
- **Careers**: carpool commute, performance, promotions with skill requirements, chance cards
- **Neighborhood**: AI households with personalities, visits, relationships that persist
- **Death & ghosts**: electrocution/fire/starvation/old age → Grim Reaper, gravestones, night hauntings
- **Food system**: groceries stock, 4 meal tiers gated by cooking skill, food poisoning that spreads
- **Creativity money**: easel paintings, writing novels chapter by chapter
- **House parties** with scored guests and aspiration rewards
- **Whims/Wants**, memories diary, aging from baby to elder, romance/marriage/babies
- Weather (rain/storms), kitchen fires, bills, burglars' cousins (pizza delivery), school for kids
## Tests
Headless harness (node:vm sandbox with stubbed DOM/canvas) — 38 scenario tests:
```bash
node test/harness.mjs
```
## Layout
```
index.html shell
css/style.css all styling
js/
core.js constants, helpers, event bus
audio.js WebAudio bleeps
data.js object catalog, careers, meals, whims, socials
world.js lot grid, pathfinding, objects
sims.js Sim class, needs, skills
ai.js autonomy, actions, parties, deaths, wants
render.js isometric painter
ui.js HUD, panels, modals
hood.js neighborhood map screen
main.js game loop, save/load, CAS flow
test/harness.mjs headless test runner
```
+235
View File
@@ -0,0 +1,235 @@
/* ============ The Sims Online 2D — styles ============ */
* { margin:0; padding:0; box-sizing:border-box; user-select:none; }
html,body { width:100%; height:100%; overflow:hidden; background:#0b1020;
font-family:'Segoe UI', 'Trebuchet MS', Verdana, sans-serif; }
#app { position:relative; width:100%; height:100%; }
#game { position:absolute; inset:0; width:100%; height:100%; display:block; cursor:default; }
.hidden { display:none !important; }
button { font-family:inherit; cursor:pointer; }
/* ---------- HUD chips ---------- */
#topbar { position:absolute; top:10px; left:10px; right:10px; display:flex;
gap:8px; align-items:center; z-index:20; pointer-events:none; }
.hud-chip { pointer-events:auto; background:linear-gradient(#3a6fd8,#274b9f);
border:2px solid #16255c; color:#fff; border-radius:12px;
padding:6px 12px; font-size:14px; font-weight:600;
box-shadow:0 3px 8px rgba(0,0,0,.45), inset 0 1px 0 rgba(255,255,255,.35); }
#fundsBox { font-size:18px; min-width:110px; text-align:center; color:#ffe97a; }
#clockBox { display:flex; gap:6px; align-items:center; }
#speedBox { display:flex; gap:4px; padding:5px 8px; }
.speed-btn { background:#1d3c85; color:#cfe0ff; border:1px solid #14224f;
border-radius:8px; padding:3px 9px; font-size:13px; }
.speed-btn.active { background:#ffd23e; color:#3a2800; border-color:#b98a00; }
#optionsBox { margin-left:auto; display:flex; gap:6px; padding:5px 8px; }
.opt-btn { background:#1d3c85; color:#dfe9ff; border:1px solid #14224f; border-radius:8px;
padding:4px 10px; font-size:12px; font-weight:600; }
.opt-btn:hover { background:#2a51ab; }
.opt-btn.active { background:#37b34a; color:#fff; }
/* ---------- toasts ---------- */
#toasts { position:absolute; top:56px; left:50%; transform:translateX(-50%);
z-index:60; display:flex; flex-direction:column; gap:6px; align-items:center; pointer-events:none; }
.toast { background:rgba(16,24,52,.92); color:#fff; border:2px solid #4a72d6;
padding:8px 18px; border-radius:14px; font-size:14px; max-width:520px;
box-shadow:0 4px 14px rgba(0,0,0,.5); animation:toastIn .25s ease-out; }
.toast.bad { border-color:#e05252; }
.toast.good { border-color:#43c15a; }
.toast .envelope { cursor:pointer; pointer-events:auto; display:inline-block;
background:#ffd23e; color:#4a3200; border-radius:8px; padding:2px 10px; margin-left:8px;
font-weight:700; }
@keyframes toastIn { from { opacity:0; transform:translateY(-12px);} to { opacity:1; } }
/* ---------- bottom bar ---------- */
#bottombar { position:absolute; bottom:10px; left:50%; transform:translateX(-50%);
z-index:20; display:flex; flex-direction:column; gap:8px; align-items:center; }
#modeBtns { display:flex; gap:8px; }
.mode-btn { background:linear-gradient(#4472e0,#22449c); border:2px solid #131f4d;
color:#fff; width:86px; height:64px; border-radius:14px; display:flex;
flex-direction:column; align-items:center; justify-content:center; gap:2px;
box-shadow:0 4px 10px rgba(0,0,0,.5), inset 0 1px 0 rgba(255,255,255,.35); }
.mode-btn span { font-size:22px; }
.mode-btn label { font-size:11px; font-weight:700; letter-spacing:.08em; cursor:pointer; }
.mode-btn.active { background:linear-gradient(#ffd94e,#e89a00); color:#3a2500;
border-color:#7a5500; transform:translateY(3px); }
#portraitRow { display:flex; gap:8px; }
.portrait { width:74px; height:88px; background:linear-gradient(#20356f,#101b40);
border:2px solid #0c1533; border-radius:12px; position:relative; cursor:pointer;
overflow:hidden; transition:transform .12s; }
.portrait:hover { transform:translateY(-3px); }
.portrait canvas { width:70px; height:62px; display:block; margin-top:2px; }
.portrait .pname { position:absolute; bottom:2px; left:0; right:0; text-align:center;
color:#fff; font-size:11px; font-weight:700; text-shadow:0 1px 2px #000; }
.portrait.selected { border-color:#ffd23e; box-shadow:0 0 12px #ffd23e88; }
.portrait .plumbob { position:absolute; top:3px; right:5px; font-size:13px; }
.moodbar { position:absolute; bottom:16px; left:6px; right:6px; height:5px;
border-radius:3px; background:#222c; overflow:hidden; }
.moodbar > div { height:100%; border-radius:3px; }
/* ---------- sim panel ---------- */
#simPanel { position:absolute; top:60px; right:10px; bottom:96px; width:308px;
background:linear-gradient(#28407f,#182a5c); border:2px solid #0e1838;
border-radius:16px; z-index:30; display:flex; flex-direction:column;
box-shadow:0 8px 24px rgba(0,0,0,.55); color:#fff; }
#simPanelClose { position:absolute; top:8px; right:8px; background:#12204d;
color:#9fb4ea; border:none; border-radius:8px; width:26px; height:26px; font-weight:700; }
#simPanelHead { display:flex; gap:10px; padding:12px 12px 6px; align-items:center; }
#simPortrait { background:#0d1737; border-radius:10px; border:2px solid #0a1230; }
#simPanelName { font-size:17px; font-weight:800; }
#simPanelMood { font-size:12px; color:#bcd0ff; }
#simPanelTabs { display:flex; gap:4px; padding:4px 10px; flex-wrap:wrap; }
#simPanelTabs button { flex:1; background:#152450; color:#a9bdf0; border:1px solid #0c1738;
border-radius:8px; padding:5px 2px; font-size:11px; font-weight:700; }
#simPanelTabs button.active { background:#ffd23e; color:#3a2800; }
#simPanelBody { flex:1; overflow-y:auto; padding:8px 12px 12px; font-size:13px; }
.needRow { margin-bottom:8px; }
.needRow .nlabel { display:flex; justify-content:space-between; font-size:12px; margin-bottom:2px; }
.nbar { height:10px; background:#0c1636; border-radius:5px; overflow:hidden;
border:1px solid #0a1230; }
.nbar > div { height:100%; transition:width .4s; }
.skillRow { display:flex; justify-content:space-between; align-items:center; margin-bottom:6px; }
.pips { letter-spacing:2px; font-size:11px; }
.relCard { background:#152450; border:1px solid #0c1738; border-radius:10px;
padding:8px; margin-bottom:8px; }
.relCard .rname { font-weight:700; margin-bottom:4px; display:flex; justify-content:space-between;}
.relBar { height:8px; border-radius:4px; background:#0c1636; margin-bottom:3px; overflow:hidden; }
.relBar > div { height:100%; }
.careerLine { margin-bottom:8px; line-height:1.45; }
.bioLine { margin-bottom:6px; }
#simPanelBody::-webkit-scrollbar { width:8px; }
#simPanelBody::-webkit-scrollbar-thumb { background:#3a5aa8; border-radius:4px; }
/* ---------- buy drawer ---------- */
#buyDrawer { position:absolute; bottom:96px; left:10px; right:10px; height:218px;
background:linear-gradient(#28407f,#141f47); border:2px solid #0e1838; border-radius:16px;
z-index:25; display:flex; flex-direction:column; color:#fff;
box-shadow:0 -6px 20px rgba(0,0,0,.5); }
#buyTabs { display:flex; gap:4px; padding:8px 10px 4px; flex-wrap:wrap; }
#buyTabs button { background:#152450; color:#a9bdf0; border:1px solid #0c1738;
border-radius:8px 8px 0 0; padding:5px 12px; font-size:12px; font-weight:700; }
#buyTabs button.active { background:#ffd23e; color:#3a2800; }
#buyGrid { flex:1; overflow-y:auto; display:flex; gap:8px; padding:8px 12px; flex-wrap:wrap;
align-content:flex-start; }
.buyItem { width:104px; background:#152450; border:2px solid #0c1738; border-radius:10px;
padding:6px; text-align:center; cursor:pointer; }
.buyItem:hover { border-color:#ffd23e; }
.buyItem.sel { border-color:#43c15a; box-shadow:0 0 10px #43c15a88; }
.buyItem canvas { width:84px; height:64px; }
.buyItem .bn { font-size:11px; font-weight:700; white-space:nowrap; overflow:hidden;
text-overflow:ellipsis; }
.buyItem .bp { color:#ffe97a; font-size:12px; font-weight:800; }
#buyHint, #buildHint { padding:4px 12px 8px; font-size:11px; color:#9fb4ea; }
#buildHint b { color:#ffd23e; }
/* ---------- build bar ---------- */
#buildBar { position:absolute; bottom:96px; left:50%; transform:translateX(-50%);
background:linear-gradient(#28407f,#141f47); border:2px solid #0e1838; border-radius:14px;
z-index:25; display:flex; gap:6px; align-items:center; padding:8px 12px; color:#fff;
flex-wrap:wrap; max-width:92vw; }
#buildBar button { background:#152450; color:#dfe9ff; border:1px solid #0c1738;
border-radius:9px; padding:7px 12px; font-size:13px; font-weight:600; }
#buildBar button.active { background:#ffd23e; color:#3a2800; }
#floorSwatches { display:flex; gap:4px; margin-left:4px; }
.swatch { width:22px; height:22px; border-radius:5px; border:2px solid #0c1738; cursor:pointer; }
.swatch.sel { border-color:#fff; }
/* ---------- pie menu ---------- */
#pieMenu { position:absolute; z-index:50; background:rgba(13,20,46,.96);
border:2px solid #4a72d6; border-radius:12px; padding:6px; min-width:170px;
box-shadow:0 6px 18px rgba(0,0,0,.6); }
#pieMenu .pi { display:flex; gap:8px; align-items:center; padding:7px 12px; color:#fff;
border-radius:8px; cursor:pointer; font-size:13px; }
#pieMenu .pi:hover { background:#2a51ab; }
#pieMenu .pi.dis { opacity:.4; pointer-events:none; }
#pieMenu .pi .price { margin-left:auto; color:#ffe97a; font-weight:700; }
#pieMenu hr { border:none; border-top:1px solid #2a3f80; margin:4px 6px; }
/* ---------- CAS screen ---------- */
#casScreen { position:absolute; inset:0; z-index:80; background:
radial-gradient(1200px 700px at 50% 20%, #2b3f86 0%, #131c44 55%, #0a0f26 100%);
display:flex; flex-direction:column; align-items:center; color:#fff; overflow-y:auto; padding:14px; }
.cas-title { font-size:34px; letter-spacing:.06em; text-shadow:0 3px 0 #0a1030; margin-bottom:6px; }
#casMain { display:flex; gap:18px; align-items:flex-start; }
#casLeft { display:flex; flex-direction:column; align-items:center; gap:8px; }
#casPreview { background:linear-gradient(#1a2a5e,#0d1533); border:2px solid #0a1230; border-radius:16px; }
#casRight { width:400px; display:flex; flex-direction:column; gap:9px; }
.cas-row { display:flex; align-items:center; gap:8px; flex-wrap:wrap;
background:rgba(21,36,80,.75); border:1px solid #0c1738; border-radius:12px; padding:8px 10px; }
.cas-row .clabel { width:92px; font-size:12px; font-weight:700; color:#bcd0ff; }
.swatchBig { width:26px; height:26px; border-radius:7px; border:2px solid #0c1738; cursor:pointer; }
.swatchBig.sel { border-color:#fff; box-shadow:0 0 8px #fff8; }
.chip { background:#152450; border:2px solid #0c1738; color:#dfe9ff; border-radius:9px;
padding:5px 11px; font-size:12px; font-weight:700; cursor:pointer; }
.chip.sel { background:#ffd23e; color:#3a2800; border-color:#7a5500; }
input[type=text] { background:#0d1737; border:1px solid #2a3f80; color:#fff;
border-radius:8px; padding:7px 10px; font-size:15px; font-family:inherit; width:200px; }
input[type=range] { flex:1; accent-color:#ffd23e; }
.pval { width:22px; text-align:center; font-weight:800; color:#ffe97a; }
#casFamilyRow { display:flex; gap:8px; margin-top:10px; flex-wrap:wrap; justify-content:center; }
.famSlot { width:64px; height:78px; border-radius:10px; background:rgba(21,36,80,.75);
border:2px dashed #2a3f80; display:flex; flex-direction:column; align-items:center;
justify-content:center; cursor:pointer; font-size:10px; color:#8fa6dd; position:relative; }
.famSlot.sel { border-style:solid; border-color:#ffd23e; color:#fff; }
.famSlot canvas { width:58px; height:48px; }
.famSlot .del { position:absolute; top:-7px; right:-7px; background:#e05252; color:#fff;
border:none; width:20px; height:20px; border-radius:50%; font-size:11px; display:none; }
.famSlot:hover .del { display:block; }
#casActions { display:flex; gap:10px; margin-top:10px; }
.cas-btn { background:#152450; color:#dfe9ff; border:2px solid #0c1738; border-radius:11px;
padding:9px 18px; font-size:14px; font-weight:700; }
.cas-btn:hover { background:#1e3272; }
.cas-btn.primary { background:linear-gradient(#ffd94e,#e89a00); color:#3a2500; border-color:#7a5500; }
/* ---------- title ---------- */
#titleScreen { position:absolute; inset:0; z-index:90; display:flex; align-items:center;
justify-content:center; background:
radial-gradient(1400px 900px at 50% 30%, #2e4390 0%, #16204d 50%, #090e22 100%); }
#titleInner { text-align:center; color:#fff; }
#titleInner h1 { font-size:76px; letter-spacing:.04em; text-shadow:0 5px 0 #0a1030, 0 0 40px #4a72d688; }
#titleInner h1 .tm { font-size:22px; vertical-align:top; }
#titleInner h2 { font-size:22px; letter-spacing:.42em; color:#ffd23e; margin:2px 0 10px; }
.tagline { color:#aebfec; margin-bottom:26px; font-style:italic; }
.title-btn { display:block; width:260px; margin:10px auto; background:linear-gradient(#4472e0,#22449c);
border:2px solid #131f4d; color:#fff; font-size:19px; font-weight:800; padding:13px;
border-radius:14px; box-shadow:0 5px 14px rgba(0,0,0,.5), inset 0 1px 0 rgba(255,255,255,.35); }
.title-btn:hover { filter:brightness(1.15); transform:translateY(-2px); }
.credits { margin-top:24px; color:#67799f; font-size:12px; }
/* ---------- help ---------- */
#helpOverlay { position:absolute; inset:0; z-index:95; background:rgba(5,8,20,.72);
display:flex; align-items:center; justify-content:center; }
#helpCard { background:linear-gradient(#28407f,#141f47); border:2px solid #4a72d6;
border-radius:18px; padding:22px 26px; max-width:560px; color:#e7eeff; }
#helpCard h2 { margin-bottom:10px; color:#ffd23e; }
#helpCard ul { list-style:none; }
#helpCard li { margin-bottom:8px; line-height:1.45; font-size:13.5px; }
#helpCard b { color:#ffd23e; }
#helpClose { margin-top:8px; float:right; }
@media (max-width: 900px) {
#simPanel { width:260px; }
#casRight { width:min(400px, 92vw); }
}
/* ===== career chance card modal ===== */
#chanceCard {
position: fixed; inset: 0; z-index: 90;
display: flex; align-items: center; justify-content: center;
background: rgba(10, 14, 24, .55); backdrop-filter: blur(2px);
}
#chanceCard.hidden { display: none; }
.cc-box {
width: min(430px, 92vw); background: linear-gradient(#fdf8ec, #f1e7d0);
border: 3px solid #b98a2e; border-radius: 16px; padding: 18px 20px;
box-shadow: 0 12px 40px rgba(0,0,0,.5); font-family: inherit;
}
.cc-head { font-weight: bold; color: #7a4d12; margin-bottom: 8px; letter-spacing: .3px; }
.cc-q { font-size: 15px; color: #2e2a22; min-height: 42px; }
.cc-btns { display: flex; gap: 10px; margin-top: 14px; flex-wrap: wrap; }
.cc-btns button {
flex: 1; padding: 9px 10px; border: none; border-radius: 10px; cursor: pointer;
background: #2f6f3e; color: #fff; font-weight: bold; font-size: 13px;
}
.cc-btns button:last-child { background: #8a4040; }
.cc-btns button:hover { filter: brightness(1.12); }
.cc-sub { margin-top: 10px; font-size: 11px; color: #94856a; text-align: center; }
+156
View File
@@ -0,0 +1,156 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>The Sims Online 2D</title>
<link rel="stylesheet" href="css/style.css">
</head>
<body>
<div id="app">
<canvas id="game"></canvas>
<!-- ===== TOP HUD ===== -->
<div id="topbar" class="hidden">
<div id="fundsBox" class="hud-chip">§<span id="fundsVal">0</span></div>
<div id="clockBox" class="hud-chip">
<span id="clockTime">12:00 AM</span> · <span id="clockDay">Mon, Day 1</span>
<span id="weatherIcon" title="Weather">☀️</span>
</div>
<div id="speedBox" class="hud-chip">
<button class="speed-btn" data-speed="0" title="Pause"></button>
<button class="speed-btn active" data-speed="1" title="Normal"></button>
<button class="speed-btn" data-speed="2" title="Fast">▶▶</button>
<button class="speed-btn" data-speed="3" title="Ultra">▶▶▶</button>
</div>
<div id="optionsBox" class="hud-chip">
<button id="btnMute" class="opt-btn active" title="Toggle sound">🔊</button>
<button id="btnFreeWill" class="opt-btn active" title="AI Mode — sims act on their own needs & personal goals (F)">🤖 AI Mode</button>
<button id="btnHood" class="opt-btn" title="Neighborhood (N)">🏘️ Neighborhood</button>
<button id="btnSave" class="opt-btn" title="Save Game">💾</button>
<button id="btnHelp" class="opt-btn" title="Help"></button>
<button id="btnQuit" class="opt-btn" title="Quit to Neighborhood">🚪</button>
</div>
</div>
<!-- ===== NOTIFICATIONS ===== -->
<div id="toasts"></div>
<!-- ===== BOTTOM BAR ===== -->
<div id="bottombar" class="hidden">
<div id="portraitRow"></div>
<div id="modeBtns">
<button id="modeLive" class="mode-btn active" title="Live Mode"><span>🏠</span><label>Live</label></button>
<button id="modeBuy" class="mode-btn" title="Buy Mode"><span>🛒</span><label>Buy</label></button>
<button id="modeBuild" class="mode-btn" title="Build Mode"><span>🔨</span><label>Build</label></button>
</div>
</div>
<!-- ===== SIM INFO PANEL (right) ===== -->
<div id="simPanel" class="hidden">
<button id="simPanelClose"></button>
<div id="simPanelHead">
<canvas id="simPortrait" width="72" height="72"></canvas>
<div>
<div id="simPanelName"></div>
<div id="simPanelMood"></div>
</div>
</div>
<div id="simPanelTabs">
<button data-tab="needs" class="active">Needs</button>
<button data-tab="wants"></button>
<button data-tab="skills">Skills</button>
<button data-tab="rels">Rel.</button>
<button data-tab="career">Career</button>
<button data-tab="bio">Bio</button>
</div>
<div id="simPanelBody"></div>
</div>
<!-- ===== BUY CATALOG DRAWER ===== -->
<div id="buyDrawer" class="hidden">
<div id="buyTabs"></div>
<div id="buyGrid"></div>
<div id="buyHint">Click item, then click a tile to place · <b>R</b> rotate · <b>Esc</b> cancel · Right-click placed item to sell</div>
</div>
<!-- ===== BUILD TOOLBAR ===== -->
<div id="buildBar" class="hidden">
<button data-tool="wall" title="Drag to build walls ($70/segment)">🧱 Wall</button>
<button data-tool="door" title="Place door in wall ($250)">🚪 Door</button>
<button data-tool="window" title="Place window in wall ($180)">🪟 Window</button>
<button data-tool="floor" title="Paint floor tiles">🎨 Floor</button>
<button data-tool="delWall" title="Delete walls/doors/windows">✂️ Remove</button>
<span id="floorSwatches"></span>
<span id="wallSwatches"></span>
<div id="buildHint">Wall: drag along edges · Door/Window: click a wall segment</div>
</div>
<!-- ===== PIE / CONTEXT MENU ===== -->
<div id="pieMenu" class="hidden"></div>
<!-- ===== CREATE-A-SIM SCREEN ===== -->
<div id="casScreen" class="hidden">
<h1 class="cas-title">Create A Sim</h1>
<div id="casMain">
<div id="casLeft">
<canvas id="casPreview" width="300" height="420"></canvas>
<button id="casRandomize" class="cas-btn">🎲 Randomize</button>
</div>
<div id="casRight"></div>
</div>
<div id="casFamilyRow"></div>
<div id="casActions">
<button id="casAdd" class="cas-btn"> Add Sim</button>
<button id="casMoveIn" class="cas-btn primary">🏡 Move In →</button>
<button id="casBack" class="cas-btn">← Back</button>
</div>
</div>
<!-- ===== TITLE SCREEN ===== -->
<div id="titleScreen">
<div id="titleInner">
<h1>THE SIMS<span class="tm"></span></h1>
<h2>ONLINE — 2D EDITION</h2>
<p class="tagline">Build a home. Chase careers. Keep your sims alive.</p>
<div id="titleBtns">
<button id="btnNewGame" class="title-btn">👨‍👩‍👧 New Family</button>
<button id="btnContinue" class="title-btn">📂 Continue</button>
</div>
<p class="credits">Fan-made tribute · runs fully in your browser · autosaves daily</p>
</div>
</div>
<!-- ===== HELP OVERLAY ===== -->
<div id="helpOverlay" class="hidden">
<div id="helpCard">
<h2>How To Play</h2>
<ul>
<li><b>Live mode:</b> click the ground to send your sim walking; click objects for their menu; click other sims to socialize.</li>
<li><b>Needs</b> decay all day — Hunger, Energy, Bladder, Hygiene, Fun, Social, Comfort, Room. Red bars = act now!</li>
<li><b>Buy mode</b>: spend simoleons on furniture. Sell items back at 70%.</li>
<li><b>Build mode</b>: walls auto-connect; add doors & windows; paint floors.</li>
<li><b>Careers</b>: use the computer or newspaper → Find Job. The carpool arrives 1 hour before work.</li>
<li><b>Skills</b> from bookshelves, mirrors, easels, treadmills & more unlock promotions.</li>
<li><b>✨ Whims</b>: fulfil your sim's short-term wants (panel tab) for aspiration points.</li>
<li><b>Bills</b> arrive every 3 days — pay from the envelope toast before the collector visits!</li>
<li><b>Sound</b>: toggle with the 🔊 button. Everything is synthesized live — no downloads.</li>
<li>Keys: <b>Space</b> pause · <b>1/2/3</b> speed · <b>P</b> cycles modes · <b>F</b> free will · <b>WASD/arrows</b> pan camera.</li>
</ul>
<button id="helpClose" class="cas-btn primary">Got it!</button>
</div>
</div>
</div>
<script src="js/core.js"></script>
<script src="js/audio.js"></script>
<script src="js/data.js"></script>
<script src="js/world.js"></script>
<script src="js/sims.js"></script>
<script src="js/ai.js"></script>
<script src="js/render.js"></script>
<script src="js/ui.js"></script>
<script src="js/hood.js"></script>
<script src="js/main.js"></script>
</body>
</html>
+1530
View File
File diff suppressed because it is too large Load Diff
+63
View File
@@ -0,0 +1,63 @@
/* ============================================================
* audio.js — tiny WebAudio synth SFX (no asset files needed)
* ============================================================ */
'use strict';
const AudioSys = {
ctx: null,
muted: false,
/** Lazily create/resume the context (must follow a user gesture). */
ensure() {
if (this.muted) return null;
try {
if (!this.ctx) {
const AC = window.AudioContext || window.webkitAudioContext;
if (!AC) return null;
this.ctx = new AC();
}
if (this.ctx.state === 'suspended') this.ctx.resume();
return this.ctx;
} catch (e) { return null; }
},
tone(freq, dur, type = 'sine', vol = 0.12, when = 0, glideTo = 0) {
const c = this.ensure();
if (!c) return;
const t = c.currentTime + when;
const o = c.createOscillator();
const g = c.createGain();
o.type = type;
o.frequency.setValueAtTime(freq, t);
if (glideTo) o.frequency.exponentialRampToValueAtTime(Math.max(30, glideTo), t + dur);
g.gain.setValueAtTime(vol, t);
g.gain.exponentialRampToValueAtTime(0.0001, t + dur);
o.connect(g); g.connect(c.destination);
o.start(t); o.stop(t + dur + 0.03);
},
sfx(name) {
switch (name) {
case 'click': this.tone(650, .05, 'square', .04); break;
case 'toast': this.tone(520, .07, 'sine', .06); this.tone(780, .06, 'sine', .04, .05); break;
case 'error': this.tone(170, .16, 'sawtooth', .09); this.tone(120, .18, 'sawtooth', .08, .08); break;
case 'place': this.tone(340, .07, 'triangle', .12); this.tone(240, .09, 'triangle', .10, .06); break;
case 'sell': [880, 1175, 1568].forEach((f, i) => this.tone(f, .09, 'square', .06, i * .06)); break;
case 'chime': this.tone(660, .12, 'sine', .09); this.tone(990, .14, 'sine', .07, .09); break;
case 'level': this.tone(700, .08, 'square', .06); this.tone(1050, .12, 'square', .06, .07); break;
case 'fanfare':
[[523, 0], [659, .11], [784, .22], [1047, .33]].forEach(([f, d]) => this.tone(f, .16, 'triangle', .1, d));
this.tone(1047, .4, 'sine', .07, .5); break;
case 'bill': this.tone(330, .1, 'square', .07); this.tone(330, .12, 'square', .07, .14); break;
case 'horn': this.tone(290, .28, 'sawtooth', .08); this.tone(290, .34, 'sawtooth', .08, .32); break;
case 'thud': this.tone(120, .22, 'sine', .16, 0, 55); break;
case 'splash': this.tone(500, .1, 'sine', .08, 0, 180); this.tone(260, .16, 'sine', .08, .05, 90); break;
case 'toll': this.tone(98, 1.4, 'sine', .2); this.tone(196, 1.2, 'sine', .08, .02); break;
case 'kiss': this.tone(900, .06, 'sine', .07); this.tone(1300, .08, 'sine', .05, .06); break;
default: break;
}
},
};
/* unlock audio on first gesture */
window.addEventListener('pointerdown', () => AudioSys.ensure(), { once: true });
+97
View File
@@ -0,0 +1,97 @@
/* ============================================================
* core.js — utilities, isometric math, event bus
* ============================================================ */
'use strict';
const TW = 64, TH = 32; // iso tile width/height
const WALL_H = 46; // wall pixel height
const LOT_W = 34, LOT_H = 34; // lot size in tiles
/* ---------- tiny helpers ---------- */
function clamp(v, a, b) { return v < a ? a : (v > b ? b : v); }
function lerp(a, b, t) { return a + (b - a) * t; }
function dist2(ax, ay, bx, by) { const dx = ax - bx, dy = ay - by; return dx * dx + dy * dy; }
let __uid = 1;
function uid() { return __uid++; }
function rand(a, b) { return a + Math.random() * (b - a); }
function randi(a, b) { return Math.floor(rand(a, b + 1)); }
function choice(arr) { return arr[Math.floor(Math.random() * arr.length)]; }
function chance(p) { return Math.random() < p; }
/** Deterministic-ish name pools for CAS random & visitors */
const FIRST_NAMES_M = ['Mortimer','Bob','Michael','Gunther','Mickey','Goopy','Skip','Dustin','Dirk','Romeo','Puck','Tybalt','Mercutio','Patrizio','Don','Victor'];
const FIRST_NAMES_F = ['Bella','Cassandra','Dina','Nina','Cornelia','Riley','Kaylynn','Jenny','Brandi','Angela','Lilith','Hermia','Juliet','Isabel','Kayla','Molly'];
const LAST_NAMES = ['Goth','Bachelor','Dreamer','Pleasant','Caliente','Broke','Oldie','Rosalie','Monty','Capp','Summerdream','Smith','Newbie','Grunt','Jacquet','Freshe','Tricou','Moore','Oasis','Lothario'];
/* ---------- isometric transforms ----------
* World coordinates: tile floats (x,y). Screen coords pre-camera. */
function isoToScreen(x, y) {
return [ (x - y) * TW / 2, (x + y) * TH / 2 ];
}
/** screen (pre-camera) -> world tile float */
function screenToIso(sx, sy) {
const x = (sx / (TW / 2) + sy / (TH / 2)) / 2;
const y = (sy / (TH / 2) - sx / (TW / 2)) / 2;
return [x, y];
}
/** camera helpers attached to game object G */
function worldToPx(wx, wy) {
const [sx, sy] = isoToScreen(wx, wy);
return [ sx * G.cam.zoom + G.cam.x, sy * G.cam.zoom + G.cam.y ];
}
function pxToWorld(px, py) {
const sx = (px - G.cam.x) / G.cam.zoom, sy = (py - G.cam.y) / G.cam.zoom;
return screenToIso(sx, sy);
}
/* Facing directions: 0=S(+y), 1=W(-x), 2=N(-y), 3=E(+x) — matches sprite art */
const DIRS = [
{ dx: 0, dy: 1 }, // S
{ dx: -1, dy: 0 }, // W
{ dx: 0, dy: -1 }, // N
{ dx: 1, dy: 0 }, // E
];
function dirFromDelta(dx, dy) {
if (Math.abs(dx) >= Math.abs(dy)) return dx > 0 ? 3 : 1;
return dy > 0 ? 0 : 2;
}
/* ---------- colors ---------- */
const SKINS = ['#f6d7c4','#eeb98f','#c98a5e','#8d5a3b'];
const HAIRS = ['#2a1c12','#5a3a1e','#a56a2f','#d9b24a','#b23a2a','#777777','#101010','#e8e0cf'];
const SHIRTS = ['#d94a4a','#4a72d9','#43b05a','#e8a33d','#8a52c9','#3ab5b0','#e06aa8','#556077','#f0f0f0','#22262e'];
const PANTS = ['#2e3542','#3d5a99','#6b4226','#7a7f88','#274832','#803040'];
/* ---------- event bus ---------- */
const Bus = {
map: {},
on(ev, fn) { (this.map[ev] ||= []).push(fn); },
emit(ev, data) { (this.map[ev] || []).forEach(fn => fn(data)); },
};
/* ---------- money formatting ---------- */
function fmtMoney(n) { return '§' + Math.round(n).toLocaleString('en-US'); }
/** trace a rounded-rectangle path (caller fills/strokes) */
function roundRect(ctx, x, y, w, h, r) {
r = Math.min(r, w / 2, h / 2);
ctx.beginPath();
ctx.moveTo(x + r, y);
ctx.arcTo(x + w, y, x + w, y + h, r);
ctx.arcTo(x + w, y + h, x, y + h, r);
ctx.arcTo(x, y + h, x, y, r);
ctx.arcTo(x, y, x + w, y, r);
ctx.closePath();
}
/* ---------- time helpers ---------- */
function hourTo12(h) {
h = ((h % 24) + 24) % 24;
const ampm = h < 12 ? 'AM' : 'PM';
let hh = h % 12; if (hh === 0) hh = 12;
return hh + ':' + String(G.time.min).padStart(2, '0') + ' ' + ampm;
}
const DAY_NAMES = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
/* ---------- deep merge for save/load safety ---------- */
function isObj(v) { return v && typeof v === 'object' && !Array.isArray(v); }
+291
View File
@@ -0,0 +1,291 @@
/* ============================================================
* data.js — object catalog, careers, skills, socials, need meta
* ============================================================ */
'use strict';
/* ---------------- NEEDS ---------------- */
const NEEDS = {
hunger: { label:'Hunger', icon:'🍔', color:'#e8a33d', decay: -7 },
energy: { label:'Energy', icon:'💤', color:'#7f6fd0', decay: -4 },
bladder: { label:'Bladder', icon:'🚽', color:'#4fa3d9', decay: -6 },
hygiene: { label:'Hygiene', icon:'🚿', color:'#5ad0c8', decay: -4 },
fun: { label:'Fun', icon:'🎉', color:'#e05aa8', decay: -5 },
social: { label:'Social', icon:'💬', color:'#63c15a', decay: -5 },
comfort: { label:'Comfort', icon:'🛋️', color:'#c9a24a', decay: -5 },
room: { label:'Room', icon:'🌸', color:'#9b7fd0', decay: 0 }, // computed from environment
};
/* ---------------- SKILLS ---------------- */
const SKILLS = [
{ id:'cooking', name:'Cooking', icon:'🍳' },
{ id:'mechanical', name:'Mechanical', icon:'🔧' },
{ id:'charisma', name:'Charisma', icon:'💬' },
{ id:'body', name:'Body', icon:'💪' },
{ id:'logic', name:'Logic', icon:'🧠' },
{ id:'creativity', name:'Creativity', icon:'🎨' },
{ id:'cleaning', name:'Cleaning', icon:'🧹' },
];
/* ---------------- PERSONALITY TRAITS ---------------- */
const TRAITS = ['neat','outgoing','active','playful','nice'];
/* ---------------- CAREERS ----------------
* 10 ranks each; generated salaries; skill requirements ramp. */
function makeCareer(id, icon, title1, titles, basePay, startHour, endHour, skillKey) {
const ranks = [];
for (let i = 0; i < 10; i++) {
const req = {};
if (skillKey && i >= 1) req[skillKey] = Math.min(10, Math.ceil(i * 1.1));
if (i >= 5 && skillKey !== 'charisma') req.charisma = Math.ceil((i - 4) * 0.8);
ranks.push({
title: titles[i],
salary: Math.round(basePay * Math.pow(1.42, i)),
hours: [startHour, endHour],
offDays: (id === 'criminal') ? [2, 4] : [5, 6], // index of DAY_NAMES; criminal works weekends
req,
carpoolHour: startHour - 1,
});
}
return { id, icon, name: titles[0], trackName: title1 + ' Track', ranks };
}
const CAREERS = [
makeCareer('business', '💼', 'Business',
['Mailroom Tech','Trainee','Junior Executive','Executive','Assistant Manager','Manager','Vice President','President','CEO','Business Tycoon'],
154, 9, 16, 'charisma'),
makeCareer('culinary', '🍳', 'Culinary',
['Dish Washer','Fast Food Shift Manager','Drive-Thru Clerk','Counter Person','Waiter','Head Waiter','Sous-Chef','Executive Chef','Celebrity Chef','Five-Star Chef'],
126, 14, 21, 'cooking'),
makeCareer('science', '🔬', 'Science',
['Test Subject','Lab Assistant','Field Researcher','Science Teacher','Lab Tech','Research Assistant','Project Leader','Inventor','Scientist','Mad Scientist'],
140, 9, 15, 'logic'),
makeCareer('medicine', '🩺', 'Medicine',
['Emergency Medical Technician','Paramedic','Nurse','Orderly','Intern','Resident','General Practitioner','Specialist','Surgeon','Chief of Staff'],
168, 8, 17, 'mechanical'),
makeCareer('athletic', '🏆', 'Athletic',
['Locker Room Attendant','Team Mascot','Waterperson','Towel Boy','Equipment Manager','Team Physician','Coach','Mascot Manager','Most Valuable Player','Hall of Famer'],
132, 12, 19, 'body'),
makeCareer('criminal', '🕵️', 'Criminal',
['Con Artist','Pickpocket','Bookmaker','Cutpurse','Safecracker','Bank Robber','Cat Burglar','Safe Cracker','Getaway Driver','Crime Lord'],
150, 21, 4, 'body'),
makeCareer('military', '🎖️', 'Military',
['Recruit','Private','Corporal','Sergeant','Junior Officer','Lieutenant','Captain','Major','Colonel','Astronaut'],
155, 7, 14, 'mechanical'),
makeCareer('art', '🎭', 'Arts',
['Subway Performer','Coffee Shop Singer','Portrait Painter','Street Mime','Wedding Singer','Commercial Actor','Supporting Role','Lead Role','Famous Star','Icon'],
118, 13, 20, 'creativity'),
];
const ASPIRATIONS = {
fortune: { name:'Fortune', icon:'💰', desc:'Wants riches and promotions.' },
knowledge: { name:'Knowledge', icon:'📚', desc:'Wants maxed skills.' },
popularity: { name:'Popularity', icon:'🎊', desc:'Wants many friends.' },
family: { name:'Family', icon:'🏡', desc:'Wants a cozy home life.' },
romance: { name:'Romance', icon:'💘', desc:'Wants love and charm.' },
};
/* ---------------- WHIMS (short-term wants) ---------------- */const WHIMS = {
fortune: [
{ id:'promote', icon:'⭐', label:'Get promoted', ev:'promotion', reward:130 },
{ id:'earn', icon:'💰', label:'Earn §400 on the side', ev:'earn', amount:400, reward:80 },
{ id:'job', icon:'📋', label:'Get hired', ev:'job', reward:60 },
],
knowledge: [
{ id:'maxskill', icon:'🧠', label:'Max out a skill (10)', ev:'skill', level:10, reward:150 },
{ id:'anylevel', icon:'📈', label:'Gain any skill level', ev:'skill', reward:50 },
{ id:'chessy', icon:'♟️', label:'Play chess', ev:'social', match:'chess', reward:40 },
],
popularity: [
{ id:'friend', icon:'🤝', label:'Make a new friend', ev:'friend', reward:120 },
{ id:'mixer', icon:'💬', label:'Socialize 5 times', ev:'social', count:5, reward:70 },
{ id:'host', icon:'👋', label:'Invite a neighbor over', ev:'visitor', reward:45 },
],
family: [
{ id:'meal', icon:'🍲', label:'Cook a nice meal', ev:'meal', reward:60 },
{ id:'rested', icon:'😴', label:'Wake up fully rested', ev:'rested', reward:60 },
{ id:'tidy', icon:'✨', label:'Clean something filthy', ev:'cleaned', reward:45 },
],
romance: [
{ id:'flirt', icon:'💗', label:'Flirt with someone', ev:'social', match:'flirt', reward:85 },
{ id:'hug', icon:'🤗', label:'Share a hug', ev:'social', match:'hug', reward:50 },
{ id:'love', icon:'💞', label:'Fall in love', ev:'love', reward:200 },
],
};
/* ---------------- SOCIAL INTERACTIONS ---------------- */
const SOCIALS = [
{ id:'talk', label:'Talk', icon:'💬', str:+4, ltr:+1.5, dur:10, minRel:-100 },
{ id:'joke', label:'Joke', icon:'😄', str:+6, ltr:+2, dur:8, playfulBoost:true },
{ id:'compliment', label:'Compliment', icon:'🌹', str:+7, ltr:+2.5, dur:6, niceBoost:true },
{ id:'hug', label:'Hug', icon:'🤗', str:+9, ltr:+3.5, dur:5, minRel:25 },
{ id:'flirt', label:'Flirt', icon:'💗', str:+11, ltr:+5, dur:8, minRel:45, romanceOnly:false },
{ id:'dance', label:'Dance Together', icon:'🕺', str:+8, ltr:+3, dur:20 },
{ id:'insult', label:'Insult', icon:'😠', str:-12, ltr:-6, dur:5 },
{ id:'argue', label:'Argue', icon:'🗯️', str:-18, ltr:-9, dur:8, minRelMax:0 },
];
/* ---------------- OBJECT CATALOG ----------------
* shape keys are painted procedurally in render.js
* interactions: generic runner in ai.js — dur = minutes, fx = need points/min while using */
/* --- meals by cooking skill (needs groceries from the fridge stock) --- */
const MEALS = [
{ id:'quick', name:'Instant Noodles', skill:0, hunger:22, fun:2, emoji:'🍜' },
{ id:'spaghetti', name:'Spaghetti', skill:2, hunger:34, fun:8, emoji:'🍝' },
{ id:'roast', name:'Sunday Roast', skill:4, hunger:46, fun:16, emoji:'🍗' },
{ id:'gourmet', name:'Gourmet Lobster', skill:6, hunger:58, fun:28, emoji:'🦞' },
];
/* --- career chance cards (picked at random while at work) --- */
const CHANCE_CARDS = [
{ q:'Your boss needs someone to stay late and finish a report.',
a:[{ label:'Stay late', icon:'🌙', fx:{ perf:8, energy:-18 }, say:'💼' },
{ label:'Go home', icon:'🏠', fx:{ perf:-3 }, say:'😐' }] },
{ q:'A coworker asks you to cover their mistake in front of the client.',
a:[{ label:'Cover for them', icon:'🤝', fx:{ perf:5 }, say:'😇' },
{ label:'Tell the truth', icon:'📢', fx:{ perf:2, bonus:150 }, say:'😎' }] },
{ q:'You found a wallet in the parking lot with §200 inside.',
a:[{ label:'Return it', icon:'🙌', fx:{ perf:6 }, say:'😊' },
{ label:'Keep it', icon:'💰', fx:{ money:200, perf:-7 }, say:'🤑' }] },
{ q:'Upper management is watching today. Impress them?',
a:[{ label:'Give a bold presentation', icon:'📊', fx:{ dice:.55, win:{ perf:14 }, lose:{ perf:-10 } } },
{ label:'Keep a low profile', icon:'🙈', fx:{ perf:1 } }] },
{ q:'The office espresso machine broke. Fix it yourself?',
a:[{ label:'Fix it', icon:'🔧', fx:{ dice:.7, win:{ skill:'mechanical', amt:1, perf:4 }, lose:{ perf:-5, say:'⚡' } } },
{ label:'Drink tea instead', icon:'🍵', fx:{ fun:10 } }] },
{ q:'A rival offers you insider tips for a quick promotion.',
a:[{ label:'Take the tips', icon:'🕵️', fx:{ dice:.6, win:{ perf:12 }, lose:{ perf:-12, firedRisk:.06 } } },
{ label:'Refuse', icon:'🙅', fx:{ perf:3, say:'🙂' } }] },
{ q:'Charity gala tonight — attend or rest?',
a:[{ label:'Attend the gala', icon:'🥂', fx:{ energy:-15, perf:7, social:20 }, say:'✨' },
{ label:'Sleep early', icon:'😴', fx:{ energy:25, perf:-2 } }] },
];
const OBJECTS = {
/* --- seating --- */
chair: { id:'chair', name:'Chair', emoji:'🪑', price:85, w:1, h:1, cat:'seating', shape:'chair', rot:true, sit:true,
env:2, interactions:[{ id:'sit', label:'Sit', icon:'🛋️', dur:60, fx:{comfort:.35}, pose:'sit' }] },
stool: { id:'stool', name:'Stool', emoji:'🟫', price:50, w:1, h:1, cat:'seating', shape:'stool', rot:true, sit:true,
env:1, interactions:[{ id:'sit', label:'Sit', icon:'🛋️', dur:60, fx:{comfort:.28}, pose:'sit' }] },
sofa: { id:'sofa', name:'Sofa', emoji:'🛋️', price:450, w:1, h:3, cat:'seating', shape:'sofa', rot:true, sit:true,
env:4, comfortSeat:1, interactions:[
{ id:'sit', label:'Sit', icon:'🛋️', dur:60, fx:{comfort:.45}, pose:'sit' },
{ id:'nap', label:'Nap', icon:'💤', dur:120, fx:{energy:.22, comfort:.3}, pose:'sit' }] },
loveseat: { id:'loveseat', name:'Loveseat', emoji:'💺', price:350, w:1, h:2, cat:'seating', shape:'loveseat', rot:true, sit:true,
env:3, interactions:[{ id:'sit', label:'Sit', icon:'🛋️', dur:60, fx:{comfort:.4}, pose:'sit' }] },
/* --- tables --- */
table: { id:'table', name:'Table', emoji:'🍽️', price:250, w:1, h:1, cat:'tables', shape:'table', rot:true, env:2, eatSpot:true, interactions:[] },
coffeeTable: { id:'coffeeTable', name:'Coffee Table', emoji:'☕', price:120, w:2, h:1, cat:'tables', shape:'coffeeTable', rot:true, env:2, interactions:[] },
desk: { id:'desk', name:'Desk', emoji:'🗄️', price:180, w:2, h:1, cat:'tables', shape:'desk', rot:true, env:2, interactions:[] },
/* --- beds --- */
bedSingle: { id:'bedSingle', name:'Single Bed', emoji:'🛏️', price:400, w:1, h:2, cat:'beds', shape:'bedSingle', rot:true,
env:3, sleep:true, interactions:[
{ id:'sleep', label:'Sleep', icon:'😴', special:'sleep', pose:'lie' },
{ id:'nap', label:'Nap 2h', icon:'💤', special:'sleep', nap:true, pose:'lie' }] },
bedDouble: { id:'bedDouble', name:'Double Bed', emoji:'🛌', price:900, w:2, h:2, cat:'beds', shape:'bedDouble', rot:true,
env:5, sleep:true, love:true, interactions:[
{ id:'sleep', label:'Sleep', icon:'😴', special:'sleep', pose:'lie' },
{ id:'nap', label:'Nap 2h', icon:'💤', special:'sleep', nap:true, pose:'lie' },
{ id:'tryBaby', label:'Try for Baby', icon:'👶', special:'tryBaby', pose:'lie', dur:25 }] },
/* --- plumbing --- */
toilet: { id:'toilet', name:'Toilet', emoji:'🚽', price:300, w:1, h:1, cat:'bath', shape:'toilet', rot:true, env:-2,
interactions:[
{ id:'pee', label:'Use Toilet', icon:'🚽', dur:10, fx:{bladder:9}, pose:'sit' },
{ id:'clean',label:'Clean Toilet', icon:'🧽', special:'clean', requiresDirty:true }] },
shower: { id:'shower', name:'Shower', emoji:'🚿', price:500, w:1, h:1, cat:'bath', shape:'shower', rot:true, env:1,
interactions:[{ id:'shower', label:'Take Shower', icon:'🚿', dur:22, fx:{hygiene:4.4, fun:.15, energy:.05}, pose:'stand' }] },
bathtub:{ id:'bathtub', name:'Bathtub', emoji:'🛁', price:600, w:1, h:2, cat:'bath', shape:'bathtub', rot:true, env:3,
interactions:[{ id:'bathe', label:'Take Bath', icon:'🛁', dur:40, fx:{hygiene:2.4, comfort:.5, fun:.3}, pose:'lie' }] },
sink: { id:'sink', name:'Sink', emoji:'🚰', price:150, w:1, h:1, cat:'bath', shape:'sink', rot:true, env:1,
interactions:[{ id:'wash', label:'Wash Up', icon:'🧼', dur:5, fx:{hygiene:3.5}, pose:'stand' }] },
mirror: { id:'mirror', name:'Wall Mirror', emoji:'🪞', price:175, w:1, h:1, cat:'bath', shape:'mirror', wallObj:true, rot:true, env:2,
interactions:[{ id:'practice', label:'Practice Charisma', icon:'💬', dur:60, fx:{social:.1, fun:.08}, skill:{id:'charisma', rate:.028}, pose:'stand' }] },
/* --- kitchen --- */
fridge: { id:'fridge', name:'Fridge', emoji:'🧊', price:600, w:1, h:1, cat:'kitchen', shape:'fridge', rot:true, env:1,
interactions:[
{ id:'meal', label:'Have a Meal', icon:'🍲', special:'cookMeal', pose:'stand' },
{ id:'snack', label:'Grab a Snack', icon:'🍎', dur:8, fx:{hunger:2.6}, pose:'stand' },
{ id:'groceries', label:'Order Groceries (§60)', icon:'🛍️', special:'groceries', cost:60, pose:'stand' }] },
stove: { id:'stove', name:'Stove', emoji:'🔥', price:500, w:1, h:1, cat:'kitchen', shape:'stove', rot:true, env:1, interactions:[] },
counter:{ id:'counter', name:'Counter', emoji:'🧾', price:140, w:1, h:1, cat:'kitchen', shape:'counter', rot:true, env:1, prepTarget:true, interactions:[] },
trash: { id:'trash', name:'Trash Can', emoji:'🗑️', price:60, w:1, h:1, cat:'kitchen', shape:'trash', rot:true, env:-3,
interactions:[{ id:'empty', label:'Empty Trash', icon:'🗑️', special:'emptyTrash', requiresFull:true }] },
/* --- electronics --- */
tv: { id:'tv', name:'Television', emoji:'📺', price:800, w:2, h:1, cat:'electronics', shape:'tv', rot:true, env:3, fragile:.0035,
watchTarget:true, interactions:[{ id:'watch', label:'Watch TV', icon:'📺', dur:90, fx:{fun:.55, comfort:.06, social:.04}, pose:'sitOrStand' }] },
stereo: { id:'stereo', name:'Stereo', emoji:'🔊', price:550, w:1, h:1, cat:'electronics', shape:'stereo', rot:true, env:2, fragile:.0025,
interactions:[{ id:'dance', label:'Dance!', icon:'💃', dur:45, fx:{fun:.75, social:.05}, skill:{id:'body', rate:.006}, pose:'stand', anim:'dance' }] },
computer:{ id:'computer', name:'Computer', emoji:'🖥️', price:2100, w:1, h:1, cat:'electronics', shape:'computer', rot:true, env:2, fragile:.0018,
interactions:[
{ id:'findJob', label:'Find a Job…', icon:'📰', special:'findJob', pose:'sit' },
{ id:'games', label:'Play Games', icon:'🎮', dur:60, fx:{fun:.65}, skill:{id:'logic', rate:.008}, pose:'sit' },
{ id:'write', label:'Write Novel', icon:'✍️', special:'writeNovel', pose:'sit', dur:90 }] },
phone: { id:'phone', name:'Telephone', emoji:'☎️', price:90, w:1, h:1, cat:'electronics', shape:'phone', rot:true, env:0,
interactions:[
{ id:'chatPhone', label:'Chat With Friend', icon:'📞', dur:30, fx:{social:1.4, fun:.15}, pose:'stand' },
{ id:'invite', label:'Invite Neighbor Over', icon:'👋', special:'inviteOver', pose:'stand' },
{ id:'party', label:'Throw Party Tonight 🎉', icon:'🥳', special:'throwParty', pose:'stand' },
{ id:'orderPizza',label:'Order Pizza (§40)', icon:'🍕', special:'pizza', cost:40, pose:'stand' }] },
/* --- skill / fun objects --- */
bookshelf:{ id:'bookshelf', name:'Bookshelf', emoji:'📚', price:250, w:1, h:1, cat:'study', shape:'bookshelf', rot:true, env:3,
interactions:[
{ id:'readLogic', label:'Study Logic', icon:'🧠', dur:60, fx:{fun:.05, energy:.02}, skill:{id:'logic', rate:.03}, pose:'stand' },
{ id:'readCook', label:'Study Cooking', icon:'🍳', dur:60, fx:{fun:.08}, skill:{id:'cooking', rate:.03}, pose:'stand' },
{ id:'readMech', label:'Study Mechanical', icon:'🔧', dur:60, fx:{fun:.05}, skill:{id:'mechanical', rate:.03}, pose:'stand' },
{ id:'readFun', label:'Read for Fun', icon:'📖', dur:60, fx:{fun:.55}, pose:'stand' }] },
easel: { id:'easel', name:'Easel', emoji:'🖼️', price:350, w:1, h:1, cat:'study', shape:'easel', rot:true, env:2,
interactions:[
{ id:'paint', label:'Paint', icon:'🎨', dur:90, fx:{fun:.4, energy:-.05}, skill:{id:'creativity', rate:.033}, special:'paint', pose:'stand' }] },
treadmill:{ id:'treadmill', name:'Treadmill', emoji:'🏃', price:1200, w:1, h:1, cat:'study', shape:'treadmill', rot:true, env:1, fragile:.0022,
interactions:[{ id:'workout', label:'Work Out', icon:'💪', dur:60, fx:{fun:-.05, hygiene:-.35, energy:-.12}, skill:{id:'body', rate:.033}, pose:'stand', anim:'exercise' }] },
piano: { id:'piano', name:'Piano', emoji:'🎹', price:1300, w:2, h:1, cat:'study', shape:'piano', rot:true, env:4,
interactions:[{ id:'playPiano', label:'Play Piano', icon:'🎹', dur:60, fx:{fun:.45, social:.05}, skill:{id:'creativity', rate:.026}, pose:'sit' }] },
chessboard: { id:'chessboard', name:'Chess Table', emoji:'♟️', price:450, w:1, h:1, cat:'study', shape:'chessboard', rot:true, env:3,
interactions:[
{ id:'chess', label:'Play Chess', icon:'♟️', dur:60, fx:{fun:.4, logic:.0}, skill:{id:'logic', rate:.028}, pose:'sit' }] },
/* --- decor --- */
plant: { id:'plant', name:'Potted Plant', emoji:'🪴', price:120, w:1, h:1, cat:'decor', shape:'plant', rot:false, env:4 },
lamp: { id:'lamp', name:'Floor Lamp', emoji:'💡', price:75, w:1, h:1, cat:'decor', shape:'lamp', rot:false, env:2, light:70 },
painting:{ id:'painting', name:'Painting', emoji:'🏞️', price:200, w:1, h:1, cat:'decor', shape:'painting', wallObj:true, rot:true, env:5 },
fountain:{ id:'fountain', name:'Fountain', emoji:'⛲', price:2500, w:2, h:2, cat:'decor', shape:'fountain', rot:false, env:8, light:40 },
easel: { id:'easel', name:'Easel', emoji:'🎨', price:450, w:1, h:1, cat:'decor', shape:'easel', rot:true, env:4,
interactions:[
{ id:'paint', label:'Paint a Canvas', icon:'🖌️', special:'paint', pose:'stand', dur:90 },
{ id:'sellArt', label:'Sell Paintings', icon:'💵', special:'sellArt', pose:'stand', dur:10 }] },
gravestone:{ id:'gravestone', name:'Gravestone', emoji:'🪦', price:0, w:1, h:1, cat:'hidden', shape:'gravestone', rot:false, env:-6 },
crib: { id:'crib', name:'Crib', emoji:'🧸', price:350, w:1, h:1, cat:'kids', shape:'crib', rot:true, env:3, sleep:true,
interactions:[
{ id:'sleep', label:'Baby Nap', icon:'😴', special:'sleep', nap:true, pose:'lie', babyOnly:true }] },
toybox: { id:'toybox', name:'Toy Box', emoji:'🪀', price:180, w:1, h:1, cat:'kids', shape:'toybox', rot:false, env:4,
interactions:[{ id:'playToys', label:'Play with Toys', icon:'🧸', dur:45, fx:{fun:.7}, pose:'stand', childOnly:true }] },
};
/* ---------------- BUY CATEGORIES ---------------- */
const BUY_CATS = [
{ id:'seating', label:'Seating', icon:'🛋️' },
{ id:'tables', label:'Tables', icon:'🍽️' },
{ id:'beds', label:'Beds', icon:'🛏️' },
{ id:'bath', label:'Bathroom', icon:'🚿' },
{ id:'kitchen', label:'Kitchen', icon:'🍳' },
{ id:'electronics', label:'Electronics', icon:'📺' },
{ id:'study', label:'Skill & Fun', icon:'📚' },
{ id:'decor', label:'Decor', icon:'🪴' },
{ id:'kids', label:'Kids', icon:'🧸' },
];
/* ---------------- FLOOR STYLES & WALL STYLES ---------------- */
const FLOORS = [
{ id:'grass', c1:'#69a84f', c2:'#5f9c47', outdoor:true },
{ id:'wood', c1:'#b98a52', c2:'#a87b46' },
{ id:'tile', c1:'#dfe6ea', c2:'#cdd6dc' },
{ id:'carpet', c1:'#b0576a', c2:'#a34e60' },
{ id:'stone', c1:'#9aa2ab', c2:'#8b939c' },
{ id:'darkwood', c1:'#7a5636', c2:'#6c4a2e' },
];
const WALL_COLORS = ['#efe6d4','#d9cdb8','#bcd3e8','#d8bfcf','#c9dfc0','#e8d2a4','#b9aabf'];
+377
View File
@@ -0,0 +1,377 @@
/* ============================================================
* hood.js — The Neighborhood
* · AI households with persistent sims living around the map
* · They visit, stroll by, remember every chat (rel memory)
* · Full-screen neighborhood map view (key N) with family cards
* ============================================================ */
const ROOF_COLORS = ['#b5432e', '#3e6fa8', '#4a8a4a', '#a87f2e', '#7d4aa8', '#2e8a8a', '#a82e5c'];
function genNeighborhood() {
const surnames = [...LAST_NAMES].sort(() => Math.random() - .5);
const spots = [
{ gx: -1.55, gy: -.62 }, { gx: 1.55, gy: -.62 },
{ gx: -1.85, gy: .45 }, { gx: 1.85, gy: .45 },
{ gx: -.95, gy: 1.15 }, { gx: .95, gy: 1.15 },
];
const lots = [];
for (let i = 0; i < 6; i++) {
const surname = surnames[i];
const nAdults = randi(1, 2) + (chance(.35) ? 1 : 0);
const family = [];
for (let j = 0; j < nAdults; j++) {
const d = randomSimData(chance(.5) ? 'f' : 'm');
d.name = d.name.split(' ')[0] + ' ' + surname;
family.push(makeHoodMember(d, 'adult'));
}
if (chance(.45)) {
const kid = randomSimData(chance(.5) ? 'f' : 'm');
kid.name = kid.name.split(' ')[0] + ' ' + surname;
family.push(makeHoodMember(kid, 'child'));
}
lots.push({
id: 'L' + i,
name: surname,
roof: ROOF_COLORS[i % ROOF_COLORS.length],
gx: spots[i].gx, gy: spots[i].gy,
family,
friendship: randi(10, 35), // household-level vibe
});
}
return { lots };
}
function makeHoodMember(data, ageStage) {
return {
id: 'n' + Math.random().toString(36).slice(2, 9),
name: data.name,
gender: data.gender || choice(['f', 'm']),
skin: data.skin ?? randi(0, SKINS.length - 1),
hairStyle: data.hairStyle ?? 0,
hairColor: data.hairColor ?? randi(0, HAIRS.length - 1),
shirt: data.shirt ?? randi(0, SHIRTS.length - 1),
pants: data.pants ?? randi(0, PANTS.length - 1),
traits: data.traits || Object.fromEntries(TRAITS.map(t => [t, randi(1, 9)])),
aspiration: data.aspiration || choice(['fortune', 'knowledge', 'family', 'romance', 'popularity']),
ageStage,
rel: {}, // playerSimId -> {ltr,str} persistent memory
movedIn: false,
lastVisitDay: -99,
lastCallDay: -99,
};
}
/* ---------------- persistent visitor memory ---------------- */
function simFromHoodMeta(meta) {
const v = new Sim({
name: meta.name, gender: meta.gender, skin: meta.skin,
hairStyle: meta.hairStyle, hairColor: meta.hairColor,
shirt: meta.shirt, pants: meta.pants,
traits: { ...meta.traits }, aspiration: meta.aspiration,
ageStage: meta.ageStage,
isVisitor: true,
x: G.world.mailbox.x, y: LOT_H - 2,
});
v.hoodMeta = meta;
// seed remembered relationships with the household
for (const s of G.sims) {
if (s.isVisitor) continue;
const mem = meta.rel[s.id];
const r = v.getRel(s);
if (mem) { r.ltr = mem.ltr; r.str = mem.str; }
else { r.ltr = clamp(G.neighborhoodFriendBase + randi(-15, 25), 0, 60); }
}
return v;
}
function syncVisitorMemory(v) {
if (!v.hoodMeta) return;
for (const s of G.sims) {
if (s === v || s.isVisitor) continue;
const r = v.rels && v.rels.get(s.id);
if (r) v.hoodMeta.rel[s.id] = { ltr: Math.round(r.ltr), str: Math.round(r.str) };
}
v.hoodMeta.lastVisitDay = G.time.day;
}
function spawnVisitor(forceMeta = null) {
if (G.sims.filter(s => s.isVisitor).length >= 2 && !forceMeta) { toast('🏠 You already have company!'); return null; }
let meta = forceMeta;
if (!meta && chance(.75)) {
const pool = [];
for (const lot of G.neighborhood.lots)
for (const m of lot.family)
if (!m.movedIn && m.lastVisitDay < G.time.day && !G.sims.some(s => s.hoodMeta === m))
pool.push(m);
if (pool.length) meta = choice(pool);
}
let v;
if (meta) {
v = simFromHoodMeta(meta);
toast(`👋 ${v.name.split(' ')[0]} from the ${meta.name.split(' ').slice(1).join(' ') || meta.name} household dropped by!`, 'good');
} else {
const data = randomSimData();
data.name = data.name.split(' ')[0] + ' ' + choice(LAST_NAMES);
v = new Sim({ ...data, isVisitor: true, x: G.world.mailbox.x, y: LOT_H - 2 });
toast(`👋 ${v.name} dropped by to visit!`, 'good');
}
v.leaveAtMin = G.time.absMin + 240 + randi(0, 120);
v.needs.social = 40;
G.addSim(v);
const spot = G.world.findFreeSpotNear(G.world.mailbox.x, LOT_H - 4, 8);
if (spot) { const p = G.world.findPath(v.x, v.y, spot[0], spot[1]); if (p) v.setPath(p); }
return v;
}
/* ---------------- daily stroll schedule ---------------- */
function scheduleVisitors() {
const dayStart = Math.floor(G.time.absMin / 1440) * 1440;
G.visitsToday = [];
const n = randi(0, 2);
for (let i = 0; i < n; i++) G.visitsToday.push(dayStart + randi(600, 1290));
G.visitsToday.sort((a, b) => a - b);
}
function processVisits() {
while (G.visitsToday && G.visitsToday.length && G.time.absMin >= G.visitsToday[0]) {
G.visitsToday.shift();
if (G.mode === 'live' || G.mode === 'hood') spawnVisitor();
}
}
/* ============================================================
* Neighborhood view (mode 'hood')
* ============================================================ */
function enterHood() {
setMode('hood');
AudioSys.sfx('click');
}
function exitHood() { setMode('live'); }
function hoodLotRects(W, H) {
// layout in relative coords → CSS px
const cx = W / 2, cy = H / 2;
const rects = [{ key: 'player', x: cx - 90, y: cy - 70, w: 180, h: 150 }];
for (const lot of G.neighborhood.lots)
rects.push({ key: lot.id, lot, x: cx + lot.gx * W * .30 - 80, y: cy + lot.gy * H * .42 - 65, w: 160, h: 140 });
return rects;
}
function drawMiniHouse(ctx, x, y, w, h, roof, isPlayer, selected) {
ctx.save();
if (selected) { ctx.strokeStyle = '#ffd23e'; ctx.lineWidth = 3; ctx.strokeRect(x - 4, y - 4, w + 8, h + 8); }
// lawn pad
ctx.fillStyle = '#79b356';
ctx.beginPath(); ctx.ellipse(x + w / 2, y + h - 18, w * .52, h * .22, 0, 0, 7); ctx.fill();
// house body
ctx.fillStyle = '#efe6d2';
ctx.fillRect(x + w * .18, y + h * .38, w * .64, h * .42);
// roof
ctx.fillStyle = roof;
ctx.beginPath();
ctx.moveTo(x + w * .08, y + h * .40);
ctx.lineTo(x + w * .5, y + h * .06);
ctx.lineTo(x + w * .92, y + h * .40);
ctx.closePath(); ctx.fill();
// door + windows
ctx.fillStyle = '#7c5836';
ctx.fillRect(x + w * .44, y + h * .58, w * .12, h * .22);
ctx.fillStyle = '#bfe3ef';
ctx.fillRect(x + w * .26, y + h * .50, w * .12, h * .13);
ctx.fillRect(x + w * .62, y + h * .50, w * .12, h * .13);
// tree
ctx.fillStyle = '#8a6438'; ctx.fillRect(x + w * .86, y + h * .58, 5, 14);
ctx.fillStyle = '#4a8a4a';
ctx.beginPath(); ctx.arc(x + w * .885, y + h * .52, 11, 0, 7); ctx.fill();
if (isPlayer) {
ctx.font = `${Math.round(h * .14)}px sans-serif`; ctx.textAlign = 'center';
ctx.fillText('⭐', x + w / 2, y + h * .02);
}
ctx.restore();
}
function heartsFor(lot) {
// best relationship any resident has with any member of this lot
let best = lot.friendship * .5;
for (const s of G.sims) {
if (s.isVisitor || !s.rels) continue;
for (const m of lot.family) {
const r = s.rels.get(m.id);
if (r) best = Math.max(best, r.ltr);
}
}
return Math.max(0, Math.min(5, Math.round(best / 20)));
}
function drawHood() {
const c = R.ctx, W = R.W, H = R.H;
c.save();
/* sky & grass */
const sky = c.createLinearGradient(0, 0, 0, H * .45);
sky.addColorStop(0, '#8ecfe8'); sky.addColorStop(1, '#cfeaf2');
c.fillStyle = sky; c.fillRect(0, 0, W, H * .45);
const grass = c.createLinearGradient(0, H * .4, 0, H);
grass.addColorStop(0, '#8cc06a'); grass.addColorStop(1, '#5f9a44');
c.fillStyle = grass; c.fillRect(0, H * .42, W, H * .58);
/* clouds */
c.fillStyle = 'rgba(255,255,255,.85)';
for (let i = 0; i < 4; i++) {
const cxp = ((R.time * 8 + i * 340) % (W + 200)) - 100, cyp = 40 + (i % 2) * 46;
c.beginPath(); c.arc(cxp, cyp, 22, 0, 7); c.arc(cxp + 24, cyp - 8, 17, 0, 7); c.arc(cxp - 22, cyp - 4, 15, 0, 7); c.fill();
}
/* winding road */
c.strokeStyle = '#cfc4ae'; c.lineWidth = 34; c.lineCap = 'round';
c.beginPath(); c.moveTo(-40, H * .78); c.bezierCurveTo(W * .3, H * .6, W * .7, H * .95, W + 40, H * .68); c.stroke();
c.strokeStyle = '#efe6cf'; c.lineWidth = 3; c.setLineDash([16, 14]);
c.beginPath(); c.moveTo(-40, H * .78); c.bezierCurveTo(W * .3, H * .6, W * .7, H * .95, W + 40, H * .68); c.stroke();
c.setLineDash([]);
/* title */
c.fillStyle = '#2c3a2a'; c.font = 'bold 30px Georgia,serif'; c.textAlign = 'left';
c.fillText('🏘️ Pleasantview', 28, 48);
c.font = '14px sans-serif'; c.fillStyle = '#41503c';
c.fillText('Click a house to meet the neighbors — press N or Esc to go home', 28, 72);
/* lots */
const sel = G.hoodSel;
drawMiniHouse(c, W / 2 - 90, H / 2 - 70, 180, 150, '#b5432e', true, false);
c.fillStyle = '#233021'; c.font = 'bold 15px sans-serif'; c.textAlign = 'center';
const playerFam = G.sims.find(s => !s.isVisitor);
c.fillText(playerFam ? playerFam.name.split(' ').slice(1).join(' ') + ' Household' : 'Your Household', W / 2, H / 2 + 96);
G.hoodRects = [];
for (const lot of G.neighborhood.lots) {
const rx = W / 2 + lot.gx * W * .30 - 80, ry = H / 2 + lot.gy * H * .42 - 65;
const hovered = G.hoodHover === lot.id;
if (hovered) { c.fillStyle = 'rgba(255,210,62,.18)'; c.fillRect(rx - 8, ry - 8, 176, 156); }
drawMiniHouse(c, rx, ry, 160, 130, lot.roof, false, sel === lot.id);
c.fillStyle = '#233021'; c.font = 'bold 15px sans-serif'; c.textAlign = 'center';
c.fillText(lot.name + ' Household', rx + 80, ry + 148);
/* friendship hearts */
const hearts = heartsFor(lot);
c.font = '13px sans-serif';
let hx = rx + 80 - hearts * 8;
for (let hh = 0; hh < hearts; hh++) { c.fillText('❤️', hx + hh * 17, ry + 168); }
G.hoodRects.push({ lot, x: rx - 8, y: ry - 8, w: 176, h: 172 });
}
/* info card */
if (sel) drawHoodCard(c, W, H);
/* close button */
c.fillStyle = 'rgba(30,26,24,.85)';
roundRect(c, W - 118, 20, 92, 38, 10); c.fill();
c.fillStyle = '#fff'; c.font = 'bold 15px sans-serif'; c.textAlign = 'center';
c.fillText('🏠 Home', W - 72, 44);
G.hoodHomeBtn = { x: W - 118, y: 20, w: 92, h: 38 };
c.restore();
}
function drawHoodCard(c, W, H) {
const lot = G.neighborhood.lots.find(l => l.id === G.hoodSel);
if (!lot) return;
const cw = 320, ch = 120 + lot.family.length * 74 + 66;
const x = W - cw - 26, y = H / 2 - ch / 2;
c.save();
c.fillStyle = 'rgba(250,246,236,.97)';
roundRect(c, x, y, cw, ch, 14); c.fill();
c.strokeStyle = lot.roof; c.lineWidth = 3; roundRect(c, x, y, cw, ch, 14); c.stroke();
c.fillStyle = '#233021'; c.font = 'bold 19px Georgia,serif'; c.textAlign = 'left';
c.fillText(`🏡 The ${lot.name}s`, x + 18, y + 32);
c.font = '12px sans-serif'; c.fillStyle = '#6a6a5f';
G.hoodBtns = [];
let yy = y + 62;
const refSim = G.selectedSim && !G.selectedSim.isVisitor ? G.selectedSim : G.sims.find(s => !s.isVisitor);
for (const m of lot.family) {
if (m.movedIn) continue;
/* avatar */
c.fillStyle = SKINS[m.skin % SKINS.length];
c.beginPath(); c.arc(x + 36, yy + 22, 17, 0, 7); c.fill();
c.fillStyle = HAIRS[m.hairColor % HAIRS.length];
c.beginPath(); c.arc(x + 36, yy + 16, 16, Math.PI, 0); c.fill();
/* name + stage */
c.fillStyle = '#233021'; c.font = 'bold 14px sans-serif';
c.fillText(m.name, x + 62, yy + 14);
c.font = '12px sans-serif'; c.fillStyle = '#6a6a5f';
const onLot = G.sims.some(s => s.hoodMeta === m);
c.fillText(`${m.ageStage === 'child' ? '🧒 Child' : '🧑 Adult'}${onLot ? ' · visiting now 👋' : ''}`, x + 62, yy + 31);
/* rel bar toward refSim */
const mem = refSim && refSim.rels ? null : null; // (kept for clarity)
const memRel = refSim && refSim.rels.get(m.id);
const val = memRel ? (memRel.ltr + 100) / 2 : 30 + lot.friendship * .4;
c.fillStyle = '#ddd6c6'; roundRect(c, x + 62, yy + 40, 170, 9, 4); c.fill();
c.fillStyle = val > 60 ? '#59b356' : val > 40 ? '#d8a53a' : '#c0574a';
roundRect(c, x + 62, yy + 40, Math.max(8, 170 * val / 100), 9, 4); c.fill();
c.fillStyle = '#6a6a5f'; c.font = '11px sans-serif'; c.textAlign = 'right';
c.fillText(memRel ? (memRel.ltr > 60 ? 'friends ❤️' : memRel.ltr > 20 ? 'friendly' : memRel.ltr < -20 ? 'tense ⚔️' : 'acquainted') : 'not met yet', x + 300, yy + 49);
c.textAlign = 'left';
/* buttons */
const by = yy + 52;
const canInvite = !onLot;
const canCall = !onLot && m.lastCallDay < G.time.day;
drawHoodBtn(c, x + 62, by, 108, 26, '👋 Invite Over', canInvite ? lot.roof : '#b9b2a2', !canInvite);
G.hoodBtns.push({ x: x + 62, y: by, w: 108, h: 26, fn: () => inviteHoodMember(m), disabled: !canInvite });
drawHoodBtn(c, x + 182, by, 118, 26, '📞 Phone Chat', canCall ? '#4a8a4a' : '#b9b2a2', !canCall);
G.hoodBtns.push({ x: x + 182, y: by, w: 118, h: 26, fn: () => callHoodMember(m), disabled: !canCall });
yy += 74;
}
/* close card */
drawHoodBtn(c, x + 18, y + ch - 44, cw - 36, 30, '✖ Close', '#5c5648', false);
G.hoodBtns.push({ x: x + 18, y: y + ch - 44, w: cw - 36, h: 30, fn: () => { G.hoodSel = null; }, disabled: false });
c.restore();
}
function drawHoodBtn(c, x, y, w, h, label, color, disabled) {
c.fillStyle = color;
roundRect(c, x, y, w, h, 8); c.fill();
c.globalAlpha = disabled ? .55 : 1;
c.fillStyle = '#fff'; c.font = 'bold 12px sans-serif'; c.textAlign = 'center';
c.fillText(label, x + w / 2, y + h / 2 + 4);
c.globalAlpha = 1;
c.textAlign = 'left';
}
function inviteHoodMember(m) {
AudioSys.sfx('click');
const already = G.sims.some(s => s.hoodMeta === m);
if (already) { toast('They are already at your place!', ''); return; }
if (G.sims.filter(s => s.isVisitor).length >= 2) { toast('🏠 Not enough room — you have company already.', 'bad'); return; }
const v = spawnVisitor(m);
if (v) { exitHood(); toast(`📞 ${v.name} said they'd love to come over!`, 'good'); }
}
function callHoodMember(m) {
AudioSys.sfx('chime');
m.lastCallDay = G.time.day;
const refSim = G.selectedSim && !G.selectedSim.isVisitor ? G.selectedSim : G.sims.find(s => !s.isVisitor);
if (refSim) {
const r = refSim.rels.get(m.id);
if (r) r.ltr = clamp(r.ltr + 3, -100, 100);
}
toast(`📞 You had a nice chat with ${m.name}.`, 'good');
}
function hoodClick(px, py) {
if (G.hoodHomeBtn && px >= G.hoodHomeBtn.x && px <= G.hoodHomeBtn.x + G.hoodHomeBtn.w &&
py >= G.hoodHomeBtn.y && py <= G.hoodHomeBtn.y + G.hoodHomeBtn.h) { exitHood(); return; }
if (G.hoodSel) {
for (const b of (G.hoodBtns || [])) {
if (!b.disabled && px >= b.x && px <= b.x + b.w && py >= b.y && py <= b.y + b.h) { b.fn(); return; }
}
}
for (const r of (G.hoodRects || [])) {
if (px >= r.x && px <= r.x + r.w && py >= r.y && py <= r.y + r.h) {
G.hoodSel = (G.hoodSel === r.lot?.id) ? null : r.lot.id;
AudioSys.sfx(r.lot ? 'chime' : 'error');
return;
}
}
G.hoodSel = null;
}
function hoodHover(px, py) {
G.hoodHover = null;
for (const r of (G.hoodRects || []))
if (px >= r.x && px <= r.x + r.w && py >= r.y && py <= r.y + r.h) G.hoodHover = r.lot.id;
}
+886
View File
@@ -0,0 +1,886 @@
/* ============================================================
* main.js — global state G, game loop, input, camera,
* Create-A-Sim, title flow, save/load, bills, aging
* ============================================================ */
'use strict';
const SPEED_MUL = [0, 1, 3, 8]; // game-minutes per real second multiplier
const START_FUNDS = 20000;
window.G = {
mode: 'title', // title | cas | live | buy | build
cam: { x: 0, y: 0, zoom: 1 },
world: null,
sims: [],
time: {
absMin: 7 * 60,
get day() { return Math.floor(this.absMin / 1440) + 1; },
get hourFloat() { return (this.absMin % 1440) / 60; },
get hour() { return Math.floor(this.hourFloat); },
get min() { return Math.floor(this.absMin % 60); },
},
funds: START_FUNDS,
speed: 1, prevSpeed: 1,
freeWill: true,
aspirationPoints: 0,
selectedSim: null,
buySel: null, buyRot: 0,
buildTool: 'wall', floorSel: 1,
wallColor: '#efe6d4',
mouseTile: null, hoverEdge: null,
dtReal: 0,
pendingPizza: 0,
mailBillsDue: false, billsAmount: 0, billsPaid: true, nextBillDay: 4,
_jobMenuSim: null,
dirtPuddleTick: 0,
dishPiles: [],
weather: { type: 'sunny', flash: 0, boltIn: 0 },
fires: [],
neighborhood: null,
hoodSel: null, hoodRects: [], hoodBtns: [], hoodHover: null,
neighborhoodFriendBase: 15,
visitsToday: [],
ghosts: [],
graves: [],
party: null,
pendingGroceries: 0,
pendingChance: null,
simById(id) { return this.sims.find(s => s.id === id) || null; },
addSim(s) { this.sims.push(s); Bus.emit('simsChanged'); rebuildPortraits(); },
removeSim(s) {
this.sims = this.sims.filter(x => x !== s);
// release every object reservation the departing sim held
if (G.world) for (const o of G.world.objects) if (o.usedBy === s) o.usedBy = null;
if (s.isVisitor && s.hoodMeta) syncVisitorMemory(s); // neighbors remember!
if (this.selectedSim === s) selectSim(this.sims.find(x => !x.isVisitor) || null);
Bus.emit('simsChanged');
if (G.mode !== 'cas') rebuildPortraits();
},
};
/* ============================================================
* BOOT
* ============================================================ */
initRender(document.getElementById('game'));
centerCamera();
requestAnimationFrame(frame);
let lastTs = performance.now();
let uiAccum = 0;
function frame(ts) {
const dt = Math.min(0.1, (ts - lastTs) / 1000);
lastTs = ts;
G.dtReal = dt;
R.time += dt;
handlePanKeys(dt);
if (G.mode === 'cas') drawCasPreview();
else if (G.world && G.mode === 'hood') { drawHood(); tickFx(dt); }
else if (G.world && G.mode !== 'title') {
const mps = SPEED_MUL[G.speed]; // game minutes per real second
let gmin = mps * dt;
if (gmin > 0) advanceTime(gmin);
draw();
tickFx(dt);
} else if (G.world) draw();
uiAccum += dt;
if (uiAccum > 0.15 && G.mode !== 'title' && G.mode !== 'cas') {
uiAccum = 0;
updateHud();
refreshPortraitsThrottled();
}
requestAnimationFrame(frame);
}
/* ---------------- time & world upkeep ---------------- */
let roomTimer = 0;
let lastDay = 1;
let autosaveMark = -1;
function advanceTime(gmin) {
G.time.absMin += gmin;
// sims
for (const s of [...G.sims]) {
s.tick(gmin);
// career/school mood sampling while away
if (s.atWork) { s.workMoodSum = (s.workMoodSum || 0) + s.moodScore() * gmin; s.workMoodN = (s.workMoodN || 0) + gmin; }
if (s.atSchool) { s.schoolMoodSum = (s.schoolMoodSum || 0) + s.moodScore() * gmin; s.schoolMoodN = (s.schoolMoodN || 0) + gmin; }
}
CareerSys.tick(gmin);
SchoolSys.tick();
processVisits();
PartySys.tick();
ghostTick(gmin);
fireTick(gmin);
worldUpkeep(gmin);
// puddle drying
for (const arr of [G.world.dirtPuddle]) {
if (!arr) break;
for (let i = arr.length - 1; i >= 0; i--) {
arr[i].t -= gmin * 2;
if (arr[i].t <= 0) arr.splice(i, 1);
}
}
// environment recompute
roomTimer += gmin;
if (roomTimer > 20 || Bus._dirty) { roomTimer = 0; G.world.recomputeRoom(); }
// day rollover
const day = G.time.day;
if (day !== lastDay) {
lastDay = day;
onNewDay(day);
}
// bills
if (!G.mailBillsDue && day >= G.nextBillDay && G.time.hour >= 9) sendBills();
if (G.mailBillsDue && !G.billsPaid && G.time.absMin > G.billDeadline) repossess();
// daily autosave at 01:00
if (G.time.hour === 1 && autosaveMark !== day) { autosaveMark = day; saveGame(true); }
// weather ambience: lightning strikes while raining
if (G.weather.type === 'rain') {
if (G.weather.boltIn <= 0) {
G.weather.boltIn = rand(25, 90); // real seconds
G.weather.flash = .8;
AudioSys.sfx('thud');
} else {
G.weather.boltIn -= G.dtReal;
}
}
if (G.weather.flash > 0) G.weather.flash -= G.dtReal * 2.2;
// queue processing
for (const s of G.sims) {
if (!s.action && s.queue.length && !s.path.length) {
const nxt = s.queue.shift();
s.action = nxt; nxt.begin();
if (nxt.done) s.action = null;
}
// release stuck walkers
if (s.anim === 'walk' && !s.path.length && !s.action) s.anim = 'idle';
}
}
function onNewDay(day) {
// roll today's weather
const r = Math.random();
G.weather.type = r < .5 ? 'sunny' : r < .8 ? 'cloudy' : 'rain';
if (G.weather.type !== 'sunny') toast(G.weather.type === 'rain' ? '🌧️ Rain moving in today…' : '☁️ A cloudy day.');
document.getElementById('weatherIcon').textContent =
G.weather.type === 'rain' ? '🌧️' : G.weather.type === 'cloudy' ? '☁️' : '☀️';
scheduleVisitors(); // neighbors plan their strolls-by today
for (const s of G.sims) if (!s.isVisitor) {
s.daysAlive++;
// relationships drift toward long-term baseline
for (const [, r] of s.rels) r.str = lerp(r.str, r.ltr, 0.35);
// birthdays
if (s.daysAlive === 20 && s.ageStage === 'adult') {
s.ageStage = 'elder';
toast(`🎂 Happy Birthday, ${s.name}! They are now an elder.`, 'good');
s.say('🎂');
}
if (s.ageStage === 'elder' && s.daysAlive >= 34 && chance(.5)) {
dieOfOldAge(s);
continue;
}
}
G.billsPaid = false;
}
function dieOfOldAge(s) { dieOf(s, 'oldage'); }
function sendBills() {
let value = 0;
for (const o of G.world.objects) value += OBJECTS[o.defId].price || 0;
value += G.world.walls.size * 70;
G.billsAmount = Math.max(60, Math.round(value * 0.006));
G.mailBillsDue = true; G.billsPaid = false;
G.billDeadline = G.time.absMin + 24 * 60;
G.nextBillDay = G.time.day + 3;
toastBill(G.billsAmount);
}
function repossess() {
const sellable = G.world.objects.filter(o => (OBJECTS[o.defId].price || 0) >= 200);
G.mailBillsDue = false;
if (sellable.length) {
const victim = choice(sellable);
G.world.removeObject(victim);
toast(`🚚 The bill collector repossessed the ${OBJECTS[victim.defId].name}!`, 'bad');
} else {
toast(`😤 Collection agency fines you §200!`, 'bad');
G.funds -= 200;
}
}
/* ============================================================
* MODE SWITCHING
* ============================================================ */
function setMode(m) {
if (G.mode === m) return;
const leavingBuildish = G.mode === 'buy' || G.mode === 'build' || G.mode === 'hood';
G.mode = m;
hidePie();
document.getElementById('modeLive').classList.toggle('active', m === 'live');
document.getElementById('modeBuy').classList.toggle('active', m === 'buy');
document.getElementById('modeBuild').classList.toggle('active', m === 'build');
if (m === 'buy') { openBuyDrawer(); closeBuildBar(); G.buySel = G.buySel; }
else closeBuyDrawer();
if (m === 'build') { openBuildBar(); closeBuyDrawer(); }
else closeBuildBar();
if ((m === 'hood') && !leavingBuildish) {
G.prevSpeed = G.speed || 1; setSpeed(0);
G.hoodSel = null;
}
if (m === 'live' && G.speed === 0 && G.prevSpeed) setSpeed(G.prevSpeed);
if (m !== 'buy') { /* keep buySel for return */ }
updateSimPanel();
R.canvas.style.cursor = m === 'live' ? 'default' : 'crosshair';
}
document.getElementById('modeLive').onclick = () => setMode('live');
document.getElementById('modeBuy').onclick = () => setMode('buy');
document.getElementById('modeBuild').onclick = () => setMode('build');
/* ---------------- options ---------------- */
document.getElementById('btnFreeWill').onclick = function () {
G.freeWill = !G.freeWill;
this.classList.toggle('active', G.freeWill);
if (G.freeWill) {
const n = G.sims.filter(s => !s.isVisitor && s.ageStage !== 'baby').length;
toast(`🤖 AI Mode ON — ${n} sim(s) now follow their own needs & goals.`, 'good');
AudioSys.sfx('chime');
} else {
toast('🧍 AI Mode OFF — you control everyone directly.', '');
AudioSys.sfx('click');
}
};
document.getElementById('btnSave').onclick = () => { saveGame(false); };
document.getElementById('btnQuit').onclick = () => { saveGame(true); location.reload(); };
/* ============================================================
* INPUT — camera pan/zoom, picking, build/buy tools
* ============================================================ */
const keys = new Map();
let mouseDown = null; // {button, sx, sy, moved, lastTile}
function canvasPos(e) { const r = R.canvas.getBoundingClientRect(); return [e.clientX - r.left, e.clientY - r.top]; }
R.canvas.addEventListener('mousemove', (e) => {
const [px, py] = canvasPos(e);
G.mousePx = [px, py];
const [wx, wy] = pxToWorld(px, py);
G.mouseTile = [Math.floor(wx), Math.floor(wy)];
G.hoverEdge = pickEdge(px, py);
if (G.mode === 'hood') hoodHover(px, py);
if (mouseDown && mouseDown.button === 2) {
G.cam.x += e.movementX; G.cam.y += e.movementY;
if (Math.abs(e.movementX) + Math.abs(e.movementY) > 0) mouseDown.moved = true;
return;
}
if (mouseDown && mouseDown.button === 0) {
if (dist2(px, py, mouseDown.sx, mouseDown.sy) > 36) mouseDown.moved = true;
if (G.mode === 'build') dragBuildTo(G.mouseTile);
}
});
R.canvas.addEventListener('mousedown', (e) => {
if (G.mode === 'title' || G.mode === 'cas') return;
const [px, py] = canvasPos(e);
mouseDown = { button: e.button, sx: px, sy: py, moved: false, lastTile: [...G.mouseTile] };
if (e.button === 0) onClickLeft(px, py);
});
window.addEventListener('mouseup', (e) => {
if (mouseDown && mouseDown.button === 2 && !mouseDown.moved) onClickRight(e);
mouseDown = null;
});
R.canvas.addEventListener('contextmenu', (e) => e.preventDefault());
R.canvas.addEventListener('wheel', (e) => {
e.preventDefault();
const [px, py] = canvasPos(e);
const oldZ = G.cam.zoom;
const nz = clamp(oldZ * Math.exp(-e.deltaY * 0.0012), 0.45, 2.4);
// zoom toward cursor
G.cam.x = px - (px - G.cam.x) * (nz / oldZ);
G.cam.y = py - (py - G.cam.y) * (nz / oldZ);
G.cam.zoom = nz;
}, { passive: false });
function handlePanKeys(dt) {
const v = 650 * dt / G.cam.zoom;
if (keys.has('arrowleft') || keys.has('a')) G.cam.x += v;
if (keys.has('arrowright') || keys.has('d')) G.cam.x -= v;
if (keys.has('arrowup') || keys.has('w')) G.cam.y += v;
if (keys.has('arrowdown') || keys.has('s')) G.cam.y -= v;
}
window.addEventListener('keydown', (e) => {
const k = e.key.toLowerCase();
keys.set(k, true);
if (G.mode === 'title' || G.mode === 'cas') return;
if (k === ' ') { e.preventDefault(); setSpeed(G.speed === 0 ? (G.prevSpeed || 1) : (G.prevSpeed = G.speed, 0)); }
if (k === '1') setSpeed(1);
if (k === '2') setSpeed(2);
if (k === '3') setSpeed(3);
if (k === 'f') document.getElementById('btnFreeWill').click();
if (k === 'p') setMode(G.mode === 'live' ? 'buy' : G.mode === 'buy' ? 'build' : 'live');
if (k === 'n') { G.mode === 'hood' ? exitHood() : enterHood(); }
if (k === 'r' && G.mode === 'buy' && G.buySel) G.buyRot = (G.buyRot + 1) % 4;
if (k === 'escape') {
if (!document.getElementById('pieMenu').classList.contains('hidden')) hidePie();
else if (G.mode !== 'live') setMode('live');
else selectSim(null);
}
});
window.addEventListener('keyup', (e) => keys.delete(e.key.toLowerCase()));
/* ---------- picking helpers ---------- */
function simAtScreen(px, py) {
let best = null, bd = 22 * 22 * G.cam.zoom * G.cam.zoom;
for (const s of G.sims) {
if (!s.atHome) continue;
const [ax, ay] = isoToScreen(s.x, s.y);
const sx = ax * G.cam.zoom + G.cam.x, sy = ay * G.cam.zoom + G.cam.y - 20 * G.cam.zoom;
const d = dist2(sx, sy, px, py);
if (d < bd) { bd = d; best = s; }
}
return best;
}
function pickEdge(px, py) {
if (!G.mouseTile || !G.world.inside(...G.mouseTile)) return null;
const [x, y] = G.mouseTile;
const cands = [
{ x, y, e: 'n' }, { x, y, e: 'w' }, { x, y: y + 1, e: 'n' }, { x: x + 1, y, e: 'w' },
];
let best = null, bd = 18 * 18;
const z = WALL_H * G.cam.zoom * .55;
for (const c of cands) {
if (c.y > G.world.h || c.x > G.world.w) continue;
const A = c.e === 'n' ? tileCornerPx(c.x, c.y) : tileCornerPx(c.x, c.y);
const B = c.e === 'n' ? tileCornerPx(c.x + 1, c.y) : tileCornerPx(c.x, c.y + 1);
const mx = (A[0] + B[0]) / 2, my = (A[1] + B[1]) / 2 - z;
const d = ptSegDist2(px, py, A[0], A[1] - z, B[0], B[1] - z);
void mx; void my;
if (d < bd) { bd = d; best = c; }
}
return best;
}
function ptSegDist2(p, q, ax, ay, bx, by) {
const dx = bx - ax, dy = by - ay;
const L2 = dx * dx + dy * dy;
let t = L2 ? ((p - ax) * dx + (q - ay) * dy) / L2 : 0;
t = clamp(t, 0, 1);
return dist2(p, q, ax + t * dx, ay + t * dy);
}
/* ---------- clicks ---------- */
function onClickLeft(px, py) {
if (G.mode === 'hood') { hoodClick(px, py); return; }
if (G.mode === 'buy') {
if (G.buySel) tryPlaceBuy();
else pickAndSelectSimOrNothing();
return;
}
if (G.mode === 'build') { buildClick(); return; }
// LIVE MODE
const sim = simAtScreen(px, py);
if (sim) {
const a = G.selectedSim;
// babies get a care menu instead of socials
if (sim.ageStage === 'baby') {
const actor = (a && !a.isVisitor && a.ageStage !== 'baby') ? a : firstFamilySim();
const pseudo = { defId:'baby', x: sim.x, y: sim.y, w:1, h:1, usedBy:null, simRef: sim };
const entries = [];
if (actor && actor.ageStage === 'adult') {
entries.push({ label:'Feed Baby', icon:'🍼', fn: () => commandUse(actor, { ...pseudo }, { id:'feedBaby', label:'Feed Baby', icon:'🍼', special:'feedBaby', pose:'stand', dur:20 }) });
entries.push({ label:'Cuddle Baby', icon:'🤱', fn: () => commandUse(actor, { ...pseudo }, { id:'cuddleBaby', label:'Cuddle Baby', icon:'🤱', special:'cuddleBaby', pose:'stand', dur:16 }) });
} else {
entries.push({ label:'(Need an adult to care for the baby)', icon:'🚼', disabled:true, fn:()=>{} });
}
entries.push('-');
entries.push({ label:'Switch to ' + sim.name.split(' ')[0], icon:'👆', fn: () => selectSim(sim) });
showPie(px, py, entries, '👶 ' + sim.name);
return;
}
const a2 = a;
if (!a || a === sim || a.isVisitor) {
selectSim(sim);
} else if (!a.atHome) {
selectSim(sim);
} else {
// social pie toward clicked sim
const rel = a.getRel(sim);
const entries = [];
for (const s of SOCIALS) {
if (s.minRel != null && rel.ltr < s.minRel) continue;
if (s.minRelMax != null && rel.str > s.minRelMax) continue;
entries.push({ label: s.label, icon: s.icon, fn: () => AI.startSocial(a, sim, s) });
}
entries.push('-');
// love & household growth
const neitherMarried = !a2.marriedTo && !sim.marriedTo;
if (neitherMarried && rel.ltr >= 85) {
entries.push({ label: 'Propose Marriage', icon: '💍', fn: () => proposeMarriage(a2, sim) });
}
if (sim.isVisitor && rel.ltr >= 65) {
entries.push({ label: 'Ask to Move In', icon: '🏡', fn: () => askToMoveIn(a2, sim) });
}
if (entries.length > 1) entries.push('-');
entries.push({ label: 'Switch to ' + sim.name.split(' ')[0], icon: '👆', fn: () => selectSim(sim) });
showPie(px, py, entries, '💬 ' + sim.name + (rel.ltr >= 50 ? ' 🤝' : rel.ltr <= -30 ? ' ⚔️' : ''));
}
return;
}
const t = G.mouseTile;
const obj = t && G.world.objAt(t[0], t[1]);
if (obj) {
selectSim(G.selectedSim || firstFamilySim());
showPie(px, py, objectInteractions(obj), `${OBJECTS[obj.defId].emoji} ${OBJECTS[obj.defId].name}`);
return;
}
// walk command
const s = G.selectedSim || firstFamilySim();
if (s && t && G.world.inside(t[0], t[1])) commandGoHere(s, t[0], t[1]);
}
function onClickRight(e) {
const [px, py] = canvasPos(e);
if (G.mode === 'buy') {
const t = G.mouseTile;
const obj = t && G.world.objAt(t[0], t[1]);
if (obj) {
const def = OBJECTS[obj.defId];
if (obj.usedBy) { toast("Can't sell an object in use!", 'bad'); return; }
const refund = Math.round(def.price * 0.7);
G.funds += refund;
G.world.removeObject(obj);
toast(`💰 Sold ${def.name} back for ${fmtMoney(refund)}.`);
Bus.emit('fundsChanged');
return;
}
if (G.buySel) { G.buySel = null; openBuyDrawer(); }
return;
}
if (G.mode === 'live') {
const t = G.mouseTile;
const obj = t && G.world.objAt(t[0], t[1]);
if (obj) showPie(px, py, objectInteractions(obj), `${OBJECTS[obj.defId].emoji} ${OBJECTS[obj.defId].name}`);
}
}
function firstFamilySim() { return G.sims.find(s => !s.isVisitor) || null; }
function pickAndSelectSimOrNothing() { /* click-through in buy mode when nothing selected */ }
/* ---------- buy placement ---------- */
function tryPlaceBuy() {
const def = OBJECTS[G.buySel];
if (!def || !G.mouseTile) return;
const rot = G.buyRot;
const w = rot % 2 ? def.h : def.w, h = rot % 2 ? def.w : def.h;
if (G.funds < def.price) { toast('❌ Not enough simoleons!', 'bad'); return; }
if (!G.world.canPlace(def, G.mouseTile[0], G.mouseTile[1], rot % 2)) {
toast("🚫 Can't place it there.", 'bad'); return;
}
G.funds -= def.price;
G.world.placeObject(G.buySel, G.mouseTile[0], G.mouseTile[1], rot % 2);
Bus.emit('fundsChanged');
}
/* ---------- build tools ---------- */
function buildClick() {
const tool = G.buildTool;
const ed = G.hoverEdge;
if (tool === 'wall' ) { /* handled by drag */ mouseDown.lastTile = [...(G.mouseTile||[])]; return; }
if (tool === 'floor') { paintFloorTile(G.mouseTile); return; }
if (!ed) return;
if (tool === 'door' || tool === 'window') {
const w = G.world.wallAt(ed.x, ed.y, ed.e);
if (!w || w.kind !== 'wall') { toast('Doors & windows go into existing walls.', 'bad'); return; }
const cost = tool === 'door' ? 250 : 180;
if (G.funds < cost) { toast('❌ Not enough simoleons!', 'bad'); return; }
G.funds -= cost;
w.kind = tool;
Bus.emit('worldChanged');
return;
}
if (tool === 'delWall') {
const w = G.world.wallAt(ed.x, ed.y, ed.e);
if (w) { G.world.removeWall(ed.x, ed.y, ed.e); G.funds += 35; Bus.emit('fundsChanged'); }
return;
}
}
function dragBuildTo(tile) {
if (!tile || !mouseDown?.lastTile) return;
const [lx, ly] = mouseDown.lastTile;
let [cx, cy] = tile;
const tool = G.buildTool;
// step line toward cursor one tile at a time
let guard = 40;
while ((lx !== cx || ly !== cy) && guard-- > 0) {
let nx = lx, ny = ly;
if (Math.abs(cx - lx) >= Math.abs(cy - ly)) nx += Math.sign(cx - lx);
else ny += Math.sign(cy - ly);
const ed = G.world.sharedEdge(lx, ly, nx, ny);
if (ed) {
if (tool === 'wall') {
if (G.funds >= 70) {
if (G.world.placeWall(ed.x, ed.y, ed.e, 'wall')) { G.funds -= 70; Bus.emit('fundsChanged'); }
} else { toastOnce('❌ Out of money for walls!', 'bad'); break; }
} else if (tool === 'delWall') {
if (G.world.removeWall(ed.x, ed.y, ed.e)) { G.funds += 35; Bus.emit('fundsChanged'); }
} else if (tool === 'floor') {
paintFloorTile([nx, ny]);
}
}
mouseDown.lastTile = [nx, ny];
mouseDown.lastTile[0] = nx; mouseDown.lastTile[1] = ny;
if (tool === 'floor') break; // floor paints per-tile via paintFloorTile below too
}
if (tool === 'floor') paintFloorTile(tile);
}
let lastToastKey = '', lastToastT = 0;
function toastOnce(msg, cls) {
if (performance.now() - lastToastT < 2500 && msg === lastToastKey) return;
lastToastKey = msg; lastToastT = performance.now();
toast(msg, cls);
}
function paintFloorTile(tile) {
if (!tile || !G.world.inside(tile[0], tile[1])) return;
const idx = tile[1] * G.world.w + tile[0];
if (G.world.floor[idx] === G.floorSel) return;
if (G.funds < 12) { toastOnce('❌ Out of money for flooring!', 'bad'); return; }
G.funds -= 12;
G.world.setFloor(tile[0], tile[1], G.floorSel);
Bus.emit('fundsChanged');
}
Bus.on('worldChanged', () => { Bus._dirty = true; });
Bus.on('objectsChanged', () => { Bus._dirty = true; });
/* ============================================================
* CAMERA init
* ============================================================ */
function centerCamera() {
const [sx, sy] = isoToScreen(LOT_W / 2, LOT_H / 2);
G.cam.x = window.innerWidth / 2 - sx;
G.cam.y = window.innerHeight / 2 - sy;
G.cam.zoom = clamp(window.innerWidth / 1500, .8, 1.3);
}
/* ============================================================
* CREATE-A-SIM
* ============================================================ */
const CAS = {
fam: [],
cur: 0,
animT: 0,
};
function openCas(fresh = true) {
if (fresh) {
CAS.fam = [];
const a = randomSimData('f'); a.name = 'Bella Goth'; a.nameCustom = true; a.gender = 'f'; a.skin = 0; a.hairStyle = 1; a.hairColor = 0; a.shirt = 4; a.aspiration='fortune';
const b = randomSimData('m'); b.name = 'Mortimer Goth'; b.nameCustom = true; b.gender = 'm'; b.skin = 0; b.hairStyle = 0; b.hairColor = 6; b.shirt = 8; b.aspiration='knowledge';
CAS.fam.push(a, b);
CAS.cur = 0;
}
G.mode = 'cas';
document.getElementById('titleScreen').classList.add('hidden');
document.getElementById('casScreen').classList.remove('hidden');
hideHud();
buildCasControls();
rebuildCasFamilyRow();
}
function hideHud() {
document.getElementById('topbar').classList.add('hidden');
document.getElementById('bottombar').classList.add('hidden');
document.getElementById('simPanel').classList.add('hidden');
}
function showHud() {
document.getElementById('topbar').classList.remove('hidden');
document.getElementById('bottombar').classList.remove('hidden');
}
function curCas() { return CAS.fam[CAS.cur]; }
function buildCasControls() {
const t = curCas();
const R_ = document.getElementById('casRight');
const keepScroll = R_.scrollTop; // rebuilding shouldn't yank the panel around
R_.innerHTML = '';
const row = (label, inner) => {
const d = document.createElement('div'); d.className = 'cas-row';
d.innerHTML = `<span class="clabel">${label}</span>`;
d.appendChild(inner);
R_.appendChild(d);
return d;
};
// name
const nameWrap = document.createElement('div');
nameWrap.innerHTML = `<input type="text" id="casName" maxlength="26" value="${t.name}">`;
row('Name', nameWrap);
R_.querySelector('#casName').oninput = (e) => { t.name = e.target.value; t.nameCustom = true; rebuildCasFamilyRow(); };
// gender — keeps every appearance choice; only suggests a fitting first name
// when the player hasn't typed their own name yet.
const gen = chipGroup([['m', '👨 Male'], ['f', '👩 Female']], t.gender, v => {
if (t.gender === v) return;
t.gender = v;
if (!t.nameCustom) {
const surname = t.name.split(' ').slice(1).join(' ') || choice(LAST_NAMES);
const pool = v === 'f' ? FIRST_NAMES_F : FIRST_NAMES_M;
t.name = choice(pool) + (surname ? ' ' + surname : '');
}
buildCasControls();
rebuildCasFamilyRow();
});
row('Gender', gen);
// skin
row('Skin tone', swatchGroup(SKINS, t.skin, v => { t.skin = v; buildCasControls(); }));
// hair style
row('Hair style', chipGroup([['0', 'Short'], ['1', 'Long'], ['2', 'Ponytail'], ['3', 'Spiky']], String(t.hairStyle),
v => { t.hairStyle = +v; buildCasControls(); }));
row('Hair color', swatchGroup(HAIRS, t.hairColor, v => { t.hairColor = v; buildCasControls(); }));
row('Shirt', swatchGroup(SHIRTS, t.shirt, v => { t.shirt = v; buildCasControls(); }));
row('Pants', swatchGroup(PANTS, t.pants, v => { t.pants = v; buildCasControls(); }));
// traits sliders
const labels = { neat:'Neat ✨', outgoing:'Outgoing 🎉', active:'Active 🏃', playful:'Playful 🤪', nice:'Nice 😊' };
for (const tr of TRAITS) {
const wrap = document.createElement('div');
wrap.style.cssText = 'display:flex;flex:1;align-items:center;gap:8px;';
wrap.innerHTML = `<input type="range" min="0" max="10" value="${t.traits[tr]}" style="flex:1">` +
`<span class="pval">${t.traits[tr]}</span>`;
wrap.querySelector('input').oninput = (e) => {
t.traits[tr] = +e.target.value;
wrap.querySelector('.pval').textContent = e.target.value;
};
row(labels[tr], wrap);
}
// aspiration
row('Aspiration', chipGroup(Object.entries(ASPIRATIONS).map(([k, v]) => [k, v.icon + ' ' + v.name]),
t.aspiration, v => { t.aspiration = v; buildCasControls(); }));
R_.scrollTop = keepScroll;
}
function chipGroup(options, sel, cb) {
const d = document.createElement('div');
d.style.cssText = 'display:flex;gap:5px;flex-wrap:wrap;';
for (const [v, label] of options) {
const c = document.createElement('button');
c.className = 'chip' + (String(v) === String(sel) ? ' sel' : '');
c.textContent = label;
c.onclick = () => cb(v);
d.appendChild(c);
}
return d;
}
function swatchGroup(colors, sel, cb) {
const d = document.createElement('div');
d.style.cssText = 'display:flex;gap:5px;flex-wrap:wrap;';
colors.forEach((c, i) => {
const s = document.createElement('div');
s.className = 'swatchBig' + (i === sel ? ' sel' : '');
s.style.background = c;
s.onclick = () => cb(i);
d.appendChild(s);
});
return d;
}
function rebuildCasFamilyRow() {
const row = document.getElementById('casFamilyRow');
row.innerHTML = '';
CAS.fam.forEach((t, i) => {
const d = document.createElement('div');
d.className = 'famSlot' + (i === CAS.cur ? ' sel' : '');
const cv = document.createElement('canvas'); cv.width = 58; cv.height = 48;
d.appendChild(cv);
const nm = document.createElement('div'); nm.textContent = (t.name || 'Sim').split(' ')[0];
d.appendChild(nm);
if (CAS.fam.length > 1) {
const del = document.createElement('button'); del.className = 'del'; del.textContent = '✕';
del.onclick = (e) => { e.stopPropagation(); CAS.fam.splice(i, 1); CAS.cur = 0; buildCasControls(); rebuildCasFamilyRow(); };
d.appendChild(del);
}
d.onclick = () => { CAS.cur = i; buildCasControls(); rebuildCasFamilyRow(); };
drawMiniPortrait(cv, t);
row.appendChild(d);
});
const add = document.createElement('div');
add.className = 'famSlot';
add.innerHTML = '<span style="font-size:22px"></span><span>Add</span>';
add.onclick = () => {
if (CAS.fam.length >= 8) { toast('Maximum household size is 8!', 'bad'); return; }
CAS.fam.push(randomSimData());
CAS.cur = CAS.fam.length - 1;
buildCasControls(); rebuildCasFamilyRow();
};
row.appendChild(add);
}
function drawMiniPortrait(cv, t) {
const c = cv.getContext('2d');
c.clearRect(0, 0, cv.width, cv.height);
const fake = Object.assign(new Sim({}), t, { selected:false });
drawSimSprite(c, cv.width / 2, cv.height - 4, fake, { zoom: 0.62, facing: 0, anim: 'idle', animT: 0, heightOffset: 74 });
}
let casAnimT = 0;
function drawCasPreview() {
casAnimT += G.dtReal;
const cv = document.getElementById('casPreview');
const c = cv.getContext('2d');
c.clearRect(0, 0, cv.width, cv.height);
const t = curCas();
if (!t) return;
const fake = Object.assign(new Sim({}), t, { selected:false });
const walking = Math.sin(casAnimT * .8) > 0;
drawSimSprite(c, cv.width / 2, cv.height - 30, fake, {
zoom: 2.6, facing: Math.sin(casAnimT * .4) > .6 ? 3 : 0,
anim: walking ? 'walk' : 'idle', animT: casAnimT, heightOffset: 78,
});
}
document.getElementById('casRandomize').onclick = () => {
CAS.fam[CAS.cur] = randomSimData(curCas()?.gender);
buildCasControls(); rebuildCasFamilyRow();
};
document.getElementById('casAdd').onclick = () => {
if (CAS.fam.length >= 8) { toast('Maximum household size is 8!', 'bad'); return; }
CAS.fam.push(randomSimData());
CAS.cur = CAS.fam.length - 1;
buildCasControls(); rebuildCasFamilyRow();
};
document.getElementById('casBack').onclick = () => {
document.getElementById('casScreen').classList.add('hidden');
document.getElementById('titleScreen').classList.remove('hidden');
G.mode = 'title';
};
document.getElementById('casMoveIn').onclick = () => {
startNewGame(CAS.fam.map(t => ({ ...t, traits: { ...t.traits } })));
};
/* ============================================================
* GAME START / SAVE / LOAD
* ============================================================ */
function startNewGame(templates) {
G.world = new World();
buildStarterHouse(G.world);
G.sims = [];
G.funds = START_FUNDS;
G.time.absMin = 7 * 60; // Monday 7:00 AM
G.aspirationPoints = 0;
G.freeWill = true;
G.nextBillDay = 4; G.mailBillsDue = false; G.billsPaid = true; G.pendingPizza = 0;
G.dishPiles = [];
G.fires = [];
G.ghosts = []; G.graves = []; G.party = null; G.pendingGroceries = 0; G.pendingChance = null;
G.neighborhood = genNeighborhood();
scheduleVisitors();
G.weather = { type: 'sunny', flash: 0, boltIn: 0 };
document.getElementById('weatherIcon').textContent = '☀️';
lastDay = 1; autosaveMark = -1; roomTimer = 999;
const doorX = 11 + 5;
templates.forEach((t, i) => {
const s = simFromTemplate(t);
const spot = G.world.findFreeSpotNear(doorX, 19 + (i % 3), 6) || [doorX + i, 20];
s.x = spot[0]; s.y = spot[1];
G.sims.push(s);
});
enterLiveMode(true);
}
function enterLiveMode(isNew) {
G.mode = 'live';
document.getElementById('titleScreen').classList.add('hidden');
document.getElementById('casScreen').classList.add('hidden');
showHud();
document.getElementById('btnContinue').classList.remove('hidden');
centerCameraOnHouse();
rebuildPortraits();
selectSim(firstFamilySim());
G.world.recomputeRoom();
for (const s of G.sims) if (!s.isVisitor) WantSys.roll(s);
if (isNew) {
setTimeout(() => toast(`🏡 Welcome home! Click the ground to walk, objects to interact. Press ❓ anytime for help.`), 400);
setTimeout(() => toast(`💡 Tip: Buy a computer → "Find a Job" to start earning.`), 6000);
}
}
function centerCameraOnHouse() {
const [sx, sy] = isoToScreen(16, 14);
G.cam.x = window.innerWidth / 2 - sx * G.cam.zoom;
G.cam.y = window.innerHeight / 2 - sy * G.cam.zoom;
}
const SAVE_KEY = 'tso2d_save_v2';
function saveGame(auto) {
if (!G.world) return;
const famIds = new Set(G.sims.filter(s => !s.isVisitor).map(s => s.id));
const data = {
v: 2, funds: G.funds, absMin: G.time.absMin, freeWill: G.freeWill,
aspirationPoints: G.aspirationPoints,
nextBillDay: G.nextBillDay, billsPaid: G.billsPaid,
world: G.world.serialize(),
sims: G.sims.filter(s => famIds.has(s.id)).map(s => s.serialize()),
neighborhood: G.neighborhood,
graves: G.graves || [],
};
try {
localStorage.setItem(SAVE_KEY, JSON.stringify(data));
toast(auto ? '💾 Autosaved.' : '💾 Game saved!');
} catch (e) { toast('⚠️ Save failed: ' + e.message, 'bad'); }
}
function loadGame() {
const raw = localStorage.getItem(SAVE_KEY);
if (!raw) return false;
try {
const d = JSON.parse(raw);
G.world = World.deserialize(d.world);
G.sims = [];
for (const sd of d.sims) {
const s = new Sim(sd);
s.atHome = true;
s.action = null; s.queue = []; s.path = [];
G.sims.push(s);
}
G.funds = d.funds ?? START_FUNDS;
G.time.absMin = d.absMin ?? 420;
G.freeWill = d.freeWill !== false;
G.aspirationPoints = d.aspirationPoints || 0;
G.nextBillDay = d.nextBillDay || 4;
G.billsPaid = d.billsPaid !== false;
G.mailBillsDue = false; G.pendingPizza = 0;
G.dishPiles = [];
G.fires = [];
G.ghosts = []; G.party = null; G.pendingGroceries = 0; G.pendingChance = null;
G.graves = d.graves || [];
G.neighborhood = d.neighborhood || genNeighborhood();
scheduleVisitors();
G.weather = { type: 'sunny', flash: 0, boltIn: 0 };
document.getElementById('weatherIcon').textContent = '☀️';
document.getElementById('btnFreeWill').classList.toggle('active', G.freeWill);
lastDay = G.time.day; roomTimer = 999;
enterLiveMode(false);
for (const s of G.sims) if (!s.isVisitor && (!s.wants || !s.wants.length)) WantSys.roll(s);
toast('📂 Welcome back to the neighborhood!');
return true;
} catch (e) {
console.error('load failed', e);
toast('⚠️ Could not load that save.', 'bad');
return false;
}
}
/* title buttons */
document.getElementById('btnNewGame').onclick = () => openCas(true);
document.getElementById('btnContinue').onclick = () => {
if (!loadGame()) toast('No save found — start a New Family!', 'bad');
};
if (localStorage.getItem(SAVE_KEY))
document.getElementById('btnContinue').classList.remove('hidden');
/* portrait refresh throttle helpers */
let portAcc = 0, panelAcc = 0;
function refreshPortraitsThrottled() {
refreshPortraits();
panelAcc += 1;
if (panelAcc % 3 === 0) updateSimPanel();
}
+997
View File
@@ -0,0 +1,997 @@
/* ============================================================
* render.js — isometric renderer: terrain, walls, furniture
* painters (procedural pixel art), sims, lighting, ghosts
* ============================================================ */
'use strict';
const R = {
canvas: null, ctx: null,
W: 0, H: 0,
time: 0, // real seconds accumulated for animations
};
function initRender(canvas) {
R.canvas = canvas;
R.ctx = canvas.getContext('2d');
resizeRender();
window.addEventListener('resize', resizeRender);
}
function resizeRender() {
if (!R.canvas) return;
R.W = R.canvas.width = window.innerWidth;
R.H = R.canvas.height = window.innerHeight;
}
/* ---------------- geometry helpers ---------------- */
function tileCornerPx(x, y) {
const [sx, sy] = isoToScreen(x, y);
return [sx * G.cam.zoom + G.cam.x, sy * G.cam.zoom + G.cam.y];
}
function diamondPath(ctx, x, y, z = 0) {
const p = [
tileCornerPx(x, y), // N corner (top)
tileCornerPx(x + 1, y), // E corner (right)
tileCornerPx(x + 1, y + 1), // S (bottom)
tileCornerPx(x, y + 1), // W (left)
];
ctx.beginPath();
ctx.moveTo(p[0][0], p[0][1] - z);
ctx.lineTo(p[1][0], p[1][1] - z);
ctx.lineTo(p[2][0], p[2][1] - z);
ctx.lineTo(p[3][0], p[3][1] - z);
ctx.closePath();
}
/** axis-aligned iso box anchored at cell (x,y) covering fw×fh tiles, height hp px */
function isoBoxPath(ctx, x, y, fw, fh, z0, z1) {
// corners in tile units
const pts = [[x, y], [x + fw, y], [x + fw, y + fh], [x, y + fh]];
const scr = pts.map(([px, py]) => {
const [sx, sy] = isoToScreen(px, py);
return [sx * G.cam.zoom + G.cam.x, sy * G.cam.zoom + G.cam.y];
});
// top face
ctx.beginPath();
ctx.moveTo(scr[0][0], scr[0][1] - z1);
ctx.lineTo(scr[1][0], scr[1][1] - z1);
ctx.lineTo(scr[2][0], scr[2][1] - z1);
ctx.lineTo(scr[3][0], scr[3][1] - z1);
ctx.closePath();
}
function shade(hex, f) {
const n = parseInt(hex.slice(1), 16);
let r = (n >> 16) & 255, g = (n >> 8) & 255, b = n & 255;
r = clamp(Math.round(r * f), 0, 255); g = clamp(Math.round(g * f), 0, 255); b = clamp(Math.round(b * f), 0, 255);
return '#' + ((r << 16) | (g << 8) | b).toString(16).padStart(6, '0');
}
/** draw a 3D box (top/left/right faces) in screen px around center point */
function box3d(ctx, cx, cy, wPx, dPx, hPx, colTop, colL, colR) {
// wPx along +x screen dir (right-down), dPx along +y (left-down)
const hx = wPx / 2, hy = dPx / 2;
const ux = (TW / 2) / TW, uy = TH / TW; // normalized iso dirs scaled later
const X = (dx, dy) => [cx + (dx - dy) * 0.5, cy + (dx + dy) * 0.25];
const A = X(-hx, -hy), B = X(hx, -hy), C = X(hx, hy), D = X(-hx, hy);
// top
ctx.fillStyle = colTop;
ctx.beginPath();
ctx.moveTo(A[0], A[1] - hPx); ctx.lineTo(B[0], B[1] - hPx);
ctx.lineTo(C[0], C[1] - hPx); ctx.lineTo(D[0], D[1] - hPx);
ctx.closePath(); ctx.fill();
// right face (B-C)
ctx.fillStyle = colR;
ctx.beginPath();
ctx.moveTo(B[0], B[1] - hPx); ctx.lineTo(C[0], C[1] - hPx);
ctx.lineTo(C[0], C[1]); ctx.lineTo(B[0], B[1]);
ctx.closePath(); ctx.fill();
// left face (D-C)
ctx.fillStyle = colL;
ctx.beginPath();
ctx.moveTo(D[0], D[1] - hPx); ctx.lineTo(C[0], C[1] - hPx);
ctx.lineTo(C[0], C[1]); ctx.lineTo(D[0], D[1]);
ctx.closePath(); ctx.fill();
ctx.strokeStyle = 'rgba(20,16,28,.35)';
ctx.lineWidth = 1;
ctx.stroke();
}
/* ============================================================
* MAIN DRAW
* ============================================================ */
function draw() {
const ctx = R.ctx;
R.time += G.dtReal;
ctx.clearRect(0, 0, R.W, R.H);
/* sky backdrop */
const skyGrad = ctx.createLinearGradient(0, 0, 0, R.H);
const nightness = getNightness();
skyGrad.addColorStop(0, mixColor('#7ec8f0', '#0a1030', nightness));
skyGrad.addColorStop(1, mixColor('#bfe3c0', '#141c44', nightness));
ctx.fillStyle = skyGrad;
ctx.fillRect(0, 0, R.W, R.H);
if (!G.world) return;
/* lot backdrop */
if (!G.world) return;
drawGround(ctx);
drawBuildGrid(ctx);
/* depth-sorted entities */
const items = [];
collectWalls(items);
collectObjects(items);
collectFires(items);
collectGhosts(items);
collectSims(items);
collectFx(items);
items.sort((a, b) => a.depth - b.depth || a.sub - b.sub);
for (const it of items) it.fn(ctx);
drawGhost(ctx);
drawLighting(ctx);
drawWeather(ctx);
}
/* ---------------- weather overlay ---------------- */
let raindrops = null;
function drawWeather(ctx) {
if (!G.weather) return;
if (G.weather.flash > 0) {
ctx.fillStyle = `rgba(255,255,240,${clamp(G.weather.flash, 0, .8) * .55})`;
ctx.fillRect(0, 0, R.W, R.H);
}
if (G.weather.type !== 'rain') return;
// persistent light dim while raining
ctx.fillStyle = 'rgba(25,35,60,.14)';
ctx.fillRect(0, 0, R.W, R.H);
// raindrops
const want = Math.floor(R.W / 6);
if (!raindrops || raindrops.length !== want) {
raindrops = Array.from({ length: want }, () => ({
x: Math.random() * R.W, y: Math.random() * R.H,
v: 700 + Math.random() * 500, l: 10 + Math.random() * 12,
}));
}
const dt = G.dtReal;
ctx.strokeStyle = 'rgba(180,205,235,.5)';
ctx.lineWidth = 1;
ctx.beginPath();
for (const d of raindrops) {
d.y += d.v * dt; d.x -= d.v * dt * .18;
if (d.y > R.H) { d.y = -20; d.x = Math.random() * (R.W + 200); }
if (d.x < -30) d.x += R.W + 60;
ctx.moveTo(d.x, d.y);
ctx.lineTo(d.x - d.l * .18, d.y - d.l);
}
ctx.stroke();
}
/* ---------------- ground & floors ---------------- */
function drawGround(ctx) {
const W = G.world;
for (let y = 0; y < W.h; y++) {
for (let x = 0; x < W.w; x++) {
const fid = W.floor[y * W.w + x] ?? 0;
const f = FLOORS[fid] || FLOORS[0];
diamondPath(ctx, x, y);
const check = (x + y) % 2 === 0;
ctx.fillStyle = check ? f.c1 : f.c2;
ctx.fill();
// subtle inner edge
ctx.strokeStyle = 'rgba(0,0,0,.06)';
ctx.lineWidth = 1;
ctx.stroke();
}
}
// mailbox
const mb = W.mailbox;
const [mx, my] = tileCornerPx(mb.x + .5, mb.y + .9);
ctx.font = `${18 * G.cam.zoom}px sans-serif`;
ctx.textAlign = 'center';
ctx.fillText('📮', mx, my - 14 * G.cam.zoom);
if (G.mailBillsDue && !G.billsPaid) {
ctx.font = `${13 * G.cam.zoom}px sans-serif`;
ctx.fillText('✉️', mx + 12 * G.cam.zoom, my - 22 * G.cam.zoom);
}
// dirt puddles & scorch marks
for (const p of G.world.dirtPuddle || []) {
diamondPath(ctx, p.x, p.y);
if (p.kind === 'scorch') {
const a = Math.min(.85, p.t / 800);
ctx.fillStyle = `rgba(28,24,22,${a})`;
} else if (p.kind === 'puke') {
ctx.fillStyle = `rgba(140,170,60,${Math.min(.8, p.t / 400)})`;
} else {
ctx.fillStyle = `rgba(110,80,40,${Math.min(.75, p.t / 300)})`;
}
ctx.fill();
}
}
function drawBuildGrid(ctx) {
if (G.mode !== 'build' && G.mode !== 'buy') return;
ctx.strokeStyle = 'rgba(255,255,255,.13)';
ctx.lineWidth = 1;
for (let y = 0; y <= G.world.h; y++) {
const a = tileCornerPx(0, y), b = tileCornerPx(G.world.w, y);
ctx.beginPath(); ctx.moveTo(a[0], a[1]); ctx.lineTo(b[0], b[1]); ctx.stroke();
}
for (let x = 0; x <= G.world.w; x++) {
const a = tileCornerPx(x, 0), b = tileCornerPx(x, G.world.h);
ctx.beginPath(); ctx.moveTo(a[0], a[1]); ctx.lineTo(b[0], b[1]); ctx.stroke();
}
}
/* ---------------- walls ---------------- */
function collectWalls(items) {
const W = G.world;
for (const [k, wall] of W.walls) {
const [x, y, e] = k.split(',');
const xi = +x, yi = +y;
const depth = xi + yi + (e === 'n' ? 0.02 : 0.03);
items.push({ depth, sub: 0, fn: (ctx) => drawWall(ctx, xi, yi, e, wall) });
}
}
function drawWall(ctx, x, y, e, wall) {
const z = WALL_H * G.cam.zoom;
const A = e === 'n' ? tileCornerPx(x, y) : tileCornerPx(x, y);
const B = e === 'n' ? tileCornerPx(x + 1, y) : tileCornerPx(x, y + 1);
const base = shade(wall.color || '#efe6d4', e === 'n' ? 1.0 : 0.82);
const lit = shade(wall.color || '#efe6d4', e === 'n' ? 1.12 : 0.92);
if (wall.kind === 'door' || wall.kind === 'window') {
// two posts + opening
const t1 = 0.14, t2 = 0.86;
seg(A, B, 0, t1); seg(A, B, t2, 1);
if (wall.kind === 'door') {
seg(A, B, t1, t2, true); // lintel across top
// door slab ajar
const dx = lerp(A[0], B[0], .5), dy = lerp(A[1], B[1], .5);
ctx.strokeStyle = '#6e4a26';
ctx.lineWidth = Math.max(2, 3 * G.cam.zoom);
ctx.beginPath();
ctx.moveTo(dx, dy - z);
ctx.lineTo(dx + 6 * G.cam.zoom, dy - z + 10 * G.cam.zoom);
ctx.stroke();
} else {
// window: bottom sill band + glass + top band
bandSeg(A, B, 0.05, z * .32);
bandSeg(A, B, 0.78, z * .22);
// glass
ctx.fillStyle = 'rgba(160,210,240,.45)';
const g0 = ptAt(A, B, t1), g1 = ptAt(A, B, t2);
ctx.beginPath();
ctx.moveTo(g0[0], g0[1] - z * .36); ctx.lineTo(g1[0], g1[1] - z * .36);
ctx.lineTo(g1[0], g1[1] - z * .74); ctx.lineTo(g0[0], g0[1] - z * .74);
ctx.closePath(); ctx.fill();
}
} else {
// solid wall
ctx.fillStyle = base;
ctx.beginPath();
ctx.moveTo(A[0], A[1]); ctx.lineTo(B[0], B[1]);
ctx.lineTo(B[0], B[1] - z); ctx.lineTo(A[0], A[1] - z);
ctx.closePath(); ctx.fill();
// lit inner face hint
ctx.fillStyle = 'rgba(255,255,255,.08)';
ctx.beginPath();
ctx.moveTo(A[0], A[1]); ctx.lineTo(B[0], B[1]);
ctx.lineTo(B[0], B[1] - z * .25); ctx.lineTo(A[0], A[1] - z * .25);
ctx.closePath(); ctx.fill();
ctx.strokeStyle = 'rgba(30,22,15,.4)';
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(A[0], A[1]); ctx.lineTo(A[0], A[1] - z); ctx.lineTo(B[0], B[1] - z); ctx.lineTo(B[0], B[1]);
ctx.stroke();
}
function seg(P, Q, t0, t1, topOnly = false) {
const p0 = ptAt(P, Q, t0), p1 = ptAt(P, Q, t1);
ctx.fillStyle = base;
ctx.beginPath();
if (!topOnly) {
ctx.moveTo(p0[0], p0[1]); ctx.lineTo(p1[0], p1[1]);
ctx.lineTo(p1[0], p1[1] - z); ctx.lineTo(p0[0], p0[1] - z);
} else {
ctx.moveTo(p0[0], p0[1] - z); ctx.lineTo(p1[0], p1[1] - z);
ctx.lineTo(p1[0], p1[1] - z * .8); ctx.lineTo(p0[0], p0[1] - z * .8);
}
ctx.closePath(); ctx.fill();
ctx.strokeStyle = 'rgba(30,22,15,.4)';
ctx.stroke();
}
function bandSeg(P, Q, hFrac, hh) {
const yy = P[1] - hh;
const y1 = Q[1] - hh;
ctx.fillStyle = lit;
ctx.fillRect(Math.min(P[0], Q[0]), Math.min(yy, y1) , Math.abs(Q[0] - P[0]) + 2, Math.abs(hh) );
}
function ptAt(P, Q, t) { return [lerp(P[0], Q[0], t), lerp(P[1], Q[1], t)]; }
}
/* ============================================================
* OBJECT PAINTERS — procedural pixel art, centered at anchor
* ============================================================ */
const PAINTERS = {
chair(ctx, o, t) { box3d(ctx, 0, 0, 30, 30, 16, '#a8743f', '#8a5c30', '#7a5028');
ctx.fillStyle = '#7a4f26'; ctx.fillRect(-14, -34, 28, 20); },
stool(ctx, o, t) { box3d(ctx, 0, 0, 24, 24, 12, '#b5824a', '#96683a', '#865a30'); },
sofa(ctx, o, t) {
const len = o.h * TH * .95;
box3d(ctx, 0, -len * .18, 52, len, 14, '#4f7fd0', '#3d63a8', '#35569a');
ctx.fillStyle = '#3d63a8'; ctx.fillRect(-24, -len * .95 - 16, 48, 22);
ctx.fillStyle = '#6fa0e8'; ctx.fillRect(-20, -len * .72, 40, 8);
ctx.fillStyle = '#6fa0e8'; ctx.fillRect(-20, -len * .38, 40, 8);
},
loveseat(ctx, o, t) {
const len = o.h * TH * .95;
box3d(ctx, 0, -len * .2, 46, len, 13, '#c05a8a', '#9c4770', '#8a3e62');
ctx.fillStyle = '#9c4770'; ctx.fillRect(-21, -len * .95 - 14, 42, 20);
},
table(ctx, o, t) { box3d(ctx, 0, -4, 54, 54, 20, '#c99b62', '#a87c48', '#98703f'); },
coffeeTable(ctx, o, t) { box3d(ctx, 0, -2, o.w * TW * .8, 34, 12, '#b5824a', '#96683a', '#7a5028'); },
desk(ctx, o, t) { box3d(ctx, 0, -4, o.w * TW * .85, 36, 22, '#a87c48', '#8a6438', '#7a562e'); },
bedSingle(ctx, o, t) { drawBed(ctx, o, '#d9d9e2', '#7f9fd9'); },
bedDouble(ctx, o, t) { drawBed(ctx, o, '#e8e2ef', '#c98aa8'); },
toilet(ctx, o, t) {
box3d(ctx, 0, 4, 30, 34, 14, '#f2f2f6', '#d8d8de', '#c8c8d0');
ctx.fillStyle = '#ffffff'; ctx.beginPath(); ctx.ellipse(0, -2, 13, 9, 0, 0, 7); ctx.fill();
ctx.strokeStyle = '#b8b8c0'; ctx.stroke();
ctx.fillStyle = '#e8e8ee'; ctx.fillRect(-14, -30, 28, 18);
if (o.dirty > .3) { ctx.fillStyle = `rgba(140,110,60,${o.dirty * .5})`; ctx.fillRect(-12, -28, 24, 14); }
},
shower(ctx, o, t) {
box3d(ctx, 0, 6, 44, 44, 6, '#bcd8e8', '#9dbdd2', '#8fb0c6');
ctx.strokeStyle = '#aac8da'; ctx.lineWidth = 3;
ctx.strokeRect(-20, -52, 40, 52);
ctx.fillStyle = 'rgba(190,225,245,.35)'; ctx.fillRect(-20, -52, 40, 52);
ctx.fillStyle = '#8899aa'; ctx.fillRect(-6, -58, 12, 6);
if (o.usedBy) { ctx.fillStyle = 'rgba(200,235,255,.8)';
for (let i = 0; i < 5; i++) ctx.fillRect(-14 + i * 7, -46 + ((t * 60 + i * 13) % 40), 2, 6); }
},
bathtub(ctx, o, t) {
box3d(ctx, 0, -o.h * 6, 44, o.h * TH * .8, 18, '#eef4f8', '#ccd8e2', '#bcc8d2');
ctx.fillStyle = '#cfe6f2'; ctx.beginPath(); ctx.ellipse(0, -8, 15, 10, 0, 0, 7); ctx.fill();
},
sink(ctx, o, t) {
box3d(ctx, 0, 2, 36, 26, 16, '#f2f2f6', '#d8d8de', '#c8c8d0');
ctx.fillStyle = '#8899aa'; ctx.fillRect(-2, -26, 4, 10);
},
mirror(ctx, o, t) {
ctx.fillStyle = '#8a6438'; ctx.fillRect(-16, -58, 32, 40);
ctx.fillStyle = '#bfe3f2'; ctx.fillRect(-13, -55, 26, 34);
ctx.fillStyle = 'rgba(255,255,255,.5)'; ctx.fillRect(-13, -55, 8, 34);
},
fridge(ctx, o, t) {
box3d(ctx, 0, 0, 40, 36, 52, '#e8ecf2', '#ccd2dc', '#bcc2cc');
ctx.fillStyle = '#9aa4b0'; ctx.fillRect(12, -40, 3, 16); ctx.fillRect(12, -18, 3, 10);
ctx.fillStyle = '#ffd23e'; ctx.fillRect(-14, -34, 8, 6);
},
stove(ctx, o, t) {
box3d(ctx, 0, 0, 40, 36, 26, '#d8dce4', '#b8bec8', '#a8aeb8');
ctx.fillStyle = '#333844';
ctx.beginPath(); ctx.arc(-8, -22, 5, 0, 7); ctx.arc(8, -22, 5, 0, 7); ctx.fill();
if (o.usedBy) { ctx.fillStyle = `rgba(255,${100 + Math.sin(t * 9) * 60},40,.9)`;
ctx.beginPath(); ctx.arc(-8, -26, 4 + Math.sin(t * 13) * 2, 0, 7); ctx.fill();
ctx.beginPath(); ctx.arc(8, -26, 4 + Math.cos(t * 11) * 2, 0, 7); ctx.fill(); }
},
counter(ctx, o, t) {
box3d(ctx, 0, 0, 52, 40, 22, '#c9a26a', '#a87c48', '#98703f');
ctx.fillStyle = '#e8e2d4'; ctx.fillRect(-24, -24, 48, 6);
},
trash(ctx, o, t) {
box3d(ctx, 0, 2, 26, 26, 18, '#7a8494', '#646e7e', '#586270');
if (o.dirty > .5) { ctx.font = '12px sans-serif'; ctx.textAlign = 'center';
ctx.fillText('🪰', 8, -22 + Math.sin(t * 5) * 3); }
},
tv(ctx, o, t) {
box3d(ctx, 0, 4, o.w * TW * .8, 22, 10, '#4a4038', '#3a322c', '#332c27');
ctx.fillStyle = '#22201e'; ctx.fillRect(-o.w * TW * .35, -52, o.w * TW * .7, 40);
const on = !!o.usedBy;
if (on) {
const flick = [' #4a90e2', '#3ac05a', '#e2c04a'][Math.floor(t * 3) % 3];
ctx.fillStyle = flick.trim();
ctx.fillRect(-o.w * TW * .32, -49, o.w * TW * .64, 34);
ctx.fillStyle = 'rgba(255,255,255,.25)';
for (let i = 0; i < 4; i++)
ctx.fillRect(-o.w * TW * .3 + Math.random() * o.w * TW * .5, -47 + Math.random() * 28, 6, 3);
} else { ctx.fillStyle = '#101418'; ctx.fillRect(-o.w * TW * .32, -49, o.w * TW * .64, 34); }
},
stereo(ctx, o, t) {
box3d(ctx, 0, 0, 34, 26, 30, '#2e3038', '#23252c', '#1e2026');
ctx.fillStyle = o.usedBy ? '#43e05a' : '#445058'; ctx.beginPath(); ctx.arc(-7, -15, 6, 0, 7); ctx.fill();
ctx.fillStyle = o.usedBy ? '#43e05a' : '#445058'; ctx.beginPath(); ctx.arc(7, -15, 6, 0, 7); ctx.fill();
if (o.usedBy) { ctx.font = '11px sans-serif'; ctx.textAlign = 'center';
ctx.fillText('🎵', -14, -34 + Math.abs(Math.sin(t * 4)) * -8);
ctx.fillText('🎶', 14, -34 + Math.abs(Math.cos(t * 4)) * -8); }
},
computer(ctx, o, t) {
box3d(ctx, 0, 6, 40, 30, 16, '#d8d4c8', '#b8b4a8', '#a8a498');
ctx.fillStyle = '#2a2e38'; ctx.fillRect(-12, -44, 24, 20);
ctx.fillStyle = o.usedBy ? '#4ab8e2' : '#14181e'; ctx.fillRect(-10, -42, 20, 16);
if (o.usedBy) { ctx.fillStyle = 'rgba(255,255,255,.5)';
for (let i = 0; i < 3; i++) ctx.fillRect(-9 + i * 7, -41 + ((t * 30 + i * 5) % 13), 4, 2); }
ctx.fillStyle = '#3a3e48'; ctx.fillRect(-14, -24, 28, 4);
},
phone(ctx, o, t) {
box3d(ctx, 0, 4, 22, 20, 12, '#d94a4a', '#b83a3a', '#a83232');
ctx.fillStyle = '#fff'; ctx.fillRect(-6, -18, 12, 8);
},
bookshelf(ctx, o, t) {
box3d(ctx, 0, 0, 44, 26, 48, '#8a6438', '#704e28', '#62441f');
const cols = ['#c94a4a', '#4a72c9', '#43b05a', '#e8a33d', '#8a52c9'];
for (let row = 0; row < 3; row++)
for (let i = 0; i < 5; i++) {
ctx.fillStyle = cols[(i + row * 2) % cols.length];
ctx.fillRect(-17 + i * 7, -42 + row * 14, 5, 11);
}
},
easel(ctx, o, t) {
ctx.strokeStyle = '#8a6438'; ctx.lineWidth = 4;
ctx.beginPath(); ctx.moveTo(-14, 8); ctx.lineTo(0, -52); ctx.lineTo(14, 8); ctx.stroke();
ctx.fillStyle = '#f2ede2'; ctx.fillRect(-18, -48, 36, 28);
if (o.usedBy) {
ctx.fillStyle = ['#4a90e2','#e25a4a','#43b05a'][Math.floor(t) % 3];
ctx.beginPath(); ctx.arc(Math.sin(t * 3) * 10, -36 + Math.cos(t * 2) * 6, 4, 0, 7); ctx.fill();
}
},
treadmill(ctx, o, t) {
box3d(ctx, 0, 4, 34, 52, 8, '#3a4048', '#2e343a', '#282e34');
ctx.strokeStyle = '#5a6470'; ctx.lineWidth = 4;
ctx.beginPath(); ctx.moveTo(-12, 0); ctx.lineTo(-12, -40); ctx.lineTo(12, -40); ctx.stroke();
if (o.usedBy) { ctx.fillStyle = 'rgba(120,220,255,.6)';
ctx.fillRect(-14 + Math.sin(t * 8) * 3, -34, 4, 4); }
},
piano(ctx, o, t) {
box3d(ctx, 0, 0, o.w * TW * .85, 40, 26, '#2a2228', '#201a1f', '#18131a');
ctx.fillStyle = '#f2f2f2';
for (let i = 0; i < 10; i++) ctx.fillRect(-o.w * TW * .36 + i * 8, -14, 6, 12);
ctx.fillStyle = '#111';
for (let i = 0; i < 7; i++) ctx.fillRect(-o.w * TW * .34 + i * 11 + 4, -14, 4, 8);
if (o.usedBy) { ctx.font = '12px sans-serif'; ctx.textAlign = 'center';
ctx.fillText('🎵', Math.sin(t * 3) * 16, -40 - Math.abs(Math.sin(t * 5)) * 8); }
},
chessboard(ctx, o, t) {
box3d(ctx, 0, -2, 46, 46, 18, '#a87c48', '#8a6438', '#7a562e');
ctx.fillStyle = '#e8dcc8'; ctx.fillRect(-16, -24, 32, 16);
ctx.fillStyle = '#5a4426';
for (let r = 0; r < 2; r++) for (let c = 0; c < 4; c++) ctx.fillRect(-16 + c * 8 + (r ? 4 : 0), -23 + r * 7, 4, 6);
ctx.font = '10px sans-serif'; ctx.textAlign = 'center';
ctx.fillText('♟', -8, -28); ctx.fillText('♞', 8, -28);
},
crib(ctx, o, t) {
box3d(ctx, 0, 2, 40, 40, 16, '#e8dcc8', '#c9bda6', '#b8ac96');
ctx.strokeStyle = '#8a6438'; ctx.lineWidth = 3;
for (let i = 0; i < 5; i++) {
const xx = -16 + i * 8;
ctx.beginPath(); ctx.moveTo(xx, -34); ctx.lineTo(xx, -14); ctx.stroke();
}
ctx.fillStyle = '#c9a24a';
ctx.fillRect(-20, -38, 40, 5);
// sleeping baby bump when occupied
if (o.usedBy && o.usedBy.ageStage === 'baby') {
ctx.fillStyle = SKINS[o.usedBy.skin % SKINS.length];
ctx.beginPath(); ctx.arc(0, -22, 7, 0, 7); ctx.fill();
ctx.font = '10px sans-serif'; ctx.textAlign = 'center';
if (chance(.02)) ctx.fillText('💤', 12, -30);
}
},
easel(ctx, o, t) {
// tripod legs
ctx.strokeStyle = '#8a6438'; ctx.lineWidth = 4;
ctx.beginPath();
ctx.moveTo(-14, 2); ctx.lineTo(0, -40);
ctx.moveTo(14, 2); ctx.lineTo(0, -40);
ctx.moveTo(0, 2); ctx.lineTo(0, -34);
ctx.stroke();
// canvas
ctx.fillStyle = '#f7f2e6'; ctx.fillRect(-13, -36, 26, 20);
ctx.strokeStyle = '#c9bda6'; ctx.lineWidth = 2; ctx.strokeRect(-13, -36, 26, 20);
// a dab of art
ctx.fillStyle = ['#e84a1a', '#3e6fa8', '#4a8a4a', '#ffd23e'][Math.floor(t / 2) % 4];
ctx.beginPath(); ctx.arc(-5 + (Math.sin(t) * 5), -27 + Math.cos(t * .7) * 4, 3.4, 0, 7); ctx.fill();
ctx.beginPath(); ctx.arc(6, -30, 2.6, 0, 7); ctx.fill();
},
toybox(ctx, o, t) { box3d(ctx, 0, 2, 36, 28, 20, '#d94a4a', '#b83a3a', '#a83232');
ctx.fillStyle = '#ffd23e'; ctx.fillRect(-14, -24, 28, 5);
ctx.font = '11px sans-serif'; ctx.textAlign = 'center';
ctx.fillText('🪀', -9, -26); ctx.fillText('🧸', 9, -27);
},
gravestone(ctx, o, t) { box3d(ctx, 0, 2, 26, 12, 24, '#9aa2ab', '#7f878f', '#70787f');
ctx.fillStyle = '#6a727a'; ctx.fillRect(-6, -20, 12, 3);
ctx.font = '11px sans-serif'; ctx.textAlign = 'center';
ctx.fillText('RIP', 0, -24);
if (chance(.02)) { ctx.font = '10px sans-serif'; ctx.fillText('🕯️', 10, -18); }
},
plant(ctx, o, t) {
box3d(ctx, 0, 4, 24, 24, 14, '#b5651e', '#964f18', '#864414');
ctx.fillStyle = '#3d8a3d';
ctx.beginPath(); ctx.ellipse(0, -22, 14, 16, 0, 0, 7); ctx.fill();
ctx.fillStyle = '#4da34d';
ctx.beginPath(); ctx.ellipse(-5, -26, 8, 10, -.4, 0, 7); ctx.fill();
ctx.beginPath(); ctx.ellipse(6, -24, 7, 9, .4, 0, 7); ctx.fill();
},
lamp(ctx, o, t) {
ctx.fillStyle = '#5a5048'; ctx.fillRect(-2, -34, 4, 34);
ctx.fillStyle = o.lightOn ? '#ffe9a8' : '#d8cfb8';
ctx.beginPath(); ctx.moveTo(-12, -34); ctx.lineTo(12, -34); ctx.lineTo(8, -48); ctx.lineTo(-8, -48); ctx.closePath(); ctx.fill();
},
painting(ctx, o, t) {
ctx.fillStyle = '#8a6438'; ctx.fillRect(-16, -64, 32, 26);
ctx.fillStyle = ['#7fb2e2','#e2c07f','#9be27f'][o.id % 3];
ctx.fillRect(-13, -61, 26, 20);
ctx.fillStyle = 'rgba(255,255,255,.4)';
ctx.beginPath(); ctx.arc(-4, -53, 5, 0, 7); ctx.fill();
},
fountain(ctx, o, t) {
box3d(ctx, 0, 0, o.w * TW * .8, o.h * TH * 1.4, 14, '#c8ccd4', '#a8acb6', '#989ca6');
ctx.fillStyle = '#6fc0e8'; ctx.beginPath(); ctx.ellipse(0, -10, o.w * TW * .3, o.h * TH * .5, 0, 0, 7); ctx.fill();
ctx.fillStyle = 'rgba(255,255,255,.6)';
const jh = 14 + Math.sin(t * 4) * 5;
ctx.fillRect(-2, -14 - jh, 4, jh);
ctx.font = `${12 * G.cam.zoom}px sans-serif`; ctx.textAlign = 'center';
ctx.fillText('💧', 6, -20 - jh);
},
};
function drawBed(ctx, o, sheetCol, blankCol) {
const len = o.h * TH * 1.05;
box3d(ctx, 0, -len * .12, o.w * TW * .78, len, 12, '#8a6438', '#704e28', '#62441f');
// mattress + pillow + blanket
ctx.fillStyle = sheetCol;
ctx.fillRect(-o.w * TW * .34, -len * .95, o.w * TW * .68, len * .8);
ctx.fillStyle = '#ffffff';
ctx.fillRect(-o.w * TW * .3, -len * .93, o.w * TW * .6, len * .18);
ctx.fillStyle = blankCol;
ctx.fillRect(-o.w * TW * .34, -len * .62, o.w * TW * .68, len * .45);
ctx.strokeStyle = 'rgba(30,20,10,.3)';
ctx.strokeRect(-o.w * TW * .34, -len * .95, o.w * TW * .68, len * .8);
}
function collectObjects(items) {
for (const o of G.world.objects) {
const def = OBJECTS[o.defId];
// draw at the deepest footprint cell so multi-tile objects sort correctly
const depth = (o.x + o.w - 1) + (o.y + o.h - 1) - 0.25;
items.push({
depth,
sub: 1,
fn: (ctx) => drawObject(ctx, o, def),
});
}
// dirty dish piles
for (const p of (G.dishPiles || [])) {
if (p.n <= 0) continue;
items.push({ depth: p.x + p.y + 0.3, sub: 3, fn: (ctx) => {
const [ax, ay] = isoToScreen(p.x, p.y);
const px = ax * G.cam.zoom + G.cam.x, py = ay * G.cam.zoom + G.cam.y;
ctx.save();
ctx.translate(px, py);
ctx.scale(G.cam.zoom, G.cam.zoom);
const stacks = Math.min(4, Math.ceil(p.n));
for (let i = 0; i < stacks; i++) {
ctx.font = '13px sans-serif';
ctx.textAlign = 'center';
ctx.fillText('🍽️', (i % 2 ? 8 : -7), -4 - Math.floor(i / 2) * 9);
}
if (p.n >= 3) { ctx.font = '10px sans-serif'; ctx.fillText('🪰', 12, -18 + Math.sin(R.time * 5) * 3); }
ctx.restore();
}});
}
}
function drawObject(ctx, o, def) {
const vcx = o.x + o.w / 2, vcy = o.y + o.h / 2;
const [px, py] = tileCornerPx(vcx - .5 + .5 * 0, vcy - .5);
// center of footprint on screen:
const [ax, ay] = isoToScreen(vcx, vcy);
const cx = ax * G.cam.zoom + G.cam.x;
const cy = ay * G.cam.zoom + G.cam.y;
ctx.save();
ctx.translate(cx, cy);
ctx.scale(G.cam.zoom, G.cam.zoom);
// soft ground shadow
ctx.fillStyle = 'rgba(20,16,28,.18)';
ctx.beginPath();
ctx.ellipse(0, o.h * TH * .18, o.w * TW * .34, o.h * TH * .3, 0, 0, 7);
ctx.fill();
const painter = PAINTERS[def.shape];
if (painter) painter(ctx, o, R.time);
else { ctx.fillStyle = '#caa'; ctx.fillRect(-14, -30, 28, 30); }
if (o.broken) {
// sparks & smoke over broken objects
ctx.font = '13px sans-serif'; ctx.textAlign = 'center';
const jx = Math.sin(R.time * 23 + o.id) * 4, jy = -Math.abs(Math.cos(R.time * 17)) * 6;
ctx.fillText('⚡', jx, -46 - def.h * 8 + jy);
ctx.fillStyle = 'rgba(60,60,66,.5)';
ctx.beginPath();
ctx.arc(0, -40 - def.h * 8, 7 + Math.sin(R.time * 3) * 2, 0, 7);
ctx.fill();
}
ctx.restore();
// usage sparkle: show who uses it
if (o.usedBy && def.cat === 'electronics') { /* anim handled in painters */ }
}
/* ============================================================
* SIM SPRITES
* ============================================================ */
/**
* Draw a sim character at screen point (px,py)=feet position.
* opts: {zoom, facing, anim, animT, skin, hairStyle, hairColorIdx, shirt, pants, scale}
*/
function drawSimSprite(ctx, px, py, s, opts = {}) {
const zoom = opts.zoom ?? G.cam.zoom;
const stageK = s.ageStage === 'baby' ? .55 : s.ageStage === 'child' ? .78 : 1;
const sc = (opts.scale ?? 1) * zoom * stageK;
const t = opts.animT ?? 0;
const anim = s.ageStage === 'baby' ? 'idle' : (opts.anim ?? 'idle');
const facing = opts.facing ?? 0;
const skin = SKINS[s.skin % SKINS.length];
const hair = HAIRS[s.hairColor % HAIRS.length];
const shirt = SHIRTS[s.shirt % SHIRTS.length];
const pants = PANTS[s.pants % PANTS.length];
ctx.save();
ctx.translate(px, py);
ctx.scale(sc, sc);
if (facing === 1) ctx.scale(-1, 1); // W mirrors E
const back = facing === 2; // facing away (N)
const walking = anim === 'walk';
const swing = walking ? Math.sin(t * 11) : 0;
const bob = walking ? Math.abs(Math.sin(t * 11)) * 2 : (anim === 'dance' ? Math.abs(Math.sin(t * 6)) * 4 : Math.sin(t * 2.2) * .8);
const danceWave = anim === 'dance' ? Math.sin(t * 6) * 8 : 0;
const exercise = anim === 'exercise';
if (anim === 'lie') {
// lying down: horizontal body
ctx.fillStyle = 'rgba(20,16,28,.2)';
ctx.beginPath(); ctx.ellipse(0, 2, 26, 8, 0, 0, 7); ctx.fill();
ctx.fillStyle = pants; ctx.fillRect(-22, -10, 18, 9); // legs
ctx.fillStyle = shirt; ctx.fillRect(-4, -11, 22, 11); // torso
ctx.fillStyle = skin; ctx.beginPath(); ctx.arc(24, -8, 8, 0, 7); ctx.fill(); // head
ctx.fillStyle = hair; ctx.beginPath(); ctx.arc(24, -12, 8, Math.PI, 0); ctx.fill();
ctx.restore();
return;
}
const sitPose = anim === 'sit';
const legH = sitPose ? 8 : 14;
const bodyY = -(legH + 16) - bob;
// shadow
ctx.fillStyle = 'rgba(20,16,28,.25)';
ctx.beginPath(); ctx.ellipse(0, 1, 11, 4.5, 0, 0, 7); ctx.fill();
// legs
ctx.fillStyle = pants;
if (sitPose) {
ctx.fillRect(-8, -8, 6, 9); ctx.fillRect(2, -8, 6, 9);
ctx.fillRect(-8, -2, 16, 4); // shins forward
} else if (walking) {
ctx.fillRect(-7 + swing * 3, -14, 5, 14);
ctx.fillRect(2 - swing * 3, -14, 5, 14);
} else {
ctx.fillRect(-7, -14, 5, 14); ctx.fillRect(2, -14, 5, 14);
}
// torso
ctx.fillStyle = shirt;
ctx.fillRect(-8, bodyY, 16, sitPose ? 12 : 16);
// arms
ctx.fillStyle = shirt;
if (anim === 'dance') {
ctx.fillRect(-13, bodyY - 6 - danceWave * .5, 5, 14);
ctx.fillRect(8, bodyY - 6 + danceWave * .5, 5, 14);
} else if (exercise) {
ctx.fillRect(-12, bodyY + Math.sin(t * 9) * 4, 5, 13);
ctx.fillRect(7, bodyY - Math.sin(t * 9) * 4, 5, 13);
} else {
ctx.fillRect(-12, bodyY + 2, 5, sitPose ? 8 : 13);
ctx.fillRect(7, bodyY + 2, 5, sitPose ? 8 : 13);
}
// hands
ctx.fillStyle = skin;
ctx.fillRect(-12, bodyY + (sitPose ? 9 : 14), 5, 4);
ctx.fillRect(7, bodyY + (sitPose ? 9 : 14), 5, 4);
// head
const headY = bodyY - 9;
ctx.fillStyle = skin;
ctx.beginPath(); ctx.arc(0, headY, 8.4, 0, 7); ctx.fill();
// hair styles: 0 short, 1 long, 2 ponytail, 3 spiky/bald-cap
ctx.fillStyle = hair;
const hs = s.hairStyle % 4;
ctx.beginPath();
if (back) ctx.arc(0, headY, 8.4, Math.PI * .95, Math.PI * 2.05);
else ctx.arc(0, headY - 1.5, 8.4, Math.PI, Math.PI * 2);
ctx.fill();
if (hs === 1) { ctx.fillRect(-9, headY - 2, 5, 14); ctx.fillRect(4, headY - 2, 5, 14); }
if (hs === 2) { ctx.beginPath(); ctx.arc(back ? 0 : 9, headY + (back ? -2 : 2), 4.4, 0, 7); ctx.fill(); }
if (hs === 3) { for (let i = -1; i <= 1; i++) { ctx.beginPath(); ctx.moveTo(i * 5 - 2, headY - 7); ctx.lineTo(i * 5, headY - 13); ctx.lineTo(i * 5 + 2, headY - 7); ctx.fill(); } }
// face
if (!back) {
ctx.fillStyle = '#222';
const ex = facing === 1 ? -1 : 0;
ctx.fillRect(-4 + ex, headY - 1, 2, 2.6);
ctx.fillRect(2 + ex, headY - 1, 2, 2.6);
ctx.fillStyle = 'rgba(220,120,120,.5)';
ctx.fillRect(-6 + ex, headY + 2, 3, 2); ctx.fillRect(3 + ex, headY + 2, 3, 2);
}
// carried plate
if (s.carryPlate) {
ctx.fillStyle = '#f2f2f2'; ctx.beginPath(); ctx.ellipse(12, bodyY + 12, 6, 3, 0, 0, 7); ctx.fill();
ctx.fillStyle = '#c9803d'; ctx.beginPath(); ctx.ellipse(12, bodyY + 11, 3.4, 2, 0, 0, 7); ctx.fill();
}
ctx.restore();
// sickly pallor
if (s.sickUntil && G.time.absMin < s.sickUntil) {
ctx.fillStyle = 'rgba(130,200,90,.30)';
ctx.beginPath(); ctx.arc(0, bodyY - 8, 10, 0, 7); ctx.fill();
}
// plumbob for selected
if (s.selected) {
const bobP = Math.sin(R.time * 3) * 3;
const [gx, gy] = [px, py - (opts.heightOffset ?? 62) * zoom * stageK + bobP * zoom];
ctx.fillStyle = s.plumbob();
ctx.beginPath();
ctx.moveTo(gx, gy - 7 * zoom); ctx.lineTo(gx + 5 * zoom, gy);
ctx.lineTo(gx, gy + 7 * zoom); ctx.lineTo(gx - 5 * zoom, gy);
ctx.closePath(); ctx.fill();
ctx.strokeStyle = 'rgba(255,255,255,.6)'; ctx.stroke();
}
// thought bubble
if (s.bubble) {
const bx = px + 16 * zoom * Math.max(stageK, .8), by = py - 74 * zoom * Math.max(stageK, .8);
ctx.fillStyle = 'rgba(255,255,255,.95)';
ctx.beginPath(); ctx.arc(bx, by, 12 * zoom, 0, 7); ctx.fill();
ctx.beginPath(); ctx.arc(bx - 10 * zoom, by + 10 * zoom, 3 * zoom, 0, 7); ctx.fill();
ctx.beginPath(); ctx.arc(bx - 14 * zoom, by + 15 * zoom, 1.6 * zoom, 0, 7); ctx.fill();
ctx.font = `${13 * zoom}px sans-serif`;
ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
ctx.fillText(s.bubble.icon, bx, by + 1);
ctx.textBaseline = 'alphabetic';
}
}
function collectSims(items) {
for (const s of G.sims) {
if (!s.atHome && !s.atWork) continue;
if (!s.atHome) continue;
const depth = s.x + s.y + 0.35;
items.push({ depth, sub: 2, fn: (ctx) => {
const [ax, ay] = isoToScreen(s.x, s.y);
const px = ax * G.cam.zoom + G.cam.x;
const py = ay * G.cam.zoom + G.cam.y;
// selection ring
if (s.selected) {
ctx.strokeStyle = 'rgba(255,210,62,.9)';
ctx.lineWidth = 2;
ctx.beginPath(); ctx.ellipse(px, py, 14 * G.cam.zoom, 7 * G.cam.zoom, 0, 0, 7); ctx.stroke();
}
drawSimSprite(ctx, px, py, s, { animT: s.animT * .12 + R.time, anim: s.anim, facing: s.facing });
}});
}
}
function collectFires(items) {
for (const f of (G.fires || [])) {
items.push({ depth: f.x + f.y + .5, sub: 4, fn: (ctx) => {
const [ax, ay] = isoToScreen(f.x + .5, f.y + .5);
const px = ax * G.cam.zoom + G.cam.x, py = ay * G.cam.zoom + G.cam.y;
const flick = Math.sin(R.time * 13 + f.x * 7) * .2 + 1;
ctx.save();
ctx.translate(px, py);
ctx.scale(G.cam.zoom, G.cam.zoom);
// glow
const g = ctx.createRadialGradient(0, -10, 2, 0, -10, 34 * flick);
g.addColorStop(0, 'rgba(255,160,40,.55)');
g.addColorStop(1, 'rgba(255,120,20,0)');
ctx.fillStyle = g;
ctx.beginPath(); ctx.arc(0, -10, 34 * flick, 0, 7); ctx.fill();
// flame tongues
for (let i = 0; i < 3; i++) {
const fx = (i - 1) * 7;
const fh = (16 + Math.sin(R.time * 11 + i * 2) * 6) * flick;
ctx.fillStyle = ['#e84a1a', '#ff8c1a', '#ffd23e'][i];
ctx.beginPath();
ctx.moveTo(fx - 6, 2);
ctx.quadraticCurveTo(fx - 8, -fh * .5, fx, -fh);
ctx.quadraticCurveTo(fx + 8, -fh * .5, fx + 6, 2);
ctx.closePath(); ctx.fill();
}
ctx.font = '14px sans-serif'; ctx.textAlign = 'center';
ctx.fillText('🔥', 0, -26 - Math.abs(Math.sin(R.time * 6)) * 6);
ctx.restore();
}});
}
}
function collectGhosts(items) {
const h = G.time.hourFloat;
if (!(h >= 1 && h < 4) || !G.ghosts) return;
for (const gh of G.ghosts) {
items.push({ depth: gh.x + gh.y + .4, sub: 3, fn: (ctx) => {
const [ax, ay] = isoToScreen(gh.x, gh.y);
const px = ax * G.cam.zoom + G.cam.x, py = ay * G.cam.zoom + G.cam.y;
const bob = Math.sin(R.time * 2 + gh.wobble) * 5;
ctx.save();
ctx.globalAlpha = .45 + Math.sin(R.time * 3 + gh.wobble) * .12;
// translucent shroud
ctx.fillStyle = '#cfe8ff';
ctx.beginPath();
ctx.moveTo(px - 9 * G.cam.zoom, py);
ctx.quadraticCurveTo(px - 10 * G.cam.zoom, py - 30 * G.cam.zoom + bob, px, py - 32 * G.cam.zoom + bob);
ctx.quadraticCurveTo(px + 10 * G.cam.zoom, py - 30 * G.cam.zoom + bob, px + 9 * G.cam.zoom, py);
// wavy tail
ctx.quadraticCurveTo(px + 5 * G.cam.zoom, py - 6 * G.cam.zoom, px, py);
ctx.quadraticCurveTo(px - 5 * G.cam.zoom, py - 6 * G.cam.zoom, px - 9 * G.cam.zoom, py);
ctx.fill();
// face
ctx.globalAlpha = .9;
ctx.fillStyle = '#233021';
ctx.beginPath(); ctx.arc(px - 3 * G.cam.zoom, py - 22 * G.cam.zoom + bob, 1.4 * G.cam.zoom, 0, 7); ctx.fill();
ctx.beginPath(); ctx.arc(px + 3 * G.cam.zoom, py - 22 * G.cam.zoom + bob, 1.4 * G.cam.zoom, 0, 7); ctx.fill();
ctx.font = `${Math.round(11 * G.cam.zoom)}px sans-serif`; ctx.textAlign = 'center';
ctx.fillText('👻', px, py - 40 * G.cam.zoom + bob);
ctx.restore();
}});
}
}
/* fx layer (floating texts etc.) */
const FX = [];
function addFloatText(x, y, text, color = '#fff') {
FX.push({ x, y, text, color, t: 0 });
}
function collectFx(items) {
for (const f of FX) {
items.push({ depth: 9999, sub: 9, fn: (ctx) => {
const [ax, ay] = isoToScreen(f.x, f.y);
const px = ax * G.cam.zoom + G.cam.x;
const py = (ay * G.cam.zoom + G.cam.y) - 30 - f.t * 22;
ctx.globalAlpha = clamp(1 - f.t, 0, 1);
ctx.font = `bold ${13 * G.cam.zoom}px Segoe UI`;
ctx.textAlign = 'center';
ctx.fillStyle = f.color;
ctx.strokeStyle = 'rgba(0,0,0,.6)'; ctx.lineWidth = 3;
ctx.strokeText(f.text, px, py); ctx.fillText(f.text, px, py);
ctx.globalAlpha = 1;
}});
}
}
function tickFx(dt) {
for (const f of FX) f.t += dt;
for (let i = FX.length - 1; i >= 0; i--) if (FX[i].t >= 1.4) FX.splice(i, 1);
}
/* ---------------- ghost previews ---------------- */
function drawGhost(ctx) {
if (G.mode === 'buy' && G.buySel && G.mouseTile) {
const def = OBJECTS[G.buySel];
const rot = G.buyRot;
const w = rot ? def.h : def.w, h = rot ? def.w : def.h;
const tx = G.mouseTile[0], ty = G.mouseTile[1];
const ok = G.funds >= def.price && G.world.canPlace(def, tx, ty, rot);
// footprint cells
for (let dy = 0; dy < h; dy++) for (let dx = 0; dx < w; dx++) {
diamondPath(ctx, tx + dx, ty + dy);
ctx.fillStyle = ok ? 'rgba(80,220,100,.4)' : 'rgba(230,70,70,.4)';
ctx.fill();
ctx.strokeStyle = ok ? '#43e05a' : '#e05a5a';
ctx.stroke();
}
// translucent preview
ctx.globalAlpha = .65;
const fake = { id:-1, defId:G.buySel, x:tx, y:ty, rot, w, h, dirty:0, usedBy:null };
drawObject(ctx, fake, def);
ctx.globalAlpha = 1;
}
if (G.mode === 'build') {
const ht = G.hoverEdge;
if (ht && ['wall','door','window'].includes(G.buildTool)) {
const A = ht.e === 'n' ? tileCornerPx(ht.x, ht.y) : tileCornerPx(ht.x, ht.y);
const B = ht.e === 'n' ? tileCornerPx(ht.x + 1, ht.y) : tileCornerPx(ht.x, ht.y + 1);
const z = WALL_H * G.cam.zoom;
ctx.strokeStyle = G.buildTool === 'wall' ? 'rgba(255,255,255,.9)' : 'rgba(120,220,255,.95)';
ctx.lineWidth = 3;
ctx.beginPath(); ctx.moveTo(A[0], A[1] - z); ctx.lineTo(B[0], B[1] - z); ctx.stroke();
ctx.setLineDash([4, 4]);
ctx.strokeStyle = 'rgba(255,255,255,.4)';
ctx.beginPath(); ctx.moveTo(A[0], A[1]); ctx.lineTo(B[0], B[1]); ctx.stroke();
ctx.setLineDash([]);
}
if (G.buildTool === 'floor' && G.mouseTile) {
diamondPath(ctx, G.mouseTile[0], G.mouseTile[1]);
const f = FLOORS[G.floorSel] || FLOORS[0];
ctx.fillStyle = f.c1; ctx.globalAlpha = .7; ctx.fill(); ctx.globalAlpha = 1;
ctx.strokeStyle = '#fff'; ctx.stroke();
}
if (G.buildTool === 'delWall' && G.hoverEdge) {
const A = tileCornerPx(G.hoverEdge.x, G.hoverEdge.y);
const B = G.hoverEdge.e === 'n' ? tileCornerPx(G.hoverEdge.x + 1, G.hoverEdge.y) : tileCornerPx(G.hoverEdge.x, G.hoverEdge.y + 1);
ctx.strokeStyle = 'rgba(255,80,80,.95)'; ctx.lineWidth = 4;
ctx.beginPath(); ctx.moveTo(A[0], A[1] - WALL_H * G.cam.zoom); ctx.lineTo(B[0], B[1] - WALL_H * G.cam.zoom); ctx.stroke();
}
}
}
/* ---------------- lighting ---------------- */
function getNightness() {
if (!G.time) return 0;
const h = G.time.hourFloat;
if (h >= 21 || h < 5) return 1;
if (h >= 19) return (h - 19) / 2;
if (h < 7) return (7 - h) / 2;
return 0;
}
function mixColor(c1, c2, t) {
const p = (c) => [parseInt(c.slice(1, 3), 16), parseInt(c.slice(3, 5), 16), parseInt(c.slice(5, 7), 16)];
const a = p(c1), b = p(c2);
return `rgb(${Math.round(lerp(a[0], b[0], t))},${Math.round(lerp(a[1], b[1], t))},${Math.round(lerp(a[2], b[2], t))})`;
}
let lightCv = null;
function drawLighting(ctx) {
const n = getNightness();
if (n <= 0.02 && !(G.time.hourFloat >= 6 && G.time.hourFloat < 8) &&
!(G.time.hourFloat >= 17 && G.time.hourFloat < 19)) return;
if (!lightCv) { lightCv = document.createElement('canvas'); }
if (lightCv.width !== R.W || lightCv.height !== R.H) { lightCv.width = R.W; lightCv.height = R.H; }
const lc = lightCv.getContext('2d');
lc.clearRect(0, 0, R.W, R.H);
// darkness
lc.fillStyle = `rgba(10,14,44,${n * .52})`;
lc.fillRect(0, 0, R.W, R.H);
// dawn/dusk warmth
const h = G.time.hourFloat;
if ((h >= 6 && h < 8) || (h >= 17 && h < 19)) {
const wt = h < 8 ? (8 - h) / 2 : (h - 17) / 2;
lc.fillStyle = `rgba(255,150,60,${wt * .16})`;
lc.fillRect(0, 0, R.W, R.H);
}
// punch lights out
lc.globalCompositeOperation = 'destination-out';
const punch = (wx, wy, r, strength = 1) => {
const [ax, ay] = isoToScreen(wx, wy);
const px = ax * G.cam.zoom + G.cam.x, py = ay * G.cam.zoom + G.cam.y - 20 * G.cam.zoom;
const rr = r * G.cam.zoom;
const g = lc.createRadialGradient(px, py, rr * .15, px, py, rr);
g.addColorStop(0, `rgba(0,0,0,${strength})`);
g.addColorStop(1, 'rgba(0,0,0,0)');
lc.fillStyle = g;
lc.beginPath(); lc.arc(px, py, rr, 0, 7); lc.fill();
};
for (const o of G.world.objects) {
const def = OBJECTS[o.defId];
if (def.light) punch(o.x + o.w / 2, o.y + o.h / 2, def.light, .9);
if (o.defId === 'tv' && o.usedBy) punch(o.x + 1, o.y + .5, 60, .5);
if (o.defId === 'stove' && o.usedBy) punch(o.x + .5, o.y + .5, 40, .6);
}
for (const f of (G.fires || [])) punch(f.x + .5, f.y + .5, 95, .85);
for (const s of G.sims) if (s.atHome) punch(s.x, s.y, 34, .35);
lc.globalCompositeOperation = 'source-over';
ctx.drawImage(lightCv, 0, 0);
}
/* ============================================================
* Portrait / CAS rendering (standalone canvases)
* ============================================================ */
function drawSimToCanvas(cv, simData, opts = {}) {
const c = cv.getContext('2d');
c.clearRect(0, 0, cv.width, cv.height);
const sc = opts.scale ?? Math.min(cv.width / 90, cv.height / 130);
const px = cv.width / 2 - (opts.offsetX ?? 0) * sc;
const py = cv.height - (opts.groundPad ?? 8);
const fakeSim = typeof simData === 'object' ? simData : { skin:0 };
const savedZoom = G.cam ? G.cam.zoom : 1;
if (opts.standalone !== false) {
// temporarily neutralize camera for plumbob math
}
drawSimSprite(c, px, py, fakeSim, {
zoom: sc, facing: opts.facing ?? 0, anim: opts.anim ?? 'idle',
animT: opts.animT ?? 0, scale: 1, heightOffset: opts.heightOffset ?? 62,
});
return c;
}
+339
View File
@@ -0,0 +1,339 @@
/* ============================================================
* sims.js — Sim: needs, mood, skills, personality, relationships,
* aging, movement along paths
* ============================================================ */
'use strict';
const WALK_TILES_PER_MIN = 1.35; // game minutes per tile
class Sim {
constructor(data = {}) {
this.id = data.id || uid();
this.name = data.name || 'Sim ' + this.id;
this.gender = data.gender || (chance(.5) ? 'm' : 'f');
this.skin = data.skin != null ? data.skin : randi(0, SKINS.length - 1);
this.hairStyle = data.hairStyle != null ? data.hairStyle : randi(0, 3);
this.hairColor = data.hairColor != null ? data.hairColor : randi(0, HAIRS.length - 1);
this.shirt = data.shirt != null ? data.shirt : randi(0, SHIRTS.length - 1);
this.pants = data.pants != null ? data.pants : randi(0, PANTS.length - 1);
this.traits = data.traits || { neat: randi(0,10), outgoing: randi(0,10), active: randi(0,10), playful: randi(0,10), nice: randi(0,10) };
this.aspiration = data.aspiration || choice(Object.keys(ASPIRATIONS));
// world state
this.x = data.x != null ? data.x : LOT_W / 2;
this.y = data.y != null ? data.y : LOT_H / 2;
this.path = []; // remaining tiles
this.facing = 0;
this.anim = 'idle'; // idle | walk | sit | lie | dance | exercise
this.animT = 0;
this.atHome = true; this.atWork = false; this.isVisitor = !!data.isVisitor;
this.leaveAtMin = data.leaveAtMin || 0; // visitor departure (absolute minute)
this.bubble = null; // {icon, t} thought bubble
// vitals
const n0 = { hunger:72, energy:82, bladder:74, hygiene:78, fun:62, social:66, comfort:70, room:55 };
this.needs = data.needs ? { ...n0, ...data.needs } : { ...n0 };
this.skills = data.skills || {};
for (const s of SKILLS) if (!(s.id in this.skills)) this.skills[s.id] = 0;
this.job = data.job ? { ...data.job } : null;
this.workdaysMissed = 0;
this.ageStage = data.ageStage || 'adult';
this.daysAlive = data.daysAlive || 0;
this.stageSince = data.stageSince != null ? data.stageSince : G.time ? G.time.absMin : 0;
this.pregnantUntil = data.pregnantUntil || 0;
this.schoolPerf = data.schoolPerf != null ? data.schoolPerf : 60;
this.cryT = 0;
this.lastCancelMsg = '';
this.memories = data.memories || [];
this.sickUntil = data.sickUntil || 0;
this.novelChapters = data.novelChapters || 0;
this.paintings = data.paintings || [];
/** rels: simId -> {str, ltr, name} — str=short-term(-100..100) ltr=long-term(-100..100) */
this.rels = new Map();
if (data.rels) for (const [k, v] of Object.entries(data.rels)) this.rels.set(+k, { ...v });
this.action = null; // current Action (ai.js)
this.queue = []; // player-queued actions (max 4)
this.wants = []; // active whims (WantSys)
this.selected = false;
this.talkCooldown = 0; // minutes until autonomous social allowed again
this.carryPlate = false;
}
/* ---------------- relationships ---------------- */
getRel(other) {
let r = this.rels.get(other.id);
if (!r) {
r = { str: other.isVisitor || this.isVisitor ? 15 : (this.familyWith(other) ? 40 : 12),
ltr: other.isVisitor || this.isVisitor ? 5 : (this.familyWith(other) ? 25 : 0),
name: other.name };
this.rels.set(other.id, r);
}
return r;
}
familyWith(other) {
return !this.isVisitor && !other.isVisitor && !this.visitorHousehold !== !other.isVisitor
? false : (!this.isVisitor && !other.isVisitor);
}
friendsCount() {
let c = 0;
for (const [id, r] of this.rels) if (r.ltr >= 50) c++;
return c;
}
/* ---------------- mood ---------------- */
moodScore() {
const w = { hunger:.24, energy:.20, bladder:.13, hygiene:.08, fun:.13, social:.11, comfort:.06, room:.05 };
let m = 0;
for (const k in w) m += clamp(this.needs[k], 0, 100) * w[k];
return m;
}
plumbob() { const m = this.moodScore(); return m > 60 ? '#3ddc55' : m > 32 ? '#ffd23e' : '#ff4040'; }
/** milestone diary shown in the Bio tab */
addMemory(icon, text) {
this.memories ||= [];
this.memories.unshift({ icon, text, day: G.time.day });
if (this.memories.length > 24) this.memories.length = 24;
}
/* ---------------- needs ticking (per game-minute) ---------------- */
tickNeeds(min) {
const t = this.traits;
for (const key in NEEDS) {
if (key === 'room') { this.needs.room = lerp(this.needs.room, G.world.roomAt(this.x, this.y), .02 * min); continue; }
let d = NEEDS[key].decay * (min / 60); // decay rates are per-hour
// personality modifiers
if (key === 'energy' && t.active > 7) d *= 1.15;
if (key === 'hunger' && t.active > 7) d *= 1.2;
if (this.ageStage === 'baby') {
if (key === 'hunger') d *= 1.7;
if (key === 'social') d *= 1.5;
if (key === 'bladder') d *= 0.7;
if (key === 'fun') d = 0;
}
if (this.job) d *= 1; // same at work (handled while away abstractly)
// sleeping / sitting states slow decay & restore
if (this.anim === 'lie') {
if (key === 'energy') d = +0.85 * min * (this.sleepQuality || 1); // strong regen handled by action fx too
else d *= 0.15;
} else if (this.anim === 'sit') d *= 0.7;
if (key === 'fun' && G.weather && G.weather.type === 'rain') d *= 1.2; // rainy-day blues
if (this.sickUntil && (key === 'hunger' || key === 'energy')) d *= 1.35;
this.needs[key] = clamp(this.needs[key] + d, -5, 100);
}
/* --- critical failures --- */
if (this.needs.bladder <= 2 && !['pee'].includes(this.action?.def?.id)) {
this.needs.bladder = 40; this.needs.hygiene = Math.min(this.needs.hygiene, 8);
G.world.dirtPuddle ||= [];
G.world.dirtPuddle.push({ x: Math.round(this.x), y: Math.round(this.y), kind:'puddle', t: 300 });
toast(`💦 ${this.name} couldn't hold it!`, 'bad');
AudioSys.sfx('splash');
thought(this, '😭');
this.cancelAction('Accident');
}
if (this.needs.energy <= 1 && this.anim !== 'lie') {
this.needs.energy = 12;
toast(`😵 ${this.name} passed out from exhaustion!`, 'bad');
thought(this, '💤');
AI.passOut(this);
}
/* --- sickness (food poisoning / flu): green pallor, slower regen, vomit --- */
if (this.sickUntil && G.time.absMin < this.sickUntil) {
if (Math.random() < .0012 * min) {
G.world.dirtPuddle ||= [];
G.world.dirtPuddle.push({ x: Math.round(this.x), y: Math.round(this.y), kind:'puke', t: 400 });
this.say('🤮'); AudioSys.sfx('thud');
this.needs.hunger = clamp(this.needs.hunger - 8, 0, 100);
}
if (Math.random() < .0006 * min) thought(this, '🤢');
// contagion via proximity
for (const o of G.sims) {
if (o === this || o.sickUntil || !o.atHome || o.ageStage === 'baby') continue;
if (dist2(o.x, o.y, this.x, this.y) < 4 && chance(.00025 * min)) makeSick(o);
}
} else if (this.sickUntil) { this.sickUntil = 0; toast(`😊 ${this.name} feels better!`, 'good'); }
/* --- starving is fatal --- */
if (this.needs.hunger <= 0) {
this.starveT = (this.starveT || 0) + min;
if (this.starveT > 900 && this.ageStage !== 'baby') dieOf(this, 'starvation');
} else this.starveT = Math.max(0, (this.starveT || 0) - min * .5);
}
gainSkill(id, amount) {
if (!(id in this.skills)) return;
const cur = this.skills[id];
if (cur >= 10) return;
// higher levels need enthusiasm (mood) — like Sims interest decay
this.skills[id] = clamp(cur + amount, 0, 10);
const before = Math.floor(cur), after = Math.floor(this.skills[id]);
if (after > before) {
const sk = SKILLS.find(s => s.id === id);
toast(`${sk.icon} ${this.name} reached ${sk.name} level ${after}!`, 'good');
AudioSys.sfx('level');
WantSys.notify(this, 'skill', { skill: id, level: after });
if (this.aspiration === 'knowledge') G.aspirationPoints += 50;
}
}
/* ---------------- movement ---------------- */
setPath(tiles) {
if (!tiles) return false;
this.path = tiles.slice();
return true;
}
walkAlong(min) {
if (!this.path.length) return true;
let budget = WALK_TILES_PER_MIN * min * (G.speedMul || 1) ;
while (budget > 0 && this.path.length) {
const [tx, ty] = this.path[0];
const dx = tx - this.x, dy = ty - this.y;
const d = Math.hypot(dx, dy);
if (d < 0.02) { this.x = tx; this.y = ty; this.path.shift(); continue; }
const step = Math.min(budget, d);
this.x += dx / d * step; this.y += dy / d * step;
this.facing = dirFromDelta(dx, dy);
this.anim = 'walk';
budget -= step;
}
return this.path.length === 0;
}
say(icon, dur = 90) { this.bubble = { icon, t: dur }; }
/** stop whatever the sim is doing */
cancelAction(msg = '') {
const a = this.action;
if (!a) return;
this.lastCancelMsg = msg;
if (typeof a.finish === 'function') { a.cancelMsg = msg; a.finish(); }
else { a.done = true; if (a.a?.busyWith === this) a.a.busyWith = null; }
if (this.action === a) this.action = null;
this.path = [];
if (this.anim === 'walk') this.anim = 'idle';
}
/* ---------------- per-minute master tick ---------------- */
tick(min) {
this.animT += min;
if (this.bubble) { this.bubble.t -= min * 6; if (this.bubble.t <= 0) this.bubble = null; }
if (this.talkCooldown > 0) this.talkCooldown -= min;
// life-stage transitions
if (this.ageStage === 'baby' && G.time.absMin - this.stageSince >= 4 * 1440) {
this.ageStage = 'child'; this.stageSince = G.time.absMin;
toast(`🎂 ${this.name} grew into a child!`, 'good'); AudioSys.sfx('level');
Bus.emit('simsChanged');
} else if (this.ageStage === 'child' && G.time.absMin - this.stageSince >= 8 * 1440) {
this.ageStage = 'adult'; this.stageSince = G.time.absMin;
toast(`🎂 ${this.name} grew into an adult!`, 'good'); AudioSys.sfx('fanfare');
Bus.emit('simsChanged');
}
// pregnancy full-term
if (this.pregnantUntil && G.time.absMin >= this.pregnantUntil) {
this.pregnantUntil = 0;
giveBirth(this);
}
// expectant mothers think about it now and then
if (this.pregnantUntil && Math.random() < .0008 * min) this.say('🍼');
if (!this.atHome) {
// working or arriving/away — needs decay slower off-lot
for (const k of ['hunger','bladder']) this.needs[k] = clamp(this.needs[k] + NEEDS[k].decay * min * .45, 0, 100);
this.needs.energy = clamp(this.needs.energy + NEEDS.energy.decay * min * .3, 0, 100);
return;
}
// babies stay put & fuss
if (this.ageStage === 'baby') {
this.anim = 'idle';
this.tickNeeds(min);
this.babyCry(min);
return;
}
if (this.path.length) { this.walkAlong(min); }
else if (this.anim === 'walk') this.anim = this.action?.pose === 'sit' ? 'sit' : 'idle';
this.tickNeeds(min);
if (this.action) this.action.tick(min);
else if (!this.isVisitor || chance(.002 * min)) AI.autonomize(this, min);
// visitors go home eventually
if (this.isVisitor) {
if (this.leavePending && !this.path.length && !this.action) { G.removeSim(this); return; }
if (G.time.absMin >= this.leaveAtMin) AI.visitorLeave(this);
}
}
babyCry(min) {
const upset = this.needs.hunger < 38 || this.needs.energy < 30 || this.needs.bladder < 22 || this.needs.social < 25;
if (!upset) { this.cryT = Math.max(0, this.cryT - min); return; }
this.cryT += min;
if (this.cryT > 8) {
this.cryT = 0;
this.say('😢', 60);
AudioSys.sfx('splash'); // wah-ish
for (const a of G.sims) {
if (a === this || !a.atHome || a.ageStage === 'baby') continue;
if (dist2(a.x, a.y, this.x, this.y) < 64) {
if (a.anim === 'lie' && a.needs.energy > 20) a.cancelAction('Woken by crying baby');
a.needs.energy = clamp(a.needs.energy - 2.5, 0, 100);
a.say('😪');
}
}
}
}
/* ---------------- persistence ---------------- */
serialize() {
return {
id:this.id, name:this.name, gender:this.gender, skin:this.skin,
hairStyle:this.hairStyle, hairColor:this.hairColor, shirt:this.shirt, pants:this.pants,
traits:{ ...this.traits }, aspiration:this.aspiration,
x:this.x, y:this.y, facing:this.facing,
needs:{ ...this.needs }, skills:{ ...this.skills },
job: this.job ? { ...this.job } : null,
marriedTo: this.marriedTo || null,
pregnantUntil: this.pregnantUntil, stageSince: this.stageSince,
schoolPerf: this.schoolPerf,
ageStage:this.ageStage, daysAlive:this.daysAlive,
memories: this.memories || [],
sickUntil: this.sickUntil || 0,
novelChapters: this.novelChapters || 0,
paintings: this.paintings || [],
isVisitor:false, // visitors are not saved
rels: Array.from(this.rels.entries()).filter(([id]) => G.simById(id)),
};
}
}
/* ---------- helpers ---------- */
function thought(sim, icon) { sim.say(icon); }
/* make a Sim from a CAS template object */
function simFromTemplate(t) {
return new Sim({
name: t.name, gender: t.gender, skin: t.skin, hairStyle: t.hairStyle,
hairColor: t.hairColor, shirt: t.shirt, pants: t.pants,
traits: { ...t.traits }, aspiration: t.aspiration,
});
}
function randomSimData(gender = null) {
const g = gender || (chance(.5) ? 'm' : 'f');
return {
name: choice(g === 'f' ? FIRST_NAMES_F : FIRST_NAMES_M) + ' ' + choice(LAST_NAMES),
gender: g,
nameCustom: false,
skin: randi(0, SKINS.length - 1), hairStyle: randi(0, 3), hairColor: randi(0, HAIRS.length - 1),
shirt: randi(0, SHIRTS.length - 1), pants: randi(0, PANTS.length - 1),
traits: Object.fromEntries(TRAITS.map(t => [t, randi(0, 10)])),
aspiration: choice(Object.keys(ASPIRATIONS)),
};
}
+473
View File
@@ -0,0 +1,473 @@
/* ============================================================
* ui.js — HUD, portraits, sim panel, buy catalog, build bar,
* pie menus, toasts, career picker
* ============================================================ */
'use strict';
/* ---------------- toasts ---------------- */
function toast(msg, cls = '') {
AudioSys.sfx(cls === 'bad' ? 'error' : 'toast');
const box = document.getElementById('toasts');
const el = document.createElement('div');
el.className = 'toast ' + cls;
el.innerHTML = msg;
box.appendChild(el);
while (box.children.length > 4) box.removeChild(box.firstChild);
setTimeout(() => { el.style.opacity = '0'; el.style.transition = 'opacity .5s'; }, 4200);
setTimeout(() => el.remove(), 4800);
}
function toastBill(amount) {
AudioSys.sfx('bill');
const box = document.getElementById('toasts');
const el = document.createElement('div');
el.className = 'toast bad';
el.innerHTML = `📬 Bills due: <b>${fmtMoney(amount)}</b> <span class="envelope" id="payBillsBtn">PAY</span>`;
box.appendChild(el);
document.getElementById('payBillsBtn').onclick = () => {
if (G.funds >= amount) { G.funds -= amount; G.billsPaid = true; G.mailBillsDue = false;
toast(`✅ Bills paid: ${fmtMoney(amount)}`); el.remove(); Bus.emit('fundsChanged'); }
else toast('❌ Not enough money for the bills!', 'bad');
};
}
/* ---------------- HUD ---------------- */
function updateHud() {
document.getElementById('fundsVal').textContent = Math.floor(G.funds).toLocaleString('en-US');
const t = G.time;
document.getElementById('clockTime').textContent =
`${Math.floor(t.hour)}:${String(Math.floor((t.hourFloat % 1) * 60)).padStart(2, '0')}` +
` ${t.hour >= 12 ? 'PM' : 'AM'}`;
document.getElementById('clockDay').textContent = `${DAY_NAMES[(t.day - 1) % 7]}, Day ${t.day}`;
}
/* ---------------- portraits ---------------- */
const portraitEls = new Map();
function rebuildPortraits() {
const row = document.getElementById('portraitRow');
row.innerHTML = '';
portraitEls.clear();
for (const s of G.sims.filter(s => !s.isVisitor)) {
const d = document.createElement('div');
d.className = 'portrait' + (s.selected ? ' selected' : '');
const cv = document.createElement('canvas');
cv.width = 70; cv.height = 62;
d.appendChild(cv);
const nm = document.createElement('div'); nm.className = 'pname'; nm.textContent = s.name.split(' ')[0];
d.appendChild(nm);
const pb = document.createElement('div'); pb.className = 'plumbob'; pb.textContent = '🔷';
d.appendChild(pb);
const mb = document.createElement('div'); mb.className = 'moodbar';
const mfill = document.createElement('div'); mb.appendChild(mfill);
d.appendChild(mb);
d.onclick = () => { selectSim(s); };
row.appendChild(d);
portraitEls.set(s.id, { root:d, cv, plumbob:pb, mfill });
}
refreshPortraits();
}
function refreshPortraits() {
for (const s of G.sims.filter(s => !s.isVisitor)) {
const pe = portraitEls.get(s.id);
if (!pe) continue;
pe.root.classList.toggle('selected', !!s.selected);
const nmEl = pe.root.querySelector('.pname');
if (nmEl) nmEl.textContent = s.name.split(' ')[0] + (s.sickUntil && G.time.absMin < s.sickUntil ? ' 🤢' : '');
pe.plumbob.textContent = s.moodScore() > 60 ? '🟢' : s.moodScore() > 32 ? '🟡' : '🔴';
const m = s.moodScore();
pe.mfill.style.width = m + '%';
pe.mfill.style.background = m > 60 ? '#3ddc55' : m > 32 ? '#ffd23e' : '#ff4040';
drawSimToCanvas(pe.cv, s, { facing:0, scale: Math.min(70/90, 62/130) + .18, groundPad: 4 });
}
}
/* ---------------- sim selection ---------------- */
function selectSim(s) {
for (const o of G.sims) o.selected = false;
if (s) s.selected = true;
G.selectedSim = s;
refreshPortraits();
updateSimPanel();
}
/* ---------------- sim side panel ---------------- */
function updateSimPanel() {
const panel = document.getElementById('simPanel');
const s = G.selectedSim;
if (!s || G.mode === 'cas') { panel.classList.add('hidden'); return; }
panel.classList.remove('hidden');
document.getElementById('simPanelName').textContent = s.name + (s.isVisitor ? ' (visiting)' : '');
const m = s.moodScore();
document.getElementById('simPanelMood').textContent =
'Mood: ' + (m > 75 ? 'Elated 😄' : m > 55 ? 'Happy 🙂' : m > 35 ? 'Uneasy 😕' : m > 18 ? 'Miserable 😣' : 'Desperate 😫');
drawSimToCanvas(document.getElementById('simPortrait'), s, { facing:0, scale:1, groundPad:6 });
const body = document.getElementById('simPanelBody');
const tab = panel.dataset.tab || 'needs';
let html = '';
if (tab === 'needs') {
for (const k in NEEDS) {
const meta = NEEDS[k];
const v = clamp(s.needs[k], 0, 100);
const col = v > 55 ? '#43c15a' : v > 28 ? '#e8a33d' : '#e05252';
html += `<div class="needRow"><div class="nlabel"><span>${meta.icon} ${meta.label}</span><span>${Math.round(v)}</span></div>
<div class="nbar"><div style="width:${v}%;background:${col}"></div></div></div>`;
}
} else if (tab === 'wants') {
const asp = ASPIRATIONS[s.aspiration];
const lvl = Math.floor(G.aspirationPoints / 500);
const prog = G.aspirationPoints % 500;
html += `<div class="relCard"><div class="rname"><span>${asp.icon} ${asp.name}</span><span>Lvl ${lvl}</span></div>
<div class="nbar"><div style="width:${prog / 5}%;background:#c95ad9"></div></div>
<div style="font-size:11px;color:#9fb4ea;margin-top:4px">${prog}/500 to next level</div></div>`;
if (!s.wants || !s.wants.length) html += '<i>No whims right now…</i>';
for (const w of (s.wants || [])) {
const t = w.tpl;
const pct = t.count ? Math.round(w.progress / t.count * 100) : (t.amount ? Math.min(100, Math.round(w.bank / t.amount * 100)) : 0);
html += `<div class="relCard"><div class="rname"><span>${t.icon} ${t.label}</span><span>+${t.reward}</span></div>
${t.count || t.amount ? `<div class="relBar"><div style="width:${pct}%;background:#43c15a"></div></div>` : ''}
</div>`;
}
html += `<div style="font-size:11px;color:#9fb4ea">Whims are guided by the ${asp.name} aspiration. Fulfil them for aspiration points!</div>`;
} else if (tab === 'skills') {
for (const sk of SKILLS) {
const lvl = Math.floor(s.skills[sk.id]);
let pips = '';
for (let i = 0; i < 10; i++) pips += i < lvl ? '●' : '·';
html += `<div class="skillRow"><span>${sk.icon} ${sk.name}</span><span class="pips" style="color:#ffd23e">${pips}</span><b>${lvl}</b></div>`;
}
} else if (tab === 'rels') {
const others = G.sims.filter(o => o !== s);
if (!others.length) html += '<i>No other sims around yet.<br>Try the phone → Invite Neighbor!</i>';
for (const o of others) {
const r = s.getRel(o);
const ltrCol = r.ltr >= 50 ? '#43c15a' : r.ltr <= -25 ? '#e05252' : '#7f9fd9';
const strCol = r.str >= 40 ? '#43c15a' : r.str <= -20 ? '#e05252' : '#c9a24a';
const badge = r.ltr >= 75 ? '💞' : r.ltr >= 50 ? '🤝' : r.ltr <= -40 ? '⚔️' : '';
html += `<div class="relCard"><div class="rname"><span>${o.name} ${badge}</span><span>${Math.round(r.ltr)}</span></div>
<div class="relBar"><div style="width:${(r.ltr + 100) / 2}%;background:${ltrCol}"></div></div>
<div class="relBar"><div style="width:${(r.str + 100) / 2}%;background:${strCol}"></div></div>
</div>`;
}
} else if (tab === 'career') {
if (s.atWork) html += `<div class="careerLine">🚗 Currently <b>at work</b>.</div>`;
if (!s.job) {
html += `<div class="careerLine">❌ Unemployed.<br>Use a <b>computer</b> → Find a Job.</div>`;
} else {
const c = CAREERS.find(c => c.id === s.job.track);
const rank = c.ranks[s.job.rank];
const perf = s.job.perf ?? 50;
html += `<div class="careerLine">${c.icon} <b>${rank.title}</b><br>
${c.trackName} · Level ${s.job.rank + 1}/10<br>
💰 ${fmtMoney(rank.salary)}/day · 🕘 ${rank.hours[0]}:00${rank.hours[1]}:00<br>
Off: ${rank.offDays.map(d => DAY_NAMES[d]).join(', ')}</div>
<div class="needRow"><div class="nlabel"><span>📈 Performance</span><span>${Math.round(perf)}</span></div>
<div class="nbar"><div style="width:${perf}%;background:${perf > 66 ? '#43c15a' : perf > 33 ? '#e8a33d' : '#e05252'}"></div></div></div>
<div class="careerLine"><b>Next level needs:</b><br>${
Object.entries(rank.req).map(([k, v]) => `${SKILLS.find(x => x.id === k)?.icon || ''} ${SKILLS.find(x => x.id === k)?.name}: ${v}`).join('<br>') || '— just keep performance up!'
}</div>`;
}
html += `<hr><div class="bioLine">⭐ Aspiration points: ${Math.round(G.aspirationPoints)}</div>`;
} else if (tab === 'bio') {
const zod = ['Capricorn','Aquarius','Pisces','Aries','Taurus','Gemini','Cancer','Leo','Virgo','Libra','Scorpio','Sagittarius'][s.id % 12];
html += `<div class="bioLine">🧑 Name: <b>${s.name}</b></div>
<div class="bioLine">${s.gender === 'm' ? '👨 Male' : '👩 Female'} · ${s.ageStage === 'elder' ? '🧓 Elder' : s.ageStage === 'child' ? '🧒 Child' : s.ageStage === 'baby' ? '👶 Baby' : '🧍 Adult'}</div>
<div class="bioLine">♒ Zodiac sign: ${zod}</div>
<div class="bioLine">✨ Aspiration: <b>${ASPIRATIONS[s.aspiration].icon} ${ASPIRATIONS[s.aspiration].name}</b><br>
<small style="color:#9fb4ea">${ASPIRATIONS[s.aspiration].desc}</small></div>
<div class="bioLine" style="margin-top:8px"><b>Personality</b></div>` +
TRAITS.map(tr => {
const labels = { neat:'Neat', outgoing:'Outgoing', active:'Active', playful:'Playful', nice:'Nice' };
return `<div class="skillRow"><span>${labels[tr]}</span><span class="pips" style="color:#ffd23e">${
'●'.repeat(s.traits[tr]) + '·'.repeat(10 - s.traits[tr])}</span></div>`;
}).join('');
// life milestones diary
const mem = (s.memories || []).slice(0, 8);
html += `<div class="bioLine" style="margin-top:8px"><b>📜 Memories</b></div>` +
(mem.length
? `<div style="max-height:130px;overflow:auto">` + mem.map(m =>
`<div class="bioLine">Day ${m.day} ${m.icon} ${m.text}</div>`).join('') + `</div>`
: `<div class="bioLine" style="color:#9aa">No memories yet — go live a little!</div>`);
if (s.novelChapters > 0) html += `<div class="bioLine">✍️ Writing a novel — chapter ${s.novelChapters}/10</div>`;
if ((s.paintings || []).length) html += `<div class="bioLine">🖼️ ${s.paintings.length} painting(s) ready to sell</div>`;
}
body.innerHTML = html;
}
/* ---------------- career chance cards ---------------- */
Bus.on('chanceCard', () => {
const p = G.pendingChance; if (!p) return;
const sim = G.simById(p.simId);
if (!sim || !sim.job) { G.pendingChance = null; setSpeed(1); return; }
const card = p.card;
let el = document.getElementById('chanceCard');
if (!el) { el = document.createElement('div'); el.id = 'chanceCard'; document.body.appendChild(el); }
const cname = (CAREERS.find(c => c.id === sim.job.track) || {}).name || 'Work';
el.innerHTML = `<div class="cc-box">
<div class="cc-head">💼 Career Opportunity — ${cname}</div>
<div class="cc-q">${card.q}</div>
<div class="cc-btns">${card.a.map((a, i) =>
`<button data-i="${i}">${a.icon || ''} ${a.label}</button>`).join('')}</div>
<div class="cc-sub">Time is paused while ${sim.name.split(' ')[0]} decides…</div>
</div>`;
el.classList.remove('hidden');
el.querySelectorAll('button').forEach(b => b.onclick = () => {
const a = card.a[+b.dataset.i];
if (a.fx && a.fx.dice != null) {
const win = chance(a.fx.dice);
applyChanceFx(sim, win ? a.fx.win : a.fx.lose);
toast(win ? `🎯 Bold move! It paid off for ${sim.name}.` : `😬 That backfired on ${sim.name}`, win ? 'good' : 'bad');
} else {
applyChanceFx(sim, a.fx || {});
toast(`${a.icon || ''} ${sim.name.split(' ')[0]} chose: ${a.label}`, '');
}
AudioSys.sfx('click');
el.classList.add('hidden');
G.pendingChance = null;
setSpeed(G.prevSpeedBeforeCard || 1);
});
});
/* tab clicks */
document.querySelectorAll('#simPanelTabs button').forEach(b => {
b.onclick = () => {
document.querySelectorAll('#simPanelTabs button').forEach(x => x.classList.remove('active'));
b.classList.add('active');
document.getElementById('simPanel').dataset.tab = b.dataset.tab;
updateSimPanel();
};
});
document.getElementById('simPanelClose').onclick = () => document.getElementById('simPanel').classList.add('hidden');
/* ---------------- BUY drawer ---------------- */
let buyThumbCache = new Map();
function thumbFor(defId) {
if (buyThumbCache.has(defId)) return buyThumbCache.get(defId).cloneNode ?
(() => { const c = document.createElement('canvas'); c.width = 84; c.height = 64;
c.getContext('2d').drawImage(buyThumbCache.get(defId), 0, 0); return c; })() : null;
const src = document.createElement('canvas'); src.width = 168; src.height = 128;
const c = src.getContext('2d');
const def = OBJECTS[defId];
c.save(); c.translate(84, 96); c.scale(.95, .95);
const fake = { id: 3, defId, x:0, y:0, rot:0, w:def.w, h:def.h, dirty:.2, usedBy:false };
const painter = PAINTERS[def.shape];
// emulate drawObject's local space
if (painter) painter(c, fake, 1.0);
else { c.fillStyle = '#caa'; c.fillRect(-14, -30, 28, 30); }
c.restore();
buyThumbCache.set(defId, src);
const out = document.createElement('canvas'); out.width = 84; out.height = 64;
out.getContext('2d').drawImage(src, 0, 0, 168, 128, 0, 0, 84, 64);
return out;
}
function openBuyDrawer() {
const drawer = document.getElementById('buyDrawer');
drawer.classList.remove('hidden');
const tabs = document.getElementById('buyTabs');
tabs.innerHTML = '';
let curCat = drawer.dataset.cat || 'seating';
for (const cat of BUY_CATS) {
const b = document.createElement('button');
b.textContent = cat.icon + ' ' + cat.label;
b.className = cat.id === curCat ? 'active' : '';
b.onclick = () => { drawer.dataset.cat = cat.id; openBuyDrawer(); };
tabs.appendChild(b);
}
const grid = document.getElementById('buyGrid');
grid.innerHTML = '';
for (const id in OBJECTS) {
const def = OBJECTS[id];
if (def.cat !== curCat) continue;
const card = document.createElement('div');
card.className = 'buyItem' + (G.buySel === id ? ' sel' : '');
card.appendChild(thumbFor(id));
const bn = document.createElement('div'); bn.className = 'bn'; bn.textContent = def.name;
const bp = document.createElement('div'); bp.className = 'bp'; bp.textContent = fmtMoney(def.price);
card.appendChild(bn); card.appendChild(bp);
card.onclick = () => { G.buySel = id; G.buyRot = 0; openBuyDrawer(); setHint(`Placing ${def.name} (${fmtMoney(def.price)}) — click a tile · R rotate · Esc cancel`); };
grid.appendChild(card);
}
}
function closeBuyDrawer() {
document.getElementById('buyDrawer').classList.add('hidden');
}
function setHint(txt) { document.getElementById('buyHint').innerHTML = txt; }
/* ---------------- BUILD bar ---------------- */
function openBuildBar() {
document.getElementById('buildBar').classList.remove('hidden');
const sw = document.getElementById('floorSwatches');
if (!sw.children.length) {
FLOORS.forEach((f, i) => {
if (f.outdoor) return;
const s = document.createElement('div');
s.className = 'swatch' + (i === G.floorSel ? ' sel' : '');
s.style.background = f.c1;
s.title = f.id;
s.onclick = () => { G.floorSel = i; openBuildBar(); };
sw.appendChild(s);
});
} else {
[...sw.children].forEach((el, i) => el.classList.toggle('sel', i === G.floorSel));
}
// wall color swatches
const ws = document.getElementById('wallSwatches');
if (!ws.children.length) {
const lbl = document.createElement('span');
lbl.style.cssText = 'font-size:11px;color:#9fb4ea;margin:0 2px;';
lbl.textContent = '🧱';
ws.appendChild(lbl);
WALL_COLORS.forEach((c, i) => {
const s = document.createElement('div');
s.className = 'swatch' + (c === G.wallColor ? ' sel' : '');
s.style.background = c;
s.onclick = () => { G.wallColor = c; openBuildBar(); };
ws.appendChild(s);
});
} else {
let ci = 0;
[...ws.children].forEach(el => {
if (!el.style.background) return; // label
el.classList.toggle('sel', WALL_COLORS[ci] === G.wallColor);
ci++;
});
}
document.querySelectorAll('#buildBar [data-tool]').forEach(b =>
b.classList.toggle('active', b.dataset.tool === G.buildTool));
document.getElementById('buildHint').innerHTML =
`Wall §70/segment · Door §250 · Window §180 · Floor §12/tile · Removing refunds 50% — <b>${{wall:'Drag across edges to build',door:'Click a wall segment',window:'Click a wall segment',floor:'Drag to paint floor',delWall:'Click walls/doors/windows to remove'}[G.buildTool]||''}</b>`;
}
function closeBuildBar() { document.getElementById('buildBar').classList.add('hidden'); }
/* build tool buttons */
document.querySelectorAll('#buildBar [data-tool]').forEach(b => {
b.onclick = () => { G.buildTool = b.dataset.tool; openBuildBar(); };
});
/* ---------------- PIE MENU ---------------- */
function showPie(px, py, entries, title = '') {
const pie = document.getElementById('pieMenu');
pie.innerHTML = '';
if (title) {
const t = document.createElement('div');
t.style.cssText = 'padding:4px 12px;font-weight:800;color:#ffd23e;font-size:13px;';
t.textContent = title;
pie.appendChild(t);
pie.appendChild(document.createElement('hr'));
}
for (const en of entries) {
if (en === '-') { pie.appendChild(document.createElement('hr')); continue; }
const d = document.createElement('div');
d.className = 'pi' + (en.disabled ? ' dis' : '');
d.innerHTML = `<span>${en.icon || ''}</span><span>${en.label}</span>` +
(en.price != null ? `<span class="price">${en.price < 0 ? '+' : ''}${fmtMoney(Math.abs(en.price)).slice(0)}</span>` : '');
if (!en.disabled) d.onclick = () => { hidePie(); AudioSys.sfx('click'); en.fn(); };
pie.appendChild(d);
}
pie.classList.remove('hidden');
// keep on-screen
const r = pie.getBoundingClientRect();
pie.style.left = clamp(px, 6, window.innerWidth - r.width - 8) + 'px';
pie.style.top = clamp(py, 6, window.innerHeight - r.height - 8) + 'px';
}
function hidePie() { document.getElementById('pieMenu').classList.add('hidden'); }
window.addEventListener('mousedown', (e) => {
const pie = document.getElementById('pieMenu');
if (!pie.classList.contains('hidden') && !pie.contains(e.target)) hidePie();
});
/* ---------------- interactions pie for an object ---------------- */
function objectInteractions(obj) {
const def = OBJECTS[obj.defId];
const entries = [];
if (obj.broken) {
entries.push({
label: 'Repair', icon: '🔧',
disabled: (G.selectedSim?.skills.mechanical || 0) < 1,
fn: () => {
const s = G.selectedSim;
if (!s || !s.atHome) { toast('Pick a sim at home first!', 'bad'); return; }
const mech = s.skills.mechanical || 0;
commandUse(s, obj, { id:'repair', label:'Repair', icon:'🔧', special:'repair',
pose:'stand', dur: Math.max(14, 50 - mech * 4) });
},
});
return entries;
}
for (const inter of def.interactions || []) {
if (inter.requiresDirty && obj.dirty <= .2) continue;
if (inter.requiresFull && obj.dirty < .5) continue;
if (inter.special === 'findJob' && G.selectedSim?.job) continue;
if (inter.special === 'tryBaby' && !canTryForBaby(G.selectedSim)) continue;
if (inter.babyOnly && G.selectedSim?.ageStage !== 'baby') continue;
if (inter.childOnly && G.selectedSim?.ageStage !== 'child') continue;
entries.push({
label: inter.label, icon: inter.icon,
disabled: !!(inter.cost && G.funds < inter.cost),
fn: () => {
const s = G.selectedSim;
if (!s || !s.atHome) { toast('Pick a sim at home first!', 'bad'); return; }
commandUse(s, obj, inter);
},
});
}
// sinks grow a Wash Dishes action when there are dirty dishes around
if (obj.defId === 'sink' && dishTotal() > 0) {
entries.push({
label: 'Wash Dishes', icon: '🧼',
fn: () => {
const s = G.selectedSim;
if (!s || !s.atHome) { toast('Pick a sim at home first!', 'bad'); return; }
commandUse(s, obj, { id:'wash', label:'Wash Dishes', icon:'🧼', special:'wash', pose:'stand', dur:40 });
},
});
}
return entries;
}
let keyIsShift = false;
/* ---------------- career picker (computer) ---------------- */
function jobMenuOpenFor(sim) { return G._jobMenuSim === sim && !document.getElementById('pieMenu').classList.contains('hidden'); }
function openJobPicker(sim, px, py) {
G._jobMenuSim = sim;
const entries = [];
for (const c of CAREERS) {
const r0 = c.ranks[0];
entries.push({
label: `${c.trackName}${r0.title}`, icon: c.icon,
fn: () => {
G._jobMenuSim = null;
CareerSys.hire(sim, c.id);
if (sim.action?.special === 'findJob') sim.action.finish();
updateSimPanel();
},
});
}
entries.push('-', { label: 'Never mind', icon: '↩️', fn: () => {
G._jobMenuSim = null;
if (sim.action?.special === 'findJob') sim.action.finish();
}});
showPie(px, py, entries, '📋 Choose a career track');
}
/* ---------------- help / mute ---------------- */
document.getElementById('btnHelp').onclick = () =>
document.getElementById('helpOverlay').classList.remove('hidden');
document.getElementById('helpClose').onclick = () =>
document.getElementById('helpOverlay').classList.add('hidden');
document.getElementById('btnHood').onclick = function () {
if (typeof enterHood === 'function') G.mode === 'hood' ? exitHood() : enterHood();
};
document.getElementById('btnMute').onclick = function () { AudioSys.muted = !AudioSys.muted;
this.classList.toggle('active', !AudioSys.muted);
this.textContent = AudioSys.muted ? '🔇' : '🔊';
};
/* ---------------- speed buttons ---------------- */
document.querySelectorAll('.speed-btn').forEach(b => {
b.onclick = () => setSpeed(+b.dataset.speed);
});
function setSpeed(v) {
G.speed = v;
document.querySelectorAll('.speed-btn').forEach(b =>
b.classList.toggle('active', +b.dataset.speed === v));
}
+342
View File
@@ -0,0 +1,342 @@
/* ============================================================
* world.js Lot: tiles, walls, object placement, pathfinding
* ============================================================ */
'use strict';
const ekey = (x, y, e) => `${x},${y},${e}`; // wall edge key ('n' | 'w')
const ckey = (x, y) => x + ',' + y;
const DIAGS = [{ dx: 1, dy: 1 }, { dx: -1, dy: 1 }, { dx: 1, dy: -1 }, { dx: -1, dy: -1 }];
class World {
constructor(w = LOT_W, h = LOT_H) {
this.w = w; this.h = h;
this.floor = new Array(w * h).fill(0); // index into FLOORS (0=grass)
this.walls = new Map(); // ekey -> {kind:'wall'|'door'|'window', color}
this.objects = []; // placed GameObjects
this.cellObj = new Map(); // ckey -> object
this.roomScore = new Array(w * h).fill(50); // environment score per tile
this.dirtPuddle = []; // fading puddles
this.mailbox = { x: Math.floor(w / 2), y: h - 1 }; // visual + bills flavor
}
inside(x, y) { return x >= 0 && y >= 0 && x < this.w && y < this.h; }
/* ---------------- walls ---------------- */
wallAt(x, y, e) { return this.walls.get(ekey(x, y, e)); }
/** Edge between two orthogonal neighbors */
sharedEdge(ax, ay, bx, by) {
if (bx === ax + 1) return { x: bx, y: by, e: 'w' };
if (bx === ax - 1) return { x: ax, y: ay, e: 'w' };
if (by === ay + 1) return { x: ax, y: by, e: 'n' };
if (by === ay - 1) return { x: ax, y: ay, e: 'n' };
return null;
}
edgeBlocked(ax, ay, bx, by) {
const ed = this.sharedEdge(ax, ay, bx, by);
if (!ed) return true;
const w = this.wallAt(ed.x, ed.y, ed.e);
return !!w && w.kind !== 'door';
}
placeWall(x, y, e, kind = 'wall', silent = false) {
if (!this.inside(x, y) && !(e === 'n' && y === this.h)) return false;
const cur = this.wallAt(x, y, e);
if (cur && cur.kind === kind && cur.color === G.wallColor) return false;
this.walls.set(ekey(x, y, e), { kind, color: G.wallColor });
if (!silent) Bus.emit('worldChanged');
return true;
}
removeWall(x, y, e) {
const k = ekey(x, y, e);
if (this.walls.has(k)) { this.walls.delete(k); Bus.emit('worldChanged'); return true; }
return false;
}
setFloor(x, y, fid) {
if (!this.inside(x, y)) return;
this.floor[y * this.w + x] = fid;
Bus.emit('worldChanged');
}
/* ---------------- objects ---------------- */
objCells(obj) {
const cells = [];
for (let dy = 0; dy < obj.h; dy++)
for (let dx = 0; dx < obj.w; dx++)
cells.push([obj.x + dx, obj.y + dy]);
return cells;
}
objAt(x, y) {
const o = this.cellObj.get(ckey(x, y));
return o || null;
}
canPlace(def, x, y, rot) {
const w = rot ? def.h : def.w, h = rot ? def.w : def.h;
for (let dy = 0; dy < h; dy++) for (let dx = 0; dx < w; dx++) {
const tx = x + dx, ty = y + dy;
if (!this.inside(tx, ty)) return false;
if (this.objAt(tx, ty)) return false;
// don't allow placing on a tile occupied by a sim body
for (const s of G.sims) {
if (!s.atHome) continue;
if (Math.round(s.x) === tx && Math.round(s.y) === ty) return false;
}
// wall objects must hug a wall edge behind them (mirror/painting)
if (def.wallObj) {
const hasWall = this.wallAt(tx, ty, 'n') || this.wallAt(tx - 1, ty, 'w') ||
this.wallAt(tx, ty + 1, 'n') || this.wallAt(tx + 1, ty, 'w');
if (!hasWall) return false;
}
}
return true;
}
placeObject(defId, x, y, rot = 0, opts = {}) {
const def = OBJECTS[defId];
if (!def) return null;
const obj = {
id: uid(), defId, x, y,
rot,
w: rot ? def.h : def.w,
h: rot ? def.w : def.h,
usedBy: null, // sim currently using
dirty: 0, // toilets / trash fill level 0..1
lightOn: false,
...opts,
};
this.objects.push(obj);
for (const [cx, cy] of this.objCells(obj)) this.cellObj.set(ckey(cx, cy), obj);
Bus.emit('worldChanged');
Bus.emit('objectsChanged');
return obj;
}
removeObject(obj) {
if (!obj) return;
this.objects = this.objects.filter(o => o !== obj);
for (const [cx, cy] of this.objCells(obj)) {
if (this.cellObj.get(ckey(cx, cy)) === obj) this.cellObj.delete(ckey(cx, cy));
}
if (obj.usedBy && obj.usedBy.action) obj.usedBy.cancelAction('Object sold');
Bus.emit('worldChanged');
Bus.emit('objectsChanged');
}
findFreeSpotNear(x, y, maxR = 8) {
for (let r = 1; r <= maxR; r++) {
const cands = [];
for (let dy = -r; dy <= r; dy++) for (let dx = -r; dx <= r; dx++) {
if (Math.max(Math.abs(dx), Math.abs(dy)) !== r) continue;
const tx = x + dx, ty = y + dy;
if (this.inside(tx, ty) && this.tileWalkable(tx, ty)) cands.push([tx, ty]);
}
if (cands.length) return choice(cands);
}
return null;
}
/** best standing tile adjacent to an object's footprint */
useSpotNear(obj, fromX, fromY) {
let best = null, bd = 1e9;
for (let dy = -1; dy <= obj.h; dy++) for (let dx = -1; dx <= obj.w; dx++) {
const onFootprint = dx >= 0 && dx < obj.w && dy >= 0 && dy < obj.h;
if (onFootprint) continue;
const tx = obj.x + dx, ty = obj.y + dy;
if (!this.inside(tx, ty) || !this.tileWalkable(tx, ty)) continue;
const d = dist2(tx, ty, fromX, fromY);
if (d < bd) { bd = d; best = [tx, ty]; }
}
return best;
}
findObjects(pred) { return this.objects.filter(pred); }
/* ---------------- walkability & pathfinding ---------------- */
tileWalkable(x, y) {
if (!this.inside(x, y)) return false;
if (this.objAt(x, y)) return false;
return true;
}
/** A* path from (sx,sy) to (tx,ty). 8-directional, no corner cutting.
* Returns array of [x,y] incl. endpoints, or null. */
findPath(sx, sy, tx, ty) {
sx = Math.round(sx); sy = Math.round(sy); tx = Math.round(tx); ty = Math.round(ty);
if (!this.inside(sx, sy)) return null;
if (!this.inside(tx, ty) || !this.tileWalkable(tx, ty)) {
const alt = this.findFreeSpotNear(tx, ty, 6);
if (!alt) return null;
tx = alt[0]; ty = alt[1];
}
if (sx === tx && sy === ty) return [[tx, ty]];
const open = [{ x: sx, y: sy, g: 0, f: Math.sqrt(dist2(sx, sy, tx, ty)), parent: null }];
const seen = new Map([[ckey(sx, sy), 0]]);
let goal = null, guard = 0;
while (open.length && guard++ < 9000) {
let bi = 0;
for (let i = 1; i < open.length; i++) if (open[i].f < open[bi].f) bi = i;
const n = open.splice(bi, 1)[0];
if (n.x === tx && n.y === ty) { goal = n; break; }
for (let di = 0; di < 8; di++) {
const diag = di >= 4;
const d = diag ? DIAGS[di - 4] : DIRS[di];
const nx = n.x + d.dx, ny = n.y + d.dy;
if (!this.inside(nx, ny) || !this.tileWalkable(nx, ny)) continue;
if (diag) {
// both orthogonal legs must be open (tiles + edges)
if (!this.tileWalkable(n.x + d.dx, n.y)) continue;
if (!this.tileWalkable(n.x, n.y + d.dy)) continue;
if (this.edgeBlocked(n.x, n.y, n.x + d.dx, n.y)) continue;
if (this.edgeBlocked(n.x, n.y, n.x, n.y + d.dy)) continue;
if (this.edgeBlocked(n.x + d.dx, n.y, nx, ny)) continue;
if (this.edgeBlocked(n.x, n.y + d.dy, nx, ny)) continue;
} else if (this.edgeBlocked(n.x, n.y, nx, ny)) continue;
const g = n.g + (diag ? 1.45 : 1);
const k = ckey(nx, ny);
if (seen.has(k) && seen.get(k) <= g) continue;
seen.set(k, g);
open.push({ x: nx, y: ny, g, f: g + Math.sqrt(dist2(nx, ny, tx, ty)), parent: n });
}
}
if (!goal) return null;
const path = [];
for (let n = goal; n; n = n.parent) path.unshift([n.x, n.y]);
return path;
}
/* ---------------- environment score ---------------- */
recomputeRoom() {
const W = this.w, H = this.h;
this.roomScore.fill(28); // bare-lot baseline
// floors & walls make rooms feel finished
for (let y = 0; y < H; y++) for (let x = 0; x < W; x++) {
let s = 24;
if (this.floor[y * W + x] > 0) s += 14;
if (this.wallAt(x, y, 'n')) s += 5;
if (this.wallAt(x, y, 'w')) s += 5;
this.roomScore[y * W + x] += s * 0.35;
}
// object auras
for (const o of this.objects) {
const def = OBJECTS[o.defId];
const env = def.env || 0;
if (!env) continue;
const cx = o.x + o.w / 2, cy = o.y + o.h / 2, R = 6;
for (let y = Math.max(0, Math.floor(cy - R)); y <= Math.min(H - 1, cy + R); y++)
for (let x = Math.max(0, Math.floor(cx - R)); x <= Math.min(W - 1, cx + R); x++) {
const d = Math.sqrt(dist2(x + .5, y + .5, cx, cy));
if (d > R) continue;
this.roomScore[y * W + x] += env * (1 - d / R) * 0.9;
}
}
// dirt stinks
for (const o of this.objects) {
if ((o.defId === 'trash' || o.defId === 'toilet') && o.dirty > 0.5) {
for (let dy = -3; dy <= 3; dy++) for (let dx = -3; dx <= 3; dx++) {
const x = o.x + dx, y = o.y + dy;
if (this.inside(x, y))
this.roomScore[y * W + x] -= (1 - Math.sqrt(dx * dx + dy * dy) / 4) * 22 * o.dirty;
}
}
}
// dirty dishes stink up the place too
const dTotal = (typeof dishTotal === 'function') ? dishTotal() : 0;
if (dTotal > 0) {
const penalty = Math.min(26, dTotal * 2.2);
for (const p of G.dishPiles) {
for (let dy = -3; dy <= 3; dy++) for (let dx = -3; dx <= 3; dx++) {
const x = p.x + dx, y = p.y + dy;
if (this.inside(x, y))
this.roomScore[y * W + x] -= penalty * Math.max(0, 1 - Math.sqrt(dx * dx + dy * dy) / 4) / Math.max(1, p.n);
}
}
}
for (let i = 0; i < this.roomScore.length; i++)
this.roomScore[i] = clamp(this.roomScore[i], 0, 100);
}
roomAt(x, y) {
x = clamp(Math.round(x), 0, this.w - 1); y = clamp(Math.round(y), 0, this.h - 1);
return this.roomScore[y * this.w + x];
}
serialize() {
return {
w: this.w, h: this.h,
floor: Array.from(this.floor),
walls: Array.from(this.walls.entries()),
roomScore: Array.from(this.roomScore),
objects: this.objects.map(o => ({ id:o.id, defId:o.defId, x:o.x, y:o.y, rot:o.rot, dirty:o.dirty, broken:!!o.broken, groceries:o.groceries||0 })),
mailbox: this.mailbox,
};
}
static deserialize(d) {
const wd = new World(d.w, d.h);
wd.floor = d.floor.slice();
wd.walls = new Map(d.walls);
wd.roomScore = d.roomScore ? d.roomScore.slice() : wd.roomScore;
wd.mailbox = d.mailbox;
for (const od of d.objects) {
const def = OBJECTS[od.defId]; if (!def) continue;
const o = wd.placeObject(od.defId, od.x, od.y, od.rot);
if (o) { o.dirty = od.dirty || 0; o.broken = !!od.broken; if (od.groceries) o.groceries = od.groceries; }
}
return wd;
}
}
/* ============================================================
* Starter house cozy 1-bed bungalow so play starts instantly
* ============================================================ */
function buildStarterHouse(world) {
const X0 = 11, Y0 = 10, Wd = 11, Ht = 9; // outer rect
// floors: wood main, tile bath/kitchen
for (let y = Y0; y < Y0 + Ht; y++)
for (let x = X0; x < X0 + Wd; x++)
world.setFloor(x, y, 1);
// walls — outer shell sits ON the boundary lines around tiles [X0..X0+Wd-1]×[Y0..Y0+Ht-1]
for (let x = X0; x < X0 + Wd; x++) {
world.placeWall(x, Y0, 'n', 'wall', true); // north (top)
world.placeWall(x, Y0 + Ht, 'n', 'wall', true); // south (bottom)
}
for (let y = Y0; y < Y0 + Ht; y++) {
world.placeWall(X0, y, 'w', 'wall', true); // west (left)
world.placeWall(X0 + Wd, y, 'w', 'wall', true); // east (right)
}
// interior walls: bath top-left (3x4), bedroom right side
const BX = X0, BY = Y0, BW = 3, BH = 4; // bathroom zone
for (let x = BX; x < BX + BW; x++) world.placeWall(x, BY + BH, 'n', 'wall', true);
for (let y = BY; y < BY + BH; y++) world.placeWall(BX + BW, y, 'w', 'wall', true);
const RX = X0 + 7; // bedroom divider
for (let y = Y0 + 4; y < Y0 + Ht; y++) world.placeWall(RX, y, 'w', 'wall', true);
// doors: front door south center, bath door, bedroom door
world.placeWall(X0 + 5, Y0 + Ht, 'n', 'door', true);
world.placeWall(BX + 1, BY + BH, 'n', 'door', true);
world.placeWall(RX, Y0 + 5, 'w', 'door', true);
// windows
for (const [wx, wy, we] of [[X0 + 3, Y0, 'n'], [X0 + 7, Y0, 'n'], [X0, Y0 + 6, 'w'], [X0 + Wd, Y0 + 2, 'w']])
world.placeWall(wx, wy, we, 'window', true);
const P = (id, x, y, rot = 0) => world.placeObject(id, x, y, rot);
// bathroom
P('toilet', X0, Y0); P('shower', X0 + 1, Y0); P('sink', X0 + 2, Y0);
P('mirror', X0 + 2, Y0 + 1);
// bedroom (cols 19..21)
P('bedDouble', X0 + 8, Y0 + 6); P('lamp', X0 + 8, Y0 + 4);
// kitchen along bottom, gaps at x=X0+3 and x=X0+6 keep lanes open
P('fridge', X0, Y0 + Ht - 1); P('stove', X0 + 1, Y0 + Ht - 1);
P('counter', X0 + 2, Y0 + Ht - 1);
P('trash', X0 + 4, Y0 + Ht - 1);
// dining
P('table', X0 + 4, Y0 + 6); P('chair', X0 + 3, Y0 + 6, 1); P('chair', X0 + 4, Y0 + 5);
// living room (west), TV tucked along south wall with open approach
P('tv', X0, Y0 + 7);
P('sofa', X0 + 2, Y0 + 7, 1);
P('coffeeTable', X0, Y0 + 5);
P('bookshelf', X0 + 6, Y0 + 1); P('phone', X0 + 3, Y0 + 1);
P('plant', X0 + 7, Y0 + 3); P('lamp', X0, Y0 + 4);
// outside decor
P('plant', X0 - 1, Y0 + Ht); P('plant', X0 + Wd, Y0 + Ht);
// paint bath + kitchen tile
for (let y = Y0; y < Y0 + 4; y++) for (let x = X0; x < X0 + 3; x++) world.setFloor(x, y, 2);
for (let x = X0; x <= X0 + 4; x++) world.setFloor(x, Y0 + Ht - 1, 2);
// mailbox by the front walk
world.mailbox = { x: X0 + 6, y: Y0 + Ht + 2 };
world.recomputeRoom();
}
+786
View File
@@ -0,0 +1,786 @@
/* Headless smoke-test harness: boots the whole game in a stubbed DOM,
* drives simulated days, exercises actions, careers, save/load. */
import fs from 'node:fs';
import vm from 'node:vm';
import path from 'node:path';
import url from 'node:url';
const dir = path.dirname(url.fileURLToPath(import.meta.url));
const root = path.join(dir, '..');
/* ---------------- DOM / Canvas stubs ---------------- */
function makeCtx() {
const grad = { addColorStop() {} };
const ctx = {
canvas: null,
createLinearGradient: () => grad,
createRadialGradient: () => grad,
measureText: () => ({ width: 10 }),
getImageData: () => ({ data: new Uint8ClampedArray(4) }),
createImageData: (w, h) => ({ data: new Uint8ClampedArray(w * h * 4), width: w, height: h }),
putImageData() {},
isPointInPath: () => false,
};
return new Proxy(ctx, {
get(t, p) {
if (p in t) return t[p];
return () => {};
},
set(t, p, v) { t[p] = v; return true; },
});
}
let elCount = 0;
function makeEl(tag = 'div', id = null) {
const listeners = {};
const childrenArr = [];
const el = {
_id: id || ('el' + elCount++),
tagName: (tag || 'div').toUpperCase(),
style: new Proxy({}, { get: () => '', set: () => true }),
dataset: {},
classList: {
_s: new Set(),
add(...c) { c.forEach(x => this._s.add(x)); },
remove(...c) { c.forEach(x => this._s.delete(x)); },
toggle(c, f) { (f === undefined ? !this._s.has(c) : f) ? this._s.add(c) : this._s.delete(c); },
contains(c) { return this._s.has(c); },
},
_children: childrenArr,
appendChild(c) { childrenArr.push(c); return c; },
removeChild(c) { const i = childrenArr.indexOf(c); if (i >= 0) childrenArr.splice(i, 1); return c; },
get children() { return childrenArr; },
get firstChild() { return childrenArr[0] || null; },
getBoundingClientRect: () => ({ left: 0, top: 0, width: 100, height: 40 }),
addEventListener(ev, fn) { (listeners[ev] ||= []).push(fn); },
removeEventListener() {},
dispatch(ev, arg) { (listeners[ev] || []).forEach(f => f(arg)); },
getContext: () => { const c = makeCtx(); return c; },
width: 300, height: 150,
innerHTML: '', textContent: '',
onclick: null, oninput: null,
querySelector: () => makeEl(),
querySelectorAll: () => [],
focus() {}, click() { if (el.onclick) el.onclick(); },
remove() {},
value: '',
title: '',
};
Object.defineProperty(el, 'id', { value: id, writable: true });
return el;
}
const byId = new Map();
const documentStub = {
getElementById(id) {
if (!byId.has(id)) byId.set(id, makeEl('div', id));
return byId.get(id);
},
createElement(tag) { return makeEl(tag); },
querySelectorAll: () => [],
querySelector: () => makeEl(),
body: makeEl('body'),
addEventListener() {},
};
const storage = new Map();
let rafCb = null;
const sandbox = {
console,
performance: { now: () => Date.now() },
requestAnimationFrame: (cb) => { rafCb = cb; return 1; },
localStorage: {
getItem: (k) => (storage.has(k) ? storage.get(k) : null),
setItem: (k, v) => storage.set(k, String(v)),
removeItem: (k) => storage.delete(k),
},
setTimeout: (fn) => 0, clearTimeout() {},
setInterval: () => 0, clearInterval() {},
addEventListener() {}, removeEventListener() {},
innerWidth: 1280, innerHeight: 800,
devicePixelRatio: 1,
location: { reload() {} },
};
sandbox.window = sandbox;
sandbox.document = documentStub;
sandbox.globalThis = sandbox;
vm.createContext(sandbox);
for (const f of ['core.js','audio.js','data.js','world.js','sims.js','ai.js','render.js','ui.js','hood.js','main.js']) {
const code = fs.readFileSync(path.join(root, 'js', f), 'utf8');
try {
vm.runInContext(code, sandbox, { filename: f });
} catch (e) {
console.error(`❌ BOOT FAILED in ${f}:`, e.stack);
process.exit(1);
}
}
console.log('✅ all scripts loaded');
const G = sandbox.G;
const run = (expr) => vm.runInContext(expr, sandbox);
/* ---------------- scenario ---------------- */
function step(label, expr) {
try {
const out = run(expr);
console.log('✅', label);
return out;
} catch (e) {
console.error('❌', label, '→', e.stack);
process.exit(1);
}
}
step('start new game with 2 sims',
`startNewGame([
{name:'Bella Goth', gender:'f', skin:0, hairStyle:1, hairColor:0, shirt:4, pants:0,
traits:{neat:6,outgoing:7,active:5,playful:6,nice:8}, aspiration:'fortune'},
{name:'Mortimer Goth', gender:'m', skin:0, hairStyle:0, hairColor:6, shirt:8, pants:1,
traits:{neat:4,outgoing:3,active:3,playful:5,nice:6}, aspiration:'knowledge'},
]); G.nextBillDay = 99999; G.disableFires = true; G.disableDeaths = true; G.sims.length`); // freeze bills/fires/deaths except dedicated tests
step('world has starter furniture', `G.world.objects.length`);
step('pathfinding through front door works',
`(function(){
const p = G.world.findPath(16,21,12,12);
if(!p) throw new Error('no path from yard to bathroom');
if(p.length < 5) throw new Error('path suspiciously short: '+p.length);
return p.length + ' tiles';
})()`);
// fast-forward 2 full days at high chunk size
step('simulate 2 days of autonomous life', `
(function(){
let issues = [];
for (let i=0;i<2880;i+=10) { // 2 days in 10-min steps
advanceTime(10);
if (!isFinite(G.funds)) issues.push('funds NaN');
for (const s of G.sims) {
if (!isFinite(s.needs.hunger)) issues.push(s.name+' hunger NaN');
if (isNaN(s.x)||isNaN(s.y)) issues.push(s.name+' position NaN');
}
if (issues.length) break;
}
if (issues.length) throw new Error(issues.join('; '));
return 'day='+G.time.day+' hunger0='+Math.round(G.sims[0].needs.hunger)+' energy0='+Math.round(G.sims[0].needs.energy);
})()`);
step('hire both sims onto career tracks', `
(function(){
CareerSys.hire(G.sims[0],'business');
CareerSys.hire(G.sims[1],'science');
return [G.sims[0].job.track, G.sims[1].job.track];
})()`);
step('work a full day incl. carpool commute & pay', `
(function(){
// jump to Monday 8:55 (business shift starts 9)
const dayStart = Math.floor((G.time.absMin)/1440)*1440;
G.time.absMin = dayStart + 9*60 - 5;
const f0 = G.funds;
for (let i=0;i<80;i++) advanceTime(15); // ~20h
const earned = G.funds - f0;
if (earned <= 0) throw new Error('no salary received: '+earned);
if (!G.sims[0].atHome) throw new Error('sim stuck away');
return 'earned §'+earned+' perf='+Math.round(G.sims[0].job.perf);
})()`);
step('direct commands: cook meal chain completes', `
(function(){
const s = G.sims[0];
G.freeWill = false;
// make sure appliances are serviceable (a stray earlier fire could have charred one)
for (const o of G.world.objects) if (['fridge','stove'].includes(o.defId)) o.broken = false;
// park time at 3am so no carpool interrupts
const dayStart = Math.floor((G.time.absMin)/1440)*1440;
G.time.absMin = dayStart + 3*60;
for (const q of G.sims){ if(q.atWork) CareerSys.arrive(q, CareerSys.todayInfo(q)); q.atHome=true; q.atWork=false; }
s.needs.hunger = 20; s.needs.bladder = 80; s.needs.energy = 90; s.needs.hygiene = 70;
const fr = G.world.findObjects(o=>o.defId==='fridge')[0];
s.x = fr.x + 1; s.y = fr.y - 1; s.path=[]; if (s.action) s.cancelAction();
commandUse(s, fr, OBJECTS.fridge.interactions[0]);
let ate=false;
for (let i=0;i<140 && !ate;i++){ advanceTime(2); ate = !s.action && s.needs.hunger>50; }
if (!ate) throw new Error('meal did not complete; action='+(s.action&&s.action.label)+' hunger='+Math.round(s.needs.hunger)+' atHome='+s.atHome+' lastCancel='+s.lastCancelMsg);
return 'hunger now '+Math.round(s.needs.hunger);
})()`);
step('sleep action restores energy', `
(function(){
const s = G.sims[1];
G.freeWill = false;
// park everyone home at 21:00 so no carpool interrupts the night
const dayStart = Math.floor((G.time.absMin)/1440)*1440;
G.time.absMin = dayStart + 21*60;
for (const q of G.sims){
if (q.atWork) CareerSys.arrive(q, CareerSys.todayInfo(q));
q.atHome = true; q.atWork = false;
if (q !== s && q.action) q.cancelAction('test reset');
if (q !== s) { q.needs.bladder = 70; q.needs.hunger = 80; }
}
s.needs.energy = 15; s.needs.bladder = 85; s.needs.hunger = 70;
const bed = G.world.findObjects(o => OBJECTS[o.defId].sleep && !o.usedBy)[0]
|| G.world.findObjects(o => OBJECTS[o.defId].sleep)[0];
if (s.action) s.cancelAction();
commandUse(s, bed, OBJECTS.bedSingle.interactions[0]);
for (let i=0;i<260;i++){ advanceTime(2); if (s.needs.energy>90) break; }
if (s.needs.energy < 90) throw new Error('still tired: '+s.needs.energy);
return 'energy '+Math.round(s.needs.energy);
})()`);
step('social interaction raises relationship', `
(function(){
const a=G.sims[0], b=G.sims[1];
const ra=a.getRel(b), rb=b.getRel(a);
ra.str=10; ra.ltr=10; rb.str=10; rb.ltr=10;
a.x=b.x+1; a.y=b.y; a.path=[];
if(a.action) a.cancelAction(); if(b.action) b.cancelAction();
AI.startSocial(a,b,SOCIALS.find(x=>x.id==='talk'));
for(let i=0;i<60;i++){ advanceTime(2); }
const relAfter = a.getRel(b).str;
if (relAfter <= 10) throw new Error('rel did not rise: '+relAfter);
return 'str 10 -> '+Math.round(relAfter);
})()`);
step('visitor invite spawns NPC who leaves', `
(function(){
spawnVisitor();
const v = G.sims.find(s=>s.isVisitor);
if(!v) throw new Error('no visitor spawned');
v.leaveAtMin = G.time.absMin + 5;
for(let i=0;i<200;i++){ advanceTime(2); if(!G.sims.includes(v)) break; }
if (G.sims.includes(v)) throw new Error('visitor never left');
return 'visitor came & went';
})()`);
step('whims fulfil and grant aspiration points', `
(function(){
const s = G.sims[0];
if (!s.wants || !s.wants.length) throw new Error('no whims rolled on move-in');
const pts0 = G.aspirationPoints;
const origTpl = WHIMS.fortune.find(w => w.id === 'promote');
s.wants = [{ tpl: origTpl, progress: 0, bank: 0 }];
const wantObj = s.wants[0];
if (!s.job) CareerSys.hire(s, 'business'); // may have been fired in earlier sims
CareerSys.promote(s, CareerSys.todayInfo(s));
if (G.aspirationPoints <= pts0) throw new Error('aspiration points not granted');
if (s.wants.includes(wantObj)) throw new Error('fulfilled whim not replaced');
return 'asp pts ' + pts0 + ' -> ' + G.aspirationPoints;
})()`);
step('fragile objects break and get repaired', `
(function(){
const s = G.sims[0];
const tv = G.world.findObjects(o=>o.defId==='tv')[0];
if (!tv) throw new Error('no tv in starter house');
tv.broken = true;
// normal use is refused
let refused = false;
try { commandUse(s, tv, OBJECTS.tv.interactions[0]); refused = s.action === null || s.action === undefined || !s.action; } catch(e){ refused = true; }
if (!refused) throw new Error('broken tv still usable');
// repair it
s.skills.mechanical = 8;
if (s.action) s.cancelAction();
const spot = G.world.useSpotNear(tv, s.x, s.y);
if (spot) { const p = G.world.findPath(s.x, s.y, spot[0], spot[1]); if (p) { s.path = []; s.x = spot[0]; s.y = spot[1]; } }
commandUse(s, tv, { id:'repair', label:'Repair', icon:'🔧', special:'repair', pose:'stand', dur:10 });
for (let i=0;i<300 && tv.broken;i++) advanceTime(2);
if (tv.broken) throw new Error('repair never completed');
return 'tv repaired by '+s.name;
})()`);
step('dirty dishes accumulate and wash clean', `
(function(){
const sink = G.world.findObjects(o=>o.defId==='sink')[0];
G.dishPiles = []; // start from a clean slate
const before = 0;
addDishPile(sink.x+1, sink.y); addDishPile(sink.x+1, sink.y); addDishPile(sink.x+2, sink.y);
if (dishTotal() !== 3) throw new Error('piles not accumulating');
G.freeWill = false;
const s = G.sims[0];
// park everyone home late evening so no carpool interrupts
const dayStart = Math.floor((G.time.absMin)/1440)*1440;
G.time.absMin = dayStart + 21*60;
for (const q of G.sims) {
q.needs.hunger = 95; // nobody cooks mid-test
q.queue = []; // and no stale queued meals either
G.pendingPizza = 0;
if (q.atWork) CareerSys.arrive(q, CareerSys.todayInfo(q));
q.atHome = true; q.atWork = false;
if (q !== s && q.action) q.cancelAction('test reset');
}
if (s.action) s.cancelAction();
s.x = sink.x+1; s.y = sink.y+1; s.path=[];
commandUse(s, sink, { id:'wash', label:'Wash Dishes', icon:'🧼', special:'wash', pose:'stand', dur:40 });
for (let i=0;i<200 && dishTotal()>0;i++) advanceTime(2);
if (dishTotal() > 0) throw new Error('dishes never washed: '+dishTotal()+
' act='+(s.action ? s.action.label+'/'+s.action.phase+' t='+Math.round(s.action.t) : 'IDLE')+
' atHome='+s.atHome+' pos='+Math.round(s.x)+','+Math.round(s.y)+' piles='+JSON.stringify(G.dishPiles));
return 'all dishes washed';
})()`);
step('askToMoveIn converts visitor to household', `
(function(){
spawnVisitor();
const v = G.sims.find(x=>x.isVisitor);
if (!v) throw new Error('no visitor');
const a = G.sims.find(x=>!x.isVisitor);
a.getRel(v).ltr = 80;
v.leaveAtMin = G.time.absMin + 99999; // don't let them leave mid-test
const nBefore = G.sims.length;
if (!askToMoveIn(a, v)) throw new Error('move-in rejected');
if (v.isVisitor) throw new Error('still flagged visitor');
if (G.sims.length !== nBefore) throw new Error('household size changed unexpectedly');
return v.name+' joined the household';
})()`);
step('weather rolls and rain renders', `
(function(){
// roll weather directly — do NOT call onNewDay (it ages the household!)
let sawSunny=false, sawCloudy=false, sawRain=false;
for (let i=0;i<60;i++){
const r=Math.random();
G.weather.type = r < .5 ? 'sunny' : r < .8 ? 'cloudy' : 'rain';
if (G.weather.type==='sunny') sawSunny=true;
if (G.weather.type==='cloudy') sawCloudy=true;
if (G.weather.type==='rain') sawRain=true;
}
if (!sawSunny || !sawCloudy || !sawRain) throw new Error('weather roll never varied');
G.weather.type='rain'; G.weather.flash=.5;
draw();
G.weather.type='cloudy'; draw();
G.weather.type='sunny'; draw();
return 'weather cycled without render errors';
})()`);
step('CAS naming: gender-appropriate pools & custom-name flag', `
(function(){
for (let i=0;i<40;i++){
const f = randomSimData('f');
if (!FIRST_NAMES_F.includes(f.name.split(' ')[0])) throw new Error('female got non-female first name: '+f.name);
const m = randomSimData('m');
if (!FIRST_NAMES_M.includes(m.name.split(' ')[0])) throw new Error('male got non-male first name: '+m.name);
if (f.nameCustom !== false) throw new Error('nameCustom should default false');
}
return 'pools verified';
})()`);
step('AI Mode: sim autonomously pursues whim-driven goal', `
(function(){
G.freeWill = true; // AI MODE ON
const s = G.sims.find(x=>!x.isVisitor && x.ageStage==='adult');
for (const vv of G.sims.filter(x=>x.isVisitor)) G.removeSim(vv);
const ap0 = G.aspirationPoints;
let fulfilled = false;
outer:
for (let attempt=0; attempt<4 && !fulfilled; attempt++) {
// comfortable body, hungry-for-purpose mind
s.needs.hunger=88; s.needs.energy=90; s.needs.bladder=90; s.needs.hygiene=80; s.needs.fun=75; s.needs.social=75; s.needs.comfort=75;
s.wants = [{ tpl:{ id:'meal', icon:'🍲', label:'Cook a nice meal', ev:'meal', reward:60 }, progress:0, bank:0 }];
const fr = G.world.findObjects(o=>o.defId==='fridge')[0];
fr.broken=false; fr.groceries=3; if (fr.usedBy) fr.usedBy=null;
for (const o of G.world.objects) if (o.defId==='stove'){ o.broken=false; if(o.usedBy&&o.usedBy!==s)o.usedBy=null; }
for (const q of G.sims) if (q!==s && q.action) q.cancelAction();
if (s.action) s.cancelAction();
s.path=[];
for (let i=0;i<170;i++){
advanceTime(2);
// WantSys refills instantly after fulfilment, so detect by reward
if (G.aspirationPoints > ap0) { fulfilled=true; break outer; }
if (!G.sims.includes(s)) throw new Error('sim vanished');
}
}
if (!fulfilled) throw new Error('whim never pursued: wants='+JSON.stringify(s.wants.map(w=>w.tpl.id)));
return 'AI cooked to fulfill its own whim (+'+(G.aspirationPoints-ap0)+' AP)';
})()`);
step('try for baby → pregnancy → birth → growing child', `
(function(){
const a = G.sims[0], b = G.sims[1];
for (const q of G.sims){ if (q.atWork) CareerSys.arrive(q, CareerSys.todayInfo(q)); q.atHome=true; q.atWork=false; }
if (!a.job) CareerSys.hire(a,'business');
a.marriedTo = b.id; b.marriedTo = a.id;
a.getRel(b).ltr = 95; b.getRel(a).ltr = 95;
if (!canTryForBaby(a)) throw new Error('eligibility failed');
const bed = G.world.findObjects(o=>o.defId==='bedDouble')[0];
G.freeWill = false;
if (a.action) a.cancelAction();
commandUse(a, bed, OBJECTS.bedDouble.interactions.find(i=>i.id==='tryBaby'));
for (let i=0;i<80 && a.action;i++) advanceTime(2);
// force-conceive to keep the test deterministic
let mother = [a,b].find(p=>p.gender==='f');
mother.pregnantUntil = G.time.absMin + 60;
const nBefore = G.sims.length;
advanceTime(61);
const baby = G.sims[G.sims.length-1];
if (G.sims.length !== nBefore+1) throw new Error('no baby born');
if (baby.ageStage !== 'baby') throw new Error('new sim not a baby');
// feed the baby
baby.needs.hunger = 20;
if (a.action) a.cancelAction();
a.x = baby.x+1; a.y = baby.y; a.path=[];
commandUse(a, { defId:'baby', x:baby.x, y:baby.y, w:1,h:1, usedBy:null, simRef:baby },
{ id:'feedBaby', label:'Feed Baby', icon:'🍼', special:'feedBaby', pose:'stand', dur:10 });
for (let i=0;i<40 && baby.needs.hunger<60;i++) advanceTime(2);
if (baby.needs.hunger < 60) throw new Error('baby not fed: '+Math.round(baby.needs.hunger));
// grow the baby up
baby.stageSince = G.time.absMin - 4*1440 - 1;
advanceTime(3);
if (baby.ageStage !== 'child') throw new Error('baby did not become child');
return baby.name+' fed & now a child (household '+G.sims.length+')';
})()`);
step('children attend school and come home', `
(function(){
let kid = G.sims.find(s => s.ageStage === 'child' && !s.isVisitor); // residents only!
if (!kid) {
const baby = G.sims.find(s => s.ageStage === 'baby' && !s.isVisitor);
if (baby) { baby.ageStage = 'child'; baby.stageSince = G.time.absMin; kid = baby; }
}
if (!kid) throw new Error('no child in household');
G.freeWill = false;
// ensure a WEEKDAY (school doesn't run on weekends)
while (((Math.floor(G.time.absMin/1440)) % 7) >= 5) G.time.absMin += 1440;
const dayStart = Math.floor((G.time.absMin)/1440)*1440;
G.time.absMin = dayStart + 8*60 - 2; // just before school
kid.atHome = true; kid.atSchool = false; kid.workDepartedSchool = false;
kid.x = 16; kid.y = 19; kid.path=[];
let sawBus=false, sawHome=false;
for (let attempt2=0; attempt2<3 && !(sawBus&&sawHome); attempt2++){
// jump to next weekday pre-dawn
do { G.time.absMin += 1440; } while (((Math.floor(G.time.absMin/1440)) % 7) >= 5);
const ds2 = Math.floor(G.time.absMin/1440)*1440;
G.time.absMin = ds2 + 7*60 + 58;
kid.atHome = true; kid.atSchool = false; kid.workDepartedSchool = false;
kid.x = 16; kid.y = 19; kid.path = [];
sawBus = false; sawHome = false;
for (let i=0;i<90;i++){
advanceTime(6);
if (kid.atSchool) sawBus = true;
if (sawBus && !kid.atSchool && kid.atHome) { sawHome = true; break; }
}
}
if (!sawBus) throw new Error('child never left for school');
if (!sawHome) throw new Error('child never came home');
return 'grade '+((kid.schoolPerf>80?'A':kid.schoolPerf>60?'B':'C'))+' day complete';
})()`);
step('kitchen fires ignite, spread risk, extinguish & burn out', `
(function(){
G.freeWill = true;
G.disableFires = false;
const stove = G.world.findObjects(o=>o.defId==='stove')[0];
igniteFire(stove.x, stove.y, stove);
if (!G.fires.length) throw new Error('fire did not ignite');
if (!stove.broken) throw new Error('stove not broken by fire');
const s = G.sims[0];
for (const q of G.sims){ q.atHome=true; q.atWork=false; q.atSchool=false; }
// let sims react (flee / extinguish autonomy)
for (let i=0;i<400 && G.fires.length;i++) advanceTime(2);
if (G.fires.length) throw new Error('fire never went out');
const scorches = G.world.dirtPuddle.filter(p=>p.kind==='scorch');
G.disableFires = true;
return 'fire out after '+scorches.length+' scorch mark(s)';
})()`);
step('neighborhood generated with AI households', `
(function(){
if (!G.neighborhood || G.neighborhood.lots.length !== 6) throw new Error('no neighborhood');
for (const lot of G.neighborhood.lots) {
if (!lot.family.length) throw new Error('empty household '+lot.name);
for (const m of lot.family) if (!m.name.includes(lot.name)) throw new Error('surname mismatch');
}
return G.neighborhood.lots.length+' households, '+
G.neighborhood.lots.reduce((s,l)=>s+l.family.length,0)+' neighbors';
})()`);
step('neighbors visit, socialize & remember', `
(function(){
const lot = G.neighborhood.lots[0];
const meta = lot.family[0];
if (G.sims.filter(s=>s.isVisitor).length) { for(const v of G.sims.filter(s=>s.isVisitor)) G.removeSim(v); }
const v = spawnVisitor(meta);
if (!v || !v.isVisitor || v.hoodMeta !== meta) throw new Error('meta visitor not spawned');
// chat with a resident (up to 3 tries — social outcomes have RNG)
const host = G.sims.find(s => !s.isVisitor);
const seededLtr = v.rels.get(host.id).ltr;
v.x = host.x + 1; v.y = host.y; v.path = []; host.path = [];
for (let c = 0; c < 3; c++) {
AI.startSocial(host, v, SOCIALS[0]);
for (let i = 0; i < 60 && (host.action || v.action); i++) advanceTime(2);
}
const liveLtr = v.rels.get(host.id).ltr;
// visitor leaves → memory synced back into the household record
v.leaveAtMin = G.time.absMin;
for (let i = 0; i < 80 && G.sims.includes(v); i++) advanceTime(2);
if (G.sims.includes(v)) throw new Error('visitor never left');
const mem = meta.rel[host.id];
if (!mem) throw new Error('no memory written');
if (Math.abs(mem.ltr - liveLtr) > 3) throw new Error('memory not synced: '+mem.ltr+' vs '+liveLtr);
if (Math.abs(seededLtr - liveLtr) < 0.001 && Math.abs(liveLtr - seededLtr) === 0) { /* identical ok */ }
return 'memory ltr='+mem.ltr+' (live '+liveLtr+', seeded '+seededLtr+')';
})()`);
step('invite neighbor over from map card', `
(function(){
const lot = G.neighborhood.lots[1];
for (const vv of G.sims.filter(s=>s.isVisitor)) G.removeSim(vv); // clear stray strollers
const meta = lot.family.find(m => !m.movedIn && !G.sims.some(s=>s.hoodMeta===m));
inviteHoodMember(meta);
const here = G.sims.some(s => s.hoodMeta === meta && s.isVisitor);
if (!here) throw new Error('invited neighbor absent');
if (G.mode !== 'live') throw new Error('invite should return to live mode');
return 'invited '+meta.name;
})()`);
step('hood view renders, selects lots and closes', `
(function(){
enterHood();
drawHood();
if (!G.hoodRects.length) throw new Error('no lot rects');
const r = G.hoodRects[1];
hoodClick(r.x + r.w/2, r.y + r.h/2);
if (G.hoodSel !== r.lot.id) throw new Error('lot not selected');
drawHood();
if (!G.hoodBtns.length) throw new Error('card buttons missing');
hoodClick(G.hoodHomeBtn.x + 5, G.hoodHomeBtn.y + 5);
if (G.mode !== 'live') throw new Error('home button did not exit');
return 'map OK ('+G.hoodRects.length+' lots clickable)';
})()`);
step('groceries stock & meal tiers (gourmet requires skill + stock)', `
(function(){
const s = G.sims[0];
const fr = G.world.findObjects(o=>o.defId==='fridge')[0];
for (const vv of G.sims.filter(x=>x.isVisitor)) G.removeSim(vv);
G.freeWill = false;
const dsM = Math.floor(G.time.absMin/1440)*1440; G.time.absMin = dsM + 3*60;
for (const q of G.sims){ if(q.atWork) CareerSys.arrive(q,CareerSys.todayInfo(q)); q.atHome=true; q.atWork=false; q.atSchool=false; }
for (const o of G.world.objects) if (['fridge','stove'].includes(o.defId)) { o.broken = false; if (o.usedBy && o.usedBy.isVisitor) o.usedBy = null; }
fr.groceries = 4;
s.skills.cooking = 6;
s.needs.hunger = 15; s.needs.bladder = 80; s.needs.energy = 90;
for (const q of G.sims) if (q.action) q.cancelAction();
s.path = [];
commandUse(s, fr, OBJECTS.fridge.interactions.find(i=>i.id==='meal'));
let ate=false; const dbg=[];
for (let i=0;i<160 && !ate;i++){
advanceTime(2); ate=!s.action && s.needs.hunger>55;
if(i%12===0) dbg.push(i+':'+(s.action?s.action.label+'/'+s.action.phase+'/t'+Math.round(s.action.t):'IDLE')+'/h'+Math.round(s.needs.hunger)+'/fu'+(fr.usedBy?'Y':'N')+'/st'+G.world.findObjects(o=>o.defId==='stove'&&!o.usedBy).length);
}
if (!ate) throw new Error('gourmet fail @'+[Math.round(s.x),Math.round(s.y)]+' fr@'+[fr.x,fr.y]+' w1217='+G.world.tileWalkable(12,17)+' objAt1217='+(G.world.objAt?((G.world.objAt(12,17)||{}).defId||'-'):'-')+' | '+dbg.slice(-6).join(' | ')+' cancel='+s.lastCancelMsg);
if ((fr.groceries||0) >= 4) throw new Error('groceries not consumed: gro='+(fr.groceries||0)+' skill='+s.skills.cooking+' mem='+JSON.stringify((s.memories[0]||{})));
return 'ate gourmet, fridge now '+(fr.groceries??0)+'/8';
})()`);
step('order groceries delivery restocks fridge', `
(function(){
const fr = G.world.findObjects(o=>o.defId==='fridge')[0];
fr.groceries = 0;
const f0 = G.funds;
const s = G.sims[0];
G.freeWill = false;
const ds = Math.floor(G.time.absMin/1440)*1440; G.time.absMin = ds + 3*60;
for (const q of G.sims){ if(q.atWork) CareerSys.arrive(q,CareerSys.todayInfo(q)); q.atHome=true; q.atWork=false; q.atSchool=false; }
if (s.action) s.cancelAction();
commandUse(s, fr, OBJECTS.fridge.interactions.find(i=>i.id==='groceries'));
for (let i=0;i<40 && !G.pendingGroceries;i++) advanceTime(2);
if (!G.pendingGroceries) throw new Error('order not placed');
G.time.absMin = G.pendingGroceries + 1;
worldUpkeep(1);
if ((fr.groceries||0) !== 8) throw new Error('fridge not restocked: '+fr.groceries);
if (G.funds !== f0 - 60) throw new Error('not charged §60');
return 'fridge restocked to 8/8';
})()`);
step('write novel chapters and publish', `
(function(){
const s = G.sims[0]; const pc = G.world.findObjects(o=>o.defId==='computer')[0];
for (const vv of G.sims.filter(x=>x.isVisitor)) G.removeSim(vv);
G.freeWill = false;
const ds = Math.floor(G.time.absMin/1440)*1440; G.time.absMin = ds + 3*60;
for (const q of G.sims){ if(q.atWork) CareerSys.arrive(q,CareerSys.todayInfo(q)); q.atHome=true; q.atWork=false; q.atSchool=false; }
s.skills.creativity = 8; s.novelChapters = 9; s.needs.bladder=80; s.needs.energy=90; s.needs.hunger=70;
for (const q of G.sims) if (q.action) q.cancelAction();
if (s.action) s.cancelAction();
const f0 = G.funds;
commandUse(s, pc, OBJECTS.computer.interactions.find(i=>i.id==='write'));
let done=false;
for (let i=0;i<120 && !done;i++){ advanceTime(2); done=!s.action; }
if (!done) throw new Error('writing session never finished');
if (s.novelChapters !== 0) throw new Error('novel not published: ch='+s.novelChapters);
if (G.funds <= f0) throw new Error('no royalties received');
return 'novel published, earned royalties';
})()`);
step('paint a canvas and sell it at the easel', `
(function(){
const s = G.sims[0];
// quiet the lot first: no strollers, no free will, dead of night
for (const vv of G.sims.filter(x=>x.isVisitor)) G.removeSim(vv);
G.freeWill = false;
const ds3 = Math.floor(G.time.absMin/1440)*1440; G.time.absMin = ds3 + 3*60;
for (const q of G.sims){ if(q.atWork) CareerSys.arrive(q,CareerSys.todayInfo(q)); q.atHome=true; q.atWork=false; q.atSchool=false; }
const es = G.world.findObjects(o=>o.defId==='easel')[0] ||
(()=>{ const spot=G.world.findFreeSpotNear(16,20,6); return spot ? G.world.placeObject('easel',spot[0],spot[1]) : null; })();
if (!es) throw new Error('could not place easel');
for (const q of G.sims) if (q.action) q.cancelAction();
// teleport onto the easel's own use-spot so routing is trivially clear
const espot = G.world.useSpotNear(es, s.x, s.y);
if (!espot) throw new Error('easel has no usable spot');
s.x = espot[0]; s.y = espot[1]; s.path = [];
s.paintings = [];
s.needs.hunger = 70; s.needs.energy = 90; s.needs.bladder = 80;
commandUse(s, es, OBJECTS.easel.interactions[0]); // paint
let painted=false;
for (let i=0;i<140 && !painted;i++){ advanceTime(2); painted=!s.action && s.paintings.length===1; }
if (!painted) throw new Error('painting never finished: act='+(s.action?s.action.label+'/'+s.action.phase+' t='+Math.round(s.action.t):'IDLE')+' cancel='+s.lastCancelMsg+' paintings='+s.paintings.length);
const worth = s.paintings[0];
if (worth < 40) throw new Error('painting worthless');
if (s.action) s.cancelAction();
const f0 = G.funds;
commandUse(s, es, OBJECTS.easel.interactions[1]); // sell
for (let i=0;i<30 && s.action;i++) advanceTime(2);
if (G.funds !== f0 + worth) throw new Error('sale mismatch');
if (s.paintings.length !== 0) throw new Error('inventory not cleared');
return 'painted §'+worth+' canvas and sold it';
})()`);
step('career chance card outcome applies', `
(function(){
const s = G.sims.find(x=>x.job) || G.sims[0];
const f0 = G.funds;
applyChanceFx(s, { money:200, perf:5 });
if (G.funds !== f0+200) throw new Error('chance money fx failed');
applyChanceFx(s, { dice:.999, win:{ perf:10 }, lose:{ perf:-10 } });
return 'chance card fx OK';
})()`);
step('house party schedules, guests arrive & is scored', `
(function(){
G.funds = Math.max(G.funds, 500);
const host = G.sims.find(s=>!s.isVisitor);
for (const vv of G.sims.filter(s=>s.isVisitor)) G.removeSim(vv);
PartySys.schedule(host);
if (!G.party || G.party.state!=='planned') throw new Error('party not planned');
G.party.at = G.time.absMin + 1; // start now
const ap0 = G.aspirationPoints;
PartySys.tick(); // guests arrive
if (G.party.state !== 'live') throw new Error('party not live');
if (!G.sims.some(s=>s.isVisitor && s.forceAutonomy)) throw new Error('no guests arrived');
// guests have fun
for (const g of G.sims.filter(s=>s.isVisitor)) { g.needs.fun = 90; g.needs.social = 90; }
G.party.end = G.time.absMin + 1;
advanceTime(2);
PartySys.tick(); // concludes
if (G.party) {
const p=G.party;
throw new Error('party did not conclude: state='+p.state+' end-'+Math.round(G.time.absMin-p.end)+' guests='+G.sims.filter(s=>s.isVisitor&&s.forceAutonomy).length);
}
if (G.aspirationPoints <= ap0) throw new Error('no reward given');
return 'party scored & rewarded (+'+(G.aspirationPoints-ap0)+' AP)';
})()`);
step('death leaves grave & ghost haunts at night', `
(function(){
const t = randomSimData('m'); t.name = 'Uncle Victor';
const victim = new Sim({ ...t });
victim.x = 18; victim.y = 12; victim.atHome = true;
G.addSim(victim);
const gravesBefore = (G.graves||[]).length;
const famBefore = G.sims.length;
G.disableDeaths = false; // this test needs a real death
dieOf(victim, 'electrocution');
if (G.sims.includes(victim)) throw new Error('victim still on lot');
if (G.sims.length !== famBefore-1) throw new Error('household size wrong');
if ((G.graves||[]).length !== gravesBefore+1) throw new Error('grave not registered');
if (!G.sims.some(s => (s.memories||[]).some(m => m.text.includes('electrocuted'))))
throw new Error('family lacks grief memory');
// night of haunting
G.graves[G.graves.length-1].ghostedDay = -1;
const ds = Math.floor(G.time.absMin/1440)*1440;
G.time.absMin = ds + 60*2; // 2 am
let rose = false;
for (let i=0;i<40 && !rose;i++){ ghostTick(10); rose = (G.ghosts||[]).length > 0; }
if (!rose) throw new Error('ghost never rose');
draw(); // renders translucent ghost
G.time.absMin = ds + 60*5; // 5 am
ghostTick(1);
if ((G.ghosts||[]).length) throw new Error('ghost stayed past dawn');
G.disableDeaths = true;
return 'Victor rests… uneasily';
})()`);
step('bills arrive and can be paid', `
(function(){
sendBills();
if (!G.mailBillsDue) throw new Error('no bill due');
const f0=G.funds, amt=G.billsAmount;
if (f0 < amt) G.funds += amt; // ensure payable
document.getElementById('payBillsBtn').onclick();
if (G.mailBillsDue || !G.billsPaid) throw new Error('payment failed');
return 'paid §'+amt;
})()`);
step('buy placement + sell refund', `
(function(){
const f0=G.funds;
G.buySel='tv'; G.buyRot=0;
const spot=(function(){for(let y=0;y<30;y++)for(let x=0;x<30;x++){if(G.world.canPlace(OBJECTS.tv,x,y,0)&&G.world.useSpotNear({x,y,w:2,h:2},x+2,y+2))return[x,y];}return null;})();
if(!spot) throw new Error('no free spot for tv');
G.mouseTile=spot;
tryPlaceBuy();
if (G.funds !== f0-800) throw new Error('placement charge wrong: '+G.funds+' vs '+f0);
const tv=G.world.objAt(spot[0],spot[1]);
if(!tv) throw new Error('tv not placed');
G.funds -= 0;
// sell it
const refund=Math.round(800*0.7);
G.world.removeObject(tv); G.funds += refund;
return 'placed & sold tv, net §'+(G.funds-f0+800-refund);
})()`);
step('build wall segment costs money', `
(function(){
const f0=G.funds;
const ok = G.world.placeWall(20,20,'n');
if(!ok) throw new Error('wall not placed');
// note: placeWall itself does not charge; dragBuildTo charges
G.world.removeWall(20,20,'n');
void f0;
return 'walls editable';
})()`);
step('save then load roundtrip preserves state', `
(function(){
const objsBefore = G.world.objects.length;
const fundsBefore = G.funds;
saveGame(true);
const raw = localStorage.getItem(SAVE_KEY);
if(!raw) throw new Error('nothing saved');
// corrupt live state
G.funds = 1;
if (!loadGame()) throw new Error('load returned false');
if (G.world.objects.length !== objsBefore) throw new Error('objects mismatch');
if (G.funds !== fundsBefore) throw new Error('funds mismatch: '+G.funds+' vs '+fundsBefore);
return 'restored '+objsBefore+' objects, §'+G.funds;
})()`);
step('aging: elder death leaves gravestone', `
(function(){
const s = G.sims[0];
s.ageStage='elder'; s.daysAlive=34;
onNewDay(G.time.day);
if (chanceSafe()) {}
function chanceSafe(){return false;}
return 'family size now '+G.sims.length+' (death may have occurred randomly)';
})()`);
step('render draw() executes headless', `
(function(){
draw();
drawGhost(R.ctx||{});
return 'frames drawn without exception';
})()`);
console.log('\\n🎉 ALL SMOKE TESTS PASSED');