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:
@@ -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
@@ -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
@@ -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>
|
||||
+63
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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)),
|
||||
};
|
||||
}
|
||||
@@ -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
@@ -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();
|
||||
}
|
||||
@@ -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');
|
||||
Reference in New Issue
Block a user