2670 lines
222 KiB
HTML
2670 lines
222 KiB
HTML
<!doctype html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="utf-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover">
|
||
<title>Life of Mellow</title>
|
||
<!-- PWA / installable app -->
|
||
<link rel="manifest" href="manifest.json">
|
||
<meta name="theme-color" content="#8db26a">
|
||
<meta name="mobile-web-app-capable" content="yes">
|
||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||
<meta name="apple-mobile-web-app-title" content="Mellow">
|
||
<meta name="application-name" content="Life of Mellow">
|
||
<link rel="apple-touch-icon" href="icon-192.png">
|
||
<link rel="icon" type="image/png" sizes="192x192" href="icon-192.png">
|
||
<link rel="icon" type="image/png" sizes="512x512" href="icon-512.png">
|
||
<style>
|
||
:root{
|
||
--wood:#8a5f38; --wood2:#a9784a; --woodLt:#c99a5a; --woodDk:#4e341f; --plank:#cB9a63;
|
||
--parch:#f0e2ba; --parch2:#e6d29e; --ink:#3a2c1a; --ink2:#7a6244;
|
||
--green:#6ab04a; --red:#c65a45; --gold:#e0a53f;
|
||
}
|
||
*{box-sizing:border-box;-webkit-tap-highlight-color:transparent;font-family:"Trebuchet MS","Segoe UI",system-ui,sans-serif}
|
||
html,body{margin:0;height:100%;overflow:hidden;background:#2c2016;color:var(--ink);
|
||
user-select:none;-webkit-user-select:none;touch-action:none}
|
||
#game{position:fixed;inset:0}
|
||
canvas#cv{position:absolute;inset:0;width:100%;height:100%;display:block}
|
||
img.ic{width:22px;height:22px;vertical-align:middle;display:inline-block;pointer-events:none}
|
||
img.ic.sm{width:16px;height:16px}
|
||
img.ic.lg{width:30px;height:30px}
|
||
|
||
.wood{background:linear-gradient(180deg,#8a5f38,#6b4a2e);border:3px solid var(--woodDk);border-radius:14px;
|
||
box-shadow:0 4px 12px rgba(0,0,0,.4), inset 0 0 0 2px rgba(255,255,255,.10);color:#f3e4c4}
|
||
.disc{width:54px;height:54px;border-radius:50%;border:3px solid var(--woodDk);
|
||
background:radial-gradient(circle at 50% 35%,#e6cd94,#c99a5a 70%,#9c6f3e);
|
||
box-shadow:0 4px 10px rgba(0,0,0,.4),inset 0 0 0 3px rgba(120,80,40,.35);
|
||
display:flex;align-items:center;justify-content:center;cursor:pointer;color:#4e341f;font-weight:bold;font-size:12px}
|
||
.disc:active{transform:scale(.93)}
|
||
.parch{background:
|
||
radial-gradient(circle at 30% 20%,rgba(255,255,255,.35),transparent 60%),
|
||
linear-gradient(180deg,var(--parch),var(--parch2));
|
||
border:5px solid var(--wood);border-radius:16px;
|
||
box-shadow:0 8px 22px rgba(0,0,0,.45), inset 0 0 0 3px rgba(200,150,90,.5), inset 0 0 34px rgba(139,95,56,.2)}
|
||
|
||
/* ---------- HUD ---------- */
|
||
#hud{position:absolute;top:0;left:0;right:0;padding:8px;padding-top:calc(8px + env(safe-area-inset-top));display:flex;flex-direction:column;gap:6px;
|
||
pointer-events:none;z-index:5;align-items:center}
|
||
.signwrap{display:flex;gap:8px;align-items:flex-start;pointer-events:auto}
|
||
.plank{position:relative;background:linear-gradient(180deg,#d9b678,#c19a5a);border:3px solid #6b4a2e;
|
||
border-radius:8px;padding:7px 14px;font-weight:bold;font-size:16px;color:#4a3316;
|
||
box-shadow:0 3px 8px rgba(0,0,0,.35),inset 0 1px 0 rgba(255,255,255,.4);
|
||
display:flex;align-items:center;gap:8px;text-shadow:0 1px 0 rgba(255,255,255,.25)}
|
||
.plank:before,.plank:after{content:"";position:absolute;top:-8px;width:4px;height:9px;background:#6b4a2e;border-radius:2px}
|
||
.plank:before{left:14px}.plank:after{right:14px}
|
||
.seasonTag{position:absolute;left:50%;bottom:-19px;transform:translateX(-50%);background:#c19a5a;
|
||
border:2px solid #6b4a2e;border-radius:6px;padding:1px 8px;font-size:10px;color:#4a3316;white-space:nowrap}
|
||
.tempSign{background:linear-gradient(180deg,#d9b678,#c19a5a);border:3px solid #6b4a2e;border-radius:8px;
|
||
padding:7px 10px;font-weight:bold;font-size:14px;color:#4a3316;display:flex;align-items:center;gap:4px;
|
||
box-shadow:0 3px 8px rgba(0,0,0,.35)}
|
||
.res-strip{display:flex;gap:6px;overflow-x:auto;padding:5px 9px;scrollbar-width:none;max-width:96vw;margin-top:6px}
|
||
.res-strip::-webkit-scrollbar{display:none}
|
||
.res{display:flex;align-items:center;gap:3px;font-size:14px;font-weight:bold;white-space:nowrap;
|
||
color:#f3e4c4;text-shadow:0 1px 1px rgba(0,0,0,.5)}
|
||
.res.low{color:#ff9b7b}
|
||
|
||
/* edge buttons */
|
||
#leftCol{position:absolute;left:calc(8px + env(safe-area-inset-left));top:calc(70px + env(safe-area-inset-top));z-index:5;display:flex;flex-direction:column;gap:9px}
|
||
#rightCol{position:absolute;right:8px;bottom:calc(14px + env(safe-area-inset-bottom));z-index:5;display:flex;flex-direction:column;gap:9px}
|
||
|
||
/* ---------- bottom dock ---------- */
|
||
#dock{position:absolute;left:0;right:0;bottom:calc(20px + env(safe-area-inset-bottom));z-index:5;display:flex;flex-direction:column;
|
||
align-items:center;gap:7px;padding:6px}
|
||
#buildRow{display:flex;gap:6px;overflow-x:auto;max-width:min(96vw,540px);padding:7px;scrollbar-width:none}
|
||
#buildRow::-webkit-scrollbar{display:none}
|
||
.bbtn{flex:0 0 auto;width:64px;height:72px;border:2px solid var(--woodDk);border-radius:11px;
|
||
background:linear-gradient(180deg,#f3e6c4,#e6d29e);
|
||
display:flex;flex-direction:column;align-items:center;justify-content:center;gap:2px;
|
||
font-size:9px;font-weight:bold;color:var(--ink);cursor:pointer;padding:3px;
|
||
box-shadow:inset 0 0 0 2px rgba(255,255,255,.35)}
|
||
.bbtn:active{transform:scale(.94)}
|
||
.bbtn.locked{opacity:.45;filter:grayscale(.5)}
|
||
.bbtn .cost{font-size:9px;color:var(--ink2);display:flex;align-items:center;gap:3px;flex-wrap:wrap;justify-content:center}
|
||
.bbtn .cost b{display:flex;align-items:center;gap:1px}
|
||
|
||
#placeBanner{position:absolute;top:96px;left:50%;transform:translateX(-50%);z-index:6;
|
||
padding:9px 16px;font-weight:bold;display:none;align-items:center;gap:12px;font-size:14px;color:#f3e4c4}
|
||
#placeBanner button{background:var(--parch);color:var(--ink);border:2px solid var(--woodDk);border-radius:10px;
|
||
padding:5px 12px;font-weight:bold;cursor:pointer}
|
||
|
||
/* ---------- overlays ---------- */
|
||
.overlay{position:absolute;inset:0;background:rgba(30,20,12,.55);z-index:20;display:none;
|
||
align-items:center;justify-content:center;padding:16px}
|
||
.card{width:100%;max-width:440px;max-height:88vh;overflow-y:auto;padding:20px}
|
||
.card h2{margin:0 0 4px;font-size:23px;display:flex;align-items:center;gap:8px}
|
||
.card h3{margin:14px 0 6px;font-size:15px;color:var(--wood)}
|
||
.row{display:flex;gap:8px;flex-wrap:wrap}
|
||
.btn{background:linear-gradient(180deg,var(--woodLt),var(--wood));color:#fff;border:2px solid var(--woodDk);
|
||
border-radius:12px;padding:11px 14px;font-weight:bold;font-size:15px;cursor:pointer;flex:1;min-width:110px;
|
||
box-shadow:inset 0 1px 0 rgba(255,255,255,.3);text-shadow:0 1px 1px rgba(0,0,0,.3)}
|
||
.btn.sec{background:linear-gradient(180deg,#f0e2ba,#e0ca97);color:var(--ink);text-shadow:none}
|
||
.btn.danger{background:linear-gradient(180deg,#d5715a,#b84a37)}
|
||
.btn:active{transform:scale(.97)}
|
||
.btn.small{padding:8px 11px;font-size:13px;flex:0 0 auto;min-width:0}
|
||
|
||
.islands{display:grid;grid-template-columns:1fr 1fr;gap:9px;margin-top:8px}
|
||
.isle{background:linear-gradient(180deg,#fff4d8,#f1dfb2);border-radius:12px;padding:8px;cursor:pointer;
|
||
border:3px solid transparent;text-align:left;box-shadow:inset 0 0 0 1px rgba(139,95,56,.2)}
|
||
.isle.sel{border-color:var(--gold);box-shadow:0 0 0 2px var(--gold)}
|
||
.isle canvas{width:100%;height:58px;border-radius:8px;display:block;margin-bottom:5px}
|
||
.isle b{display:block;font-size:13px}
|
||
.isle small{color:var(--ink2);font-size:10.5px;line-height:1.25;display:block;margin-top:1px}
|
||
.diffs{display:flex;gap:8px;margin-top:6px}
|
||
.diff{flex:1;background:linear-gradient(180deg,#fff4d8,#f1dfb2);border-radius:12px;padding:9px;text-align:center;
|
||
cursor:pointer;border:3px solid transparent;font-weight:bold}
|
||
.diff.sel{border-color:var(--green);box-shadow:0 0 0 2px var(--green)}
|
||
.diff small{display:block;font-weight:normal;color:var(--ink2);font-size:10px;margin-top:2px}
|
||
|
||
.statbar{height:13px;border-radius:7px;background:rgba(74,56,38,.18);overflow:hidden;margin:2px 0 8px;
|
||
box-shadow:inset 0 1px 2px rgba(0,0,0,.2)}
|
||
.statbar>i{display:block;height:100%;border-radius:7px}
|
||
.jobgrid{display:grid;grid-template-columns:1fr 1fr 1fr;gap:6px;margin-top:4px}
|
||
.jobopt{background:linear-gradient(180deg,#fff4d8,#f1dfb2);border:2px solid transparent;border-radius:10px;
|
||
padding:7px 3px;font-size:10.5px;cursor:pointer;text-align:center;font-weight:bold;display:flex;
|
||
flex-direction:column;align-items:center;gap:3px}
|
||
.jobopt.sel{border-color:var(--gold)}
|
||
.mini{font-size:12.5px;color:var(--ink2);margin:3px 0}
|
||
.toolPick{display:grid;grid-template-columns:1fr 1fr;gap:7px;margin:7px 0}
|
||
.tpick{display:flex;flex-direction:column;align-items:center;gap:2px;padding:8px 4px;cursor:pointer;
|
||
border:2px solid transparent;border-radius:11px;background:linear-gradient(180deg,#fff4d8,#f1dfb2);font-size:12px;font-weight:bold;color:var(--ink)}
|
||
.tpick.sel{border-color:var(--gold);box-shadow:0 0 0 2px var(--gold)}
|
||
.tpick .tcost{font-size:10.5px;color:var(--ink2);font-weight:normal;display:flex;align-items:center;gap:3px}
|
||
.close{position:absolute;top:10px;right:14px;font-size:28px;line-height:1;cursor:pointer;color:#f3e4c4;z-index:21;
|
||
text-shadow:0 1px 3px #000}
|
||
.toast{position:absolute;bottom:120px;left:50%;transform:translateX(-50%);z-index:15;
|
||
padding:10px 18px;border-radius:20px;font-size:13px;font-weight:bold;color:#f3e4c4;
|
||
opacity:0;transition:opacity .3s;pointer-events:none;max-width:90%;text-align:center}
|
||
.toast.show{opacity:1}
|
||
.settingsRow{display:flex;justify-content:space-between;align-items:center;padding:9px 0;
|
||
border-bottom:1px solid rgba(139,95,56,.25)}
|
||
.switch{width:50px;height:28px;border-radius:14px;background:rgba(74,56,38,.3);position:relative;cursor:pointer}
|
||
.switch.on{background:var(--green)}
|
||
.switch i{position:absolute;top:3px;left:3px;width:22px;height:22px;border-radius:50%;background:#fff;
|
||
transition:left .15s;box-shadow:0 1px 3px rgba(0,0,0,.3)}
|
||
.switch.on i{left:25px}
|
||
.hint{font-size:12.5px;color:var(--ink2);line-height:1.45}
|
||
.hint p{margin:6px 0}
|
||
|
||
/* build button + panel */
|
||
#btnBuild{border:3px solid var(--woodDk);border-radius:16px;padding:11px 26px;font-size:17px;font-weight:bold;
|
||
color:#f3e4c4;display:flex;align-items:center;gap:8px;cursor:pointer;text-shadow:0 1px 1px rgba(0,0,0,.4);
|
||
background:linear-gradient(180deg,#a9784a,#6b4a2e);box-shadow:0 4px 12px rgba(0,0,0,.45),inset 0 1px 0 rgba(255,255,255,.2)}
|
||
#btnBuild:active{transform:scale(.95)}
|
||
.bldTitle{text-align:center;font-size:20px;font-weight:bold;color:#4a3316;margin:-4px 0 6px;
|
||
background:linear-gradient(180deg,#d9b678,#c19a5a);border:3px solid #6b4a2e;border-radius:10px;padding:6px}
|
||
.vhead{display:flex;align-items:center;gap:8px;justify-content:space-between;background:linear-gradient(180deg,#d9b678,#c19a5a);border:3px solid #6b4a2e;border-radius:10px;padding:7px 12px;margin-bottom:8px}
|
||
.vhead b{font-size:19px;color:#4a3316}
|
||
.vhead .vsub{font-size:11px;color:#5a4326;font-weight:bold}
|
||
.vlvl{display:inline-block;font-size:12px;font-weight:bold;color:#4a3316;background:#e7cf94;border:2px solid #6b4a2e;border-radius:20px;padding:1px 9px;margin-left:6px;vertical-align:middle}
|
||
.vlnext{text-align:center;font-size:12px;color:var(--wood);margin:-2px 0 4px}
|
||
.bcard.locked{opacity:.62;filter:saturate(.5)}
|
||
.catHead{display:flex;align-items:center;gap:8px;color:var(--wood);font-weight:bold;font-size:13px;margin:12px 2px 4px}
|
||
.catHead:before,.catHead:after{content:"";flex:1;height:2px;background:rgba(139,95,56,.3)}
|
||
.bcard{display:flex;gap:10px;align-items:center;background:linear-gradient(180deg,#fff6e0,#f2e2b8);
|
||
border:2px solid #b98a4a;border-radius:12px;padding:9px;margin:7px 0;box-shadow:inset 0 0 0 1px rgba(255,255,255,.4)}
|
||
.bcard .prev{width:50px;height:50px;flex:0 0 auto;background:#e7d3a8;border-radius:9px;
|
||
display:flex;align-items:center;justify-content:center;border:2px solid #c69a5a}
|
||
.bcard .prev img{width:38px;height:38px}
|
||
.binfo{flex:1;min-width:0}.binfo b{font-size:14px;color:var(--ink)}
|
||
.bdesc{font-size:11px;color:var(--ink2);line-height:1.28;margin:2px 0 3px}
|
||
.bcost{font-size:12.5px;font-weight:bold;color:var(--ink);display:flex;align-items:center;gap:5px;flex-wrap:wrap}
|
||
.bcost b{display:flex;align-items:center;gap:1px}
|
||
.buildbtn{flex:0 0 auto;align-self:stretch;min-width:64px;background:linear-gradient(180deg,#7bc24a,#5a9a34);
|
||
color:#fff;border:2px solid #3f7020;border-radius:10px;padding:8px 12px;font-weight:bold;font-size:14px;cursor:pointer;
|
||
box-shadow:inset 0 1px 0 rgba(255,255,255,.45);text-shadow:0 1px 1px rgba(0,0,0,.25)}
|
||
.buildbtn:active{transform:scale(.95)}
|
||
.buildbtn.no{filter:grayscale(.65);opacity:.55}
|
||
|
||
/* village tabs + notice + portrait */
|
||
.vtabs{display:flex;margin:2px 0 10px;border:2px solid var(--woodDk);border-radius:12px;overflow:hidden}
|
||
.vtab{flex:1;padding:10px;text-align:center;font-weight:bold;cursor:pointer;background:#e6d29e;color:var(--ink2);font-size:14px}
|
||
.vtab.on{background:linear-gradient(180deg,#8a5f38,#6b4a2e);color:#f3e4c4}
|
||
.noticeRow{display:flex;align-items:center;gap:10px;padding:9px 2px;border-bottom:1px solid rgba(139,95,56,.25);font-size:13.5px;color:var(--ink)}
|
||
.noticeRow img.nb{width:30px;height:30px;flex:0 0 auto}
|
||
.noticeRow .num{color:#6a4fb0;font-weight:bold;font-size:15px}
|
||
.pinfo{display:flex;gap:14px;align-items:center;margin:4px 0}
|
||
.portrait{width:74px;height:82px;flex:0 0 auto;background:#e7d3a8;border:2px solid #c69a5a;border-radius:10px}
|
||
.pmeta{font-size:14px;line-height:1.55}.pmeta b{color:var(--ink)}
|
||
.resgrid{display:grid;grid-template-columns:1fr 1fr;gap:8px;margin-top:10px}
|
||
.ritem{display:flex;align-items:center;gap:8px;background:linear-gradient(180deg,#fff6e0,#f2e2b8);
|
||
border:2px solid #c69a5a;border-radius:10px;padding:8px 11px;font-weight:bold;font-size:15px;color:var(--ink)}
|
||
.ritem img{width:26px;height:26px}.ritem b{margin-left:auto}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div id="game">
|
||
<canvas id="cv"></canvas>
|
||
|
||
<div id="hud">
|
||
<div class="signwrap">
|
||
<div class="plank" id="seasonBox">
|
||
<img class="ic sm" id="seasonIco"><span id="seasonName">Spring</span>
|
||
<div class="seasonTag" id="dayNum">1st Season</div>
|
||
</div>
|
||
<div class="tempSign"><span id="tempTxt">18°</span></div>
|
||
<div class="tempSign" id="speedBtn" style="cursor:pointer;min-width:44px;text-align:center">1×</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div id="leftCol">
|
||
<div class="disc" id="btnSettings">Menu</div>
|
||
<div class="disc" id="btnJobs">Village</div>
|
||
<div class="disc" id="btnRes"><img class="ic" id="resIco"></div>
|
||
<div class="disc" id="btnHelp">?</div>
|
||
</div>
|
||
|
||
<div id="placeBanner" class="wood">
|
||
<span id="placeTxt">Tap a tile to place</span>
|
||
<button id="placeCancel">Cancel</button>
|
||
</div>
|
||
|
||
<div id="dock">
|
||
<button id="btnBuild"><img class="ic lg" id="buildIco"> Build</button>
|
||
</div>
|
||
|
||
<div class="toast wood" id="toast"></div>
|
||
|
||
<div class="overlay" id="startOverlay" style="display:flex">
|
||
<div class="card parch">
|
||
<h2>Life of Mellow</h2>
|
||
<div class="hint">Guide your Mellows to gather, build, feed, and survive the seasons. Choose a home.</div>
|
||
<div class="islands" id="isleGrid"></div>
|
||
<h3>Difficulty</h3>
|
||
<div class="diffs" id="diffRow"></div>
|
||
<div class="row" style="margin-top:14px">
|
||
<button class="btn" id="btnStart">Begin</button>
|
||
<button class="btn sec" id="btnContinue" style="display:none">Continue</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="overlay" id="buildOverlay"><span class="close" id="buildClose">×</span><div class="card parch" id="buildCard"></div></div>
|
||
<div class="overlay" id="resOverlay"><span class="close" id="resClose">×</span><div class="card parch" id="resCard"></div></div>
|
||
<div class="overlay" id="profOverlay"><span class="close" id="profClose">×</span><div class="card parch" id="profCard"></div></div>
|
||
<div class="overlay" id="bldOverlay"><span class="close" id="bldClose">×</span><div class="card parch" id="bldCard"></div></div>
|
||
<div class="overlay" id="jobsOverlay"><span class="close" id="jobsClose">×</span><div class="card parch" id="jobsCard"></div></div>
|
||
|
||
<div class="overlay" id="setOverlay"><span class="close" id="setClose">×</span>
|
||
<div class="card parch">
|
||
<h2>Menu</h2>
|
||
<div class="settingsRow"><span>Animations <small style="color:var(--ink2)">(off = smoother on big villages)</small></span>
|
||
<div class="switch on" id="swAnim"><i></i></div></div>
|
||
<div class="settingsRow"><span>Sound</span><div class="switch" id="swSound"><i></i></div></div>
|
||
<h3>Game</h3>
|
||
<div class="row"><button class="btn sec small" id="btnSave">Save</button>
|
||
<button class="btn danger small" id="btnQuit">Quit to menu</button></div>
|
||
<div class="hint" style="margin-top:10px">Autosaves as you play. Everything stays on this device.</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="overlay" id="helpOverlay"><span class="close" id="helpClose">×</span>
|
||
<div class="card parch">
|
||
<h2>How to play</h2>
|
||
<div class="hint">
|
||
<p><b>Assign jobs.</b> Tap a Mellow → pick a job. Gatherers collect resources and drop them at storage. Service jobs work inside a matching building.</p>
|
||
<p><b>Build.</b> Tap a building at the bottom, then tap a tile. A Builder constructs it — keep someone on Builder!</p>
|
||
<p><b>Feed everyone.</b> Hunger falls over time — keep food in stock. A Cook turns raw food into meals.</p>
|
||
<p><b>Survive winter.</b> Plants stop and it gets cold. Stock <b>firewood</b> (Sawmill) and build a <b>Campfire</b>, or Mellows lose health.</p>
|
||
<p><b>Need bubbles</b> pop over Mellows that are hungry, idle, or sick — a quick glance tells you what the village needs.</p>
|
||
<p><b>Stuck Mellow?</b> Open its profile → <b>Send home</b>.</p>
|
||
</div>
|
||
<button class="btn" id="helpOk" style="margin-top:8px">Got it</button>
|
||
<button class="btn sec" id="helpTut" style="margin-top:8px;margin-left:6px">Replay tutorial</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="overlay" id="overOverlay">
|
||
<div class="card parch" style="text-align:center">
|
||
<h2>Your village has faded…</h2>
|
||
<div class="hint" id="overStats"></div>
|
||
<div class="hint" style="margin-top:6px">Every Mellow has passed on — but hope endures.</div>
|
||
<div class="row" style="margin-top:14px;justify-content:center">
|
||
<button class="btn" id="overRestart">Try this island again</button>
|
||
<button class="btn sec" id="overMenu">Back to menu</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<script>
|
||
"use strict";
|
||
/* ================= CONFIG ================= */
|
||
const GRID=42, TILE_W=64, TILE_H=32, TILE_TH=7; // 42×42 ≈ 3× the old 24×24 area
|
||
const SEASONS=["Spring","Summer","Fall","Winter"];
|
||
const SEASON_TEMP={Spring:16,Summer:27,Fall:12,Winter:-4};
|
||
const DIFFS={Chill:{season:300,cost:0.7,decay:0.7,label:"relaxed"},
|
||
Normal:{season:240,cost:1.0,decay:1.0,label:"balanced"},
|
||
Harsh:{season:190,cost:1.3,decay:1.35,label:"long winters"}};
|
||
const JOBS={
|
||
idle:{name:"Idle",cloak:"#9fb0a0",kind:"idle",tool:"none"},
|
||
builder:{name:"Builder",cloak:"#4b86b6",kind:"builder",tool:"hammer"},
|
||
lumberjack:{name:"Lumberjack",cloak:"#5fa83e",kind:"gather",node:"tree",res:"wood",tool:"axe"},
|
||
miner:{name:"Miner",cloak:"#7d8a92",kind:"gather",node:"rock",res:"stone",tool:"pick"},
|
||
ironminer:{name:"Iron Miner",cloak:"#a9683c",kind:"gather",node:"ore",res:"iron",tool:"pick"},
|
||
hunter:{name:"Hunter",cloak:"#8a5a2e",kind:"gather",node:"deer",res:"meat",tool:"bow"},
|
||
fruit:{name:"Fruit Picker",cloak:"#c65a72",kind:"gather",node:"bush",res:"fruit",tool:"none",seasonal:true},
|
||
herbalgather:{name:"Herbalist Gatherer",cloak:"#4f9e4a",kind:"gather",node:"herb",res:"herbs",tool:"none",seasonal:true},
|
||
fisher:{name:"Fisherman",cloak:"#3a7fb0",kind:"gather",node:"fish",res:"fish",tool:"rod"},
|
||
shepherd:{name:"Shepherd",cloak:"#b8b2a2",kind:"gather",node:"sheep",res:"wool",tool:"staff"},
|
||
trainer:{name:"Wolf Trainer",cloak:"#7a6a4a",kind:"trainer",bld:"wolfcentre",tool:"none"},
|
||
farmer:{name:"Farmer",cloak:"#c9a13a",kind:"gather",node:"crop",res:"crops",tool:"scythe",seasonal:true},
|
||
craftsman:{name:"Craftsman",cloak:"#9c6b3a",kind:"produce",bld:"craft",tool:"hammer"},
|
||
herbalist:{name:"Apothecary",cloak:"#8e5cc0",kind:"produce",bld:"herbalist",tool:"none"},
|
||
brewer:{name:"Brewer",cloak:"#b1567f",kind:"produce",bld:"brewery",tool:"none"},
|
||
cook:{name:"Cook",cloak:"#e0913f",kind:"produce",bld:"kitchen",tool:"none"},
|
||
sawyer:{name:"Sawyer",cloak:"#7a5230",kind:"produce",bld:"sawmill",tool:"saw"},
|
||
doctor:{name:"Doctor",cloak:"#cf5a5a",kind:"doctor",bld:"medical",tool:"none"},
|
||
midwife:{name:"Midwife",cloak:"#d98ab4",kind:"midwife",bld:"nursery",tool:"none"},
|
||
teacher:{name:"Teacher",cloak:"#c99a3a",kind:"teacher",bld:"school",tool:"none"},
|
||
rancher:{name:"Rancher",cloak:"#8a6b3a",kind:"produce",bld:"ranch",tool:"staff"},
|
||
butcher:{name:"Butcher",cloak:"#a35a4a",kind:"produce",bld:"butcher",tool:"none"}};
|
||
const BUILD={
|
||
house:{name:"Wood House",cost:{wood:20,stone:10},house:2,roof:"#b06a44",icon:"house",cat:"Homes",
|
||
desc:"Let the Mellows live in a wood house. Shelters 2, and babies are born here."},
|
||
stonehouse:{name:"Stone House",cost:{planks:24,stone:30},house:2,warm:true,roof:"#8a8f98",stone:true,icon:"house",cat:"Homes",
|
||
desc:"Warmer than a Wood House — keeps Mellows cosy through winter. Needs planks from a Sawmill."},
|
||
storage:{name:"Storage",cost:{wood:25},store:true,roof:"#b98a4a",icon:"storage",cat:"Homes",
|
||
desc:"A warehouse to hold gathered resources close to your workers."},
|
||
craft:{name:"Craft Shop",cost:{planks:20,iron:8},job:"craftsman",craftTools:true,recipe:{t:3.2},roof:"#7a5230",icon:"hammer",cat:"Artisan",
|
||
desc:"A Craftsman makes tools — axe, pickaxe, bow & basket. Open the shop to choose which to craft."},
|
||
sawmill:{name:"Sawmill",cost:{wood:30},job:"sawyer",recipe:{in:{wood:2},out:{planks:1,firewood:1},t:2.6},roof:"#9c6b3a",icon:"saw",cat:"Artisan",
|
||
desc:"A Sawyer processes raw wood into planks (for advanced buildings) and firewood (for winter)."},
|
||
kitchen:{name:"Kitchen",cost:{planks:24,stone:14},job:"cook",recipe:{in:{meat:1,fruit:1},out:{meals:2},t:2.6},roof:"#e0913f",icon:"pot",cat:"Artisan",
|
||
desc:"A Cook turns raw food into hearty meals. Needs planks to build."},
|
||
herbalist:{name:"Apothecary",cost:{planks:24,iron:8},job:"herbalist",recipe:{in:{herbs:1},out:{potion:1},bonus:{res:"contra",chance:0.34},t:3.2},roof:"#8e5cc0",icon:"flask",cat:"Artisan",
|
||
desc:"An Apothecary brews potions to cure sickness, and now and then prepares contraceptive herbs. Needs planks."},
|
||
brewery:{name:"Brewery",cost:{planks:24,iron:8},job:"brewer",recipe:{in:{fruit:2},out:{wine:1},t:4},roof:"#b1567f",icon:"wine",cat:"Artisan",
|
||
desc:"A Brewer makes wine from fruit to keep Mellows happy. Needs planks."},
|
||
farm:{name:"Farm",cost:{wood:20},farm:true,size:2,roof:"#c9a13a",icon:"crops",cat:"Food",
|
||
desc:"Pick a crop to grow from your seeds. A Farmer sows 10 seeds in spring and harvests 30 in fall. 2×2 plot."},
|
||
pasture:{name:"Pasture",cost:{wood:20},pasture:true,size:2,roof:"#8fae63",icon:"sheep",cat:"Food",
|
||
desc:"A Shepherd raises a herd here — pick sheep (wool + meat) or deer (meat + leather). The herd grows each season. 2×2 plot."},
|
||
ranch:{name:"Ranch",cost:{planks:20,stone:16},job:"rancher",recipe:{in:{crops:1},out:{milk:2,leather:1},t:3.2},roof:"#9a7a4a",icon:"milk",cat:"Food",
|
||
desc:"A Rancher raises cattle — feed them crops (from a Farm) for milk and leather."},
|
||
butcher:{name:"Butcher",cost:{planks:24,iron:8},job:"butcher",recipe:{in:{meat:2},out:{meals:2,leather:1},t:2.6},roof:"#a35a4a",icon:"cleaver",cat:"Food",
|
||
desc:"A Butcher turns raw meat into meals and leather. Needs planks."},
|
||
medical:{name:"Medical Tent",cost:{planks:24,iron:8},job:"doctor",tent:true,roof:"#cf5a5a",icon:"cross",cat:"Care",
|
||
desc:"A Doctor treats the sick and injured with potions. Needs planks to build."},
|
||
nursery:{name:"Nursery",cost:{planks:24,stone:12},job:"midwife",roof:"#d98ab4",icon:"baby",cat:"Care",
|
||
desc:"A Midwife raises the birth rate and cares for babies. Needs planks."},
|
||
school:{name:"Schoolhouse",cost:{planks:28,iron:10},job:"teacher",roof:"#c99a3a",icon:"school",cat:"Care",
|
||
desc:"A Teacher educates your children. Schooled kids grow into far better skilled workers — doctors, farmers, crafters and more."},
|
||
cemetery:{name:"Cemetery",cost:{planks:12,stone:40},cemetery:true,roof:"#8a8f88",icon:"grave",cat:"Care",
|
||
desc:"A resting place. Mellows who pass are laid to rest here — their grave markers gather neatly on this plot, and you can read the memorial. Without one, the departed are only remembered in the log."},
|
||
campfire:{name:"Campfire",cost:{wood:10},warmth:true,small:true,roof:"#e0913f",icon:"fire",cat:"Care",
|
||
desc:"Burns firewood to keep nearby Mellows warm in winter."},
|
||
wolfcentre:{name:"Wolf Den",cost:{planks:20,iron:10},job:"trainer",wolfcentre:true,roof:"#6b6b72",icon:"wolf",cat:"Care",
|
||
desc:"A Wolf Trainer tames nearby wild wolves (8 meat each) and raises pups. Houses up to 10; families can adopt trained wolves here — they guard the family and lift everyone's spirits."},
|
||
dock:{name:"Dock",cost:{wood:15},dock:true,roof:"#6b93c0",icon:"boat",cat:"Water",
|
||
desc:"Reaches from land onto the water (2 tiles). Tap a water tile beside land — a home for Fishermen to fish the deep."},
|
||
bridge:{name:"Bridge",cost:{wood:12},bridge:true,roof:"#a98a5a",icon:"bridge",cat:"Water",
|
||
desc:"Bridge a river to reach resources on the far bank."},
|
||
boat:{name:"Boat",cost:{planks:20,stone:5,iron:10},boat:true,roof:"#a06a3e",icon:"boat",cat:"Water",needsCraft:true,
|
||
desc:"A little boat, made at the Craft Shop. Place it on interior water to cross rivers & ponds — ferries up to 3 Mellows across."},
|
||
flowerbed:{name:"Flower Bed",cost:{wood:3},decor:true,small:true,roof:"#e08aa0",icon:"flower",cat:"Decor",
|
||
desc:"A cheerful bed of flowers. A prettier village lifts everyone's spirits a little."},
|
||
lantern:{name:"Lantern",cost:{wood:4,stone:2},decor:true,small:true,roof:"#f2c14a",icon:"lantern",cat:"Decor",
|
||
desc:"A standing lantern that glows warmly after dark. A cosy touch."},
|
||
well:{name:"Stone Well",cost:{stone:12,wood:4},decor:true,small:true,roof:"#9aa0a6",icon:"well",cat:"Decor",
|
||
desc:"A classic stone well — a charming centrepiece for the village square."},
|
||
bench:{name:"Bench",cost:{wood:8},decor:true,small:true,roof:"#a98a5a",icon:"bench",cat:"Decor",
|
||
desc:"A wooden bench to sit and watch the day go by."},
|
||
statue:{name:"Statue",cost:{stone:16},decor:true,small:true,roof:"#cfd2d6",icon:"statue",cat:"Decor",
|
||
desc:"A little stone statue of a Mellow — the village's pride and joy."}};
|
||
const BUILD_ORDER=["house","stonehouse","storage","craft","sawmill","kitchen","herbalist","brewery",
|
||
"farm","pasture","ranch","butcher","medical","nursery","school","cemetery","wolfcentre","campfire","dock","bridge",
|
||
"flowerbed","lantern","well","bench","statue"]; // boat removed as a placeable — it's a tool/vehicle, not a building (bridges cross water)
|
||
const BUILD_CATS=["Homes","Artisan","Food","Care","Water","Decor"];
|
||
// Village level is a title/celebration only (no building unlocks) — grander titles as population grows.
|
||
const LEVEL_POP=[8,15,25,40,60,90]; // population needed for levels 2..7
|
||
const VILLAGE_TITLES=["Settlement","Hamlet","Village","Township","Town","Bustling City","Grand Metropolis"]; // level 1..7
|
||
function villageLevel(){const p=(S&&S.mellows)?S.mellows.length:0;let lv=1;for(let i=0;i<LEVEL_POP.length;i++)if(p>=LEVEL_POP[i])lv=i+2;return lv;}
|
||
function villageTitle(){return VILLAGE_TITLES[Math.min(VILLAGE_TITLES.length-1,villageLevel()-1)];}
|
||
function maxLevel(){return LEVEL_POP.length+1;}
|
||
function levelPop(lv){return LEVEL_POP[lv-2]||0;} // pop threshold that unlocks a given level
|
||
const VILLAGE_NAMES=["Meadowbrook","Willowdale","Marshmallow Hollow","Puffington","Cloudberry","Bramblewick","Honeydew","Fluffield","Dandelion Green","Sootherby","Pillowvale","Cosywick"];
|
||
function randomVillageName(){return VILLAGE_NAMES[(Math.random()*VILLAGE_NAMES.length)|0];}
|
||
const BLD_LVL={}; // no building is level-locked — every building is available from the start (gated only by resource cost)
|
||
function bldLevel(type){return BLD_LVL[type]||1;} // village level required to build this
|
||
function bldLocked(type){return bldLevel(type)>villageLevel();}
|
||
const RES_ORDER=["wood","planks","stone","iron","fruit","meat","fish","crops","corn","pumpkin","carrot","cabbage","meals","milk","herbs","potion","contra","wine","firewood","axe","pickaxe","bow","basket","hoe","wool","leather"];
|
||
const RES_NAME={contra:"Contraceptive"};
|
||
// 6 plantable crops (berries reuse the existing 'fruit' resource; the rest are their own foods).
|
||
// Each is a food you can eat, and eating it drops seeds of its own kind.
|
||
const CROPS={fruit:{name:"Berries",color:"#c1443e"},crops:{name:"Crops",color:"#e0b84a"},
|
||
corn:{name:"Corn",color:"#f2c94c"},pumpkin:{name:"Pumpkin",color:"#e08a3a"},
|
||
carrot:{name:"Carrot",color:"#e07a3a"},cabbage:{name:"Cabbage",color:"#7fae54"}};
|
||
const CROP_ORDER=["fruit","crops","corn","pumpkin","carrot","cabbage"];
|
||
const PLANT_COST=10,PLANT_YIELD=30; // 10 seeds sown → 30 harvested in fall
|
||
const DOCK_DIRS=[[1,0],[-1,0],[0,1],[0,-1]]; // land→water offset for a 2-tile dock
|
||
function startSeeds(){return {fruit:25,crops:25,corn:25,pumpkin:25,carrot:25,cabbage:25};} // 25 of each seed
|
||
// ---- Wolves ---- packs roam ~2 dens; Centres tame/train them; families adopt them
|
||
const WOLF_MAX_WILD=16, WOLF_PACK_MAX=6, WOLF_TRAIN_SEASONS=3, WOLF_TAME_MEAT=8,
|
||
FAMILY_WOLF_CAP=2, FAMILY_WOLF_TOTAL=3, FACILITY_CAP=10;
|
||
// craftable tools (village-wide boosts) — chosen & made at the Craft Shop
|
||
const TOOLS={axe:{name:"Axe",cost:{wood:5,stone:5}},pickaxe:{name:"Pickaxe",cost:{wood:5,stone:5}},
|
||
bow:{name:"Bow",cost:{wood:8,stone:2}},basket:{name:"Basket",cost:{wood:10}},hoe:{name:"Hoe",cost:{wood:5,stone:5}}};
|
||
const TOOL_ORDER=["axe","pickaxe","bow","basket","hoe"];
|
||
const JOB_TOOL={lumberjack:"axe",miner:"pickaxe",ironminer:"pickaxe",hunter:"bow",fruit:"basket"};
|
||
const FOOD_TYPES=["meals","fruit","meat","crops","corn","pumpkin","carrot","cabbage","fish","milk"];
|
||
const NODE={
|
||
tree:{res:"wood",max:40,kind:"tree"},rock:{res:"stone",max:40,kind:"rock"},ore:{res:"iron",max:30,kind:"ore"},
|
||
bush:{res:"fruit",max:20,kind:"bush",plant:true},herb:{res:"herbs",max:16,kind:"herb",plant:true},
|
||
deer:{res:"meat",max:12,kind:"deer",animal:true},sheep:{res:"wool",max:14,kind:"sheep",animal:true},
|
||
fish:{res:"fish",max:20,kind:"fish",water:true},crop:{res:"crops",max:0,kind:"crop",plant:true,farmed:true}};
|
||
|
||
const ISLANDS={
|
||
meadow:{name:"Meadow Vale",grass:"#b2c198",grass2:"#acbc8f",side:"#739051",accent:"#abc18d",
|
||
blurb:"Gentle & balanced, dotted with ponds and groves.",weights:{tree:24,rock:11,ore:6,bush:16,herb:10,deer:7,sheep:5,fish:7},water:1},
|
||
woods:{name:"Whispering Woods",grass:"#a4bc8e",grass2:"#9dba84",side:"#5e8347",accent:"#9bba83",
|
||
blurb:"Deep forest laced with streams. Wolves roam.",weights:{tree:40,rock:6,ore:3,bush:14,herb:13,deer:12,sheep:3,fish:6},water:2,wolves:2},
|
||
highlands:{name:"Rocky Highlands",grass:"#bec1a0",grass2:"#b7b899",side:"#868c5f",accent:"#bec29b",
|
||
blurb:"Stone & iron rich, with cold mountain tarns.",weights:{tree:9,rock:30,ore:18,bush:9,herb:7,deer:5,sheep:7,fish:6},water:1},
|
||
coast:{name:"Sunny Coast",grass:"#b7ca9c",grass2:"#afc296",side:"#769d56",accent:"#e0d4ac",
|
||
blurb:"Fruit, fish & water galore. Few minerals.",weights:{tree:16,rock:6,ore:3,bush:21,herb:8,deer:4,sheep:4,fish:24},water:3},
|
||
desert:{name:"Desert Oasis",grass:"#ddcdaa",grass2:"#d5c39f",side:"#af9a72",accent:"#e4d5ad",
|
||
blurb:"Life clusters around precious oasis water.",weights:{tree:8,rock:15,ore:10,bush:7,herb:5,deer:4,sheep:4,fish:8},water:2},
|
||
tundra:{name:"Frostpine Tundra",grass:"#d1dadd",grass2:"#c7d2d5",side:"#9ba9af",accent:"#dde6e9",
|
||
blurb:"Long, brutal winters and frozen lakes.",weights:{tree:20,rock:12,ore:8,bush:8,herb:7,deer:11,sheep:5,fish:6},water:1,coldland:true,wolves:2},
|
||
ashen:{name:"Ashen Isle",grass:"#b3ada8",grass2:"#aba59f",side:"#746d67",accent:"#b7b0aa",
|
||
blurb:"Mineral & crop rich, split by rivers to bridge.",weights:{tree:13,rock:24,ore:17,bush:12,herb:9,deer:4,sheep:4,fish:11},water:3}};
|
||
|
||
/* ================= STATE ================= */
|
||
let S=null, cam={x:0,y:0,z:1};
|
||
let ui={placing:null,selMel:null,selBld:null,anim:true,sound:false,speed:1,hoverX:0,hoverY:0};
|
||
let cv,ctx,DPR=1,VW=0,VH=0,lastT=0;
|
||
const NAMES=["Bumble","Pip","Nub","Momo","Tato","Wiggy","Bean","Fig","Doe","Puff","Sprout","Cricket","Ollie","Muddle","Twig","Poppy","Berry","Nib","Waddle","Clove","Pebble","Fern","Otto","Suki","Boop","Marbles","Tuck","Cinnamon","Juniper","Dizzy","Wren","Hobs","Lint","Pudding","Bramble","Moss"];
|
||
const FEM_NAMES=["Poppy","Berry","Fern","Suki","Wren","Juniper","Clove","Cinnamon","Grace","Momo","Doe","Pudding","Marbles","Fig"];
|
||
const SURNAMES=["Marshby","Puddleton","Fernwick","Thistledown","Barleymoss","Tumblewick","Honeynook","Bramblefoot","Meadowsweet","Cloverleaf","Nettlebrook","Mossbottom","Acornsby","Pinewhistle","Dewberry","Softpaw","Willowby","Sugarmell","Cobbleside","Snugglepot"];
|
||
const ELDER_AGE=55;
|
||
const TRAITS={
|
||
hardy:{name:"Hardy",desc:"Weathers cold, hunger and illness better."},
|
||
sickly:{name:"Sickly",desc:"Falls ill more easily and tires faster."},
|
||
green:{name:"Green Thumb",desc:"Faster at plants — farming, fruit and herbs."},
|
||
bookish:{name:"Bookish",desc:"Learns faster and starts a little smarter."},
|
||
cheery:{name:"Cheery",desc:"Stays happier through thick and thin."}};
|
||
const TRAIT_KEYS=Object.keys(TRAITS);
|
||
function pickTrait(){return TRAIT_KEYS[(Math.random()*TRAIT_KEYS.length)|0];}
|
||
// Hobbies: personality flavour shown on the profile. If a hobby has a favoured job, working
|
||
// that job gives a small happiness nudge (see updateMellow). The rest are pure flavour.
|
||
const HOBBIES={
|
||
angler:{label:"loves fishing",emoji:"🎣",job:"fisher"},
|
||
gardener:{label:"loves gardening",emoji:"🌱",job:"farmer"},
|
||
woodsman:{label:"loves the woods",emoji:"🪵",job:"lumberjack"},
|
||
forager:{label:"loves foraging",emoji:"🧺",job:"fruit"},
|
||
baker:{label:"loves baking",emoji:"🥧",job:"cook"},
|
||
tinkerer:{label:"loves tinkering",emoji:"🔨",job:"craftsman"},
|
||
tracker:{label:"loves tracking",emoji:"🏹",job:"hunter"},
|
||
herder:{label:"loves herding",emoji:"🐑",job:"shepherd"},
|
||
stargazer:{label:"a stargazer",emoji:"✨",job:null},
|
||
singer:{label:"loves to sing",emoji:"🎵",job:null},
|
||
dreamer:{label:"a daydreamer",emoji:"☁️",job:null},
|
||
wanderer:{label:"a wanderer",emoji:"🥾",job:null},
|
||
collector:{label:"a collector",emoji:"🐚",job:null},
|
||
napper:{label:"loves a good nap",emoji:"😴",job:null}};
|
||
const HOBBY_KEYS=Object.keys(HOBBIES);
|
||
function pickHobby(){return HOBBY_KEYS[(Math.random()*HOBBY_KEYS.length)|0];}
|
||
// Mood: derived live from a Mellow's current state — shown on the profile & as an occasional bubble.
|
||
function moodOf(m){
|
||
if(m.sick)return{emoji:"🤒",label:"poorly"};
|
||
if(m.hunger<25)return{emoji:"😖",label:"hungry"};
|
||
if(isWinter()&&m.warmth<40)return{emoji:"🥶",label:"chilly"};
|
||
const h=m.happy||0;
|
||
if(h>=82)return{emoji:"😄",label:"delighted"};
|
||
if(h>=62)return{emoji:"🙂",label:"content"};
|
||
if(h>=42)return{emoji:"😐",label:"so-so"};
|
||
return{emoji:"😔",label:"glum"};}
|
||
function isElder(m){return !isChild(m)&&m.age>=ELDER_AGE;}
|
||
const MILESTONES=[
|
||
{id:'baby',name:'First Baby',desc:'Welcome a newborn Mellow.',test:s=>s._everBaby},
|
||
{id:'winter',name:'Survived a Winter',desc:'Bring the village through its first winter.',test:s=>(s._wintersSurvived||0)>=1},
|
||
{id:'pop10',name:'Village of 10',desc:'Grow to 10 Mellows.',test:s=>s.mellows.length>=10},
|
||
{id:'pop25',name:'Bustling Town of 25',desc:'Grow to 25 Mellows.',test:s=>s.mellows.length>=25},
|
||
{id:'pop50',name:'Thriving Town of 50',desc:'Grow to 50 Mellows.',test:s=>s.mellows.length>=50},
|
||
{id:'elder',name:'First Elder',desc:'A Mellow reaches the wise age of 55.',test:s=>s.mellows.some(m=>isElder(m))},
|
||
{id:'genius',name:'A Bright Mind',desc:'Raise a Mellow to intelligence 10.',test:s=>s.mellows.some(m=>(m.intel||0)>=10)},
|
||
{id:'family',name:'Full House',desc:'Raise a home with three children.',test:s=>s.buildings.some(b=>b.built&&BUILD[b.type].house&&s.mellows.filter(m=>m.home===b.id&&isChild(m)).length>=3)}];
|
||
// Goals: a directed, ordered checklist that guides play (distinct from the passive Milestones above).
|
||
// Each completed goal gives a small happiness reward to the whole village.
|
||
const builtType=(s,t)=>s.buildings.some(b=>b.built&&b.type===t);
|
||
const QUESTS=[
|
||
{id:'q_job',name:'Put a Mellow to work',hint:'Tap a Mellow and give them a job.',test:s=>s.mellows.some(m=>!isChild(m)&&m.job!=="idle")},
|
||
{id:'q_food',name:'Secure a food supply',hint:'Assign a food gatherer (Fruit Picker, Hunter or Fisher), or build a Farm.',test:s=>s.mellows.some(m=>!isChild(m)&&["fruit","hunter","fisher","farmer"].includes(m.job))||builtType(s,'farm')},
|
||
{id:'q_store',name:'Build a Storage',hint:'Build a Storage so workers have somewhere to drop resources.',test:s=>builtType(s,'storage')},
|
||
{id:'q_home2',name:'Build a second home',hint:'Homes shelter couples — more homes mean more families.',test:s=>s.buildings.filter(b=>b.built&&BUILD[b.type].house).length>=2},
|
||
{id:'q_saw',name:'Raise a Sawmill',hint:'A Sawmill makes planks (for advanced buildings) and firewood.',test:s=>builtType(s,'sawmill')},
|
||
{id:'q_warm',name:'Prepare for winter',hint:'Build a Campfire or a Stone House to keep Mellows warm.',test:s=>builtType(s,'campfire')||builtType(s,'stonehouse')},
|
||
{id:'q_lvl2',name:'Grow to a Hamlet',hint:'Reach 8 Mellows to level the village up to a Hamlet.',test:s=>villageLevel()>=2},
|
||
{id:'q_school',name:'Open a Schoolhouse',hint:'A Teacher raises clever children who become skilled workers.',test:s=>builtType(s,'school')},
|
||
{id:'q_wolf',name:'Befriend a wolf',hint:'Build a Wolf Den and tame a wolf with meat.',test:s=>(s.wolves||[]).some(w=>w.state==="trained"||w.state==="family")},
|
||
{id:'q_pop15',name:'A village of 15',hint:'Grow your village to 15 Mellows.',test:s=>s.mellows.length>=15}];
|
||
function questTick(){S.quests=S.quests||[];
|
||
for(const q of QUESTS){if(S.quests.indexOf(q.id)>=0)continue;if(q.test(S)){S.quests.push(q.id);
|
||
for(const m of S.mellows)m.happy=Math.min(100,m.happy+8);
|
||
toast("✅ Goal complete — "+q.name+"!");logEvent("Goal complete: "+q.name+" (+8 happiness to all).");
|
||
if(ui.sound)Sound.sfx('milestone');}}}
|
||
function newMellow(gx,gy,age){const sex=Math.random()<0.5?"male":"female";
|
||
const nm=sex==="female"?FEM_NAMES[(Math.random()*FEM_NAMES.length)|0]:NAMES[(Math.random()*NAMES.length)|0];
|
||
const trait=pickTrait();
|
||
let intel=age===0?2:(3+(Math.random()*3|0));if(trait==="bookish")intel=Math.min(10,intel+1);
|
||
return {id:S.nextId++,name:nm,surname:SURNAMES[(Math.random()*SURNAMES.length)|0],sex,gx,gy,tx:gx,ty:gy,job:"idle",
|
||
hunger:80,health:100,happy:70,warmth:100,age:age==null?18+(Math.random()*12|0):age,
|
||
sick:false,pregnant:false,pregT:0,contraT:0,intel,trait,hobby:pickHobby(),parents:null,gparents:[],momId:null,dadId:null,
|
||
carry:0,carryRes:null,home:null,state:"idle",target:null,timer:0,bob:Math.random()*6,wander:0,face:1};}
|
||
function isChild(m){return m.age<12;}
|
||
function CHILD_AGE(){return 12;}
|
||
const SCHOOL_SEASONS=["Fall","Winter"];
|
||
function schoolInSession(){return SCHOOL_SEASONS.indexOf(season())>=0;}
|
||
// intelligence 1-10 -> 0..1 skill factor (drives skilled-work speed; basic gathering ignores it)
|
||
function skill(m){let v=(((m.intel||3)-1)/9);if(m.trait==="bookish")v=Math.min(1,v+0.08);return v;}
|
||
|
||
/* ================= WORLDGEN ================= */
|
||
function genWorld(islandKey,diffKey){
|
||
const isle=ISLANDS[islandKey];
|
||
S={island:islandKey,diff:diffKey,nextId:1,day:1,seasonCount:1,tsec:0,seasonIdx:0,seasonT:0,
|
||
tiles:[],nodes:[],buildings:[],mellows:[],
|
||
res:{wood:50,planks:0,stone:50,iron:0,fruit:50,meat:50,fish:0,crops:0,corn:0,pumpkin:0,carrot:0,cabbage:0,meals:0,milk:0,herbs:0,potion:0,contra:0,wine:0,firewood:0,axe:3,pickaxe:3,bow:3,basket:3,hoe:3,wool:0,leather:0},
|
||
seeds:startSeeds(),treeRegrow:0,
|
||
log:[],birthT:20,sickT:35,wolves:[],graves:[],memorial:[],quests:[],newcomers:[],_nextNewcomer:3,festYr:0,anim:true,sound:false,villageName:randomVillageName()};
|
||
for(let y=0;y<GRID;y++){S.tiles[y]=[];for(let x=0;x<GRID;x++)S.tiles[y][x]="land";}
|
||
// organic coastline — carve the outer map into ocean so each island has a natural, varied shape (bays & capes), not a square
|
||
{const cx=(GRID-1)/2,cy=(GRID-1)/2,maxR=GRID*0.5;
|
||
const a1=Math.random()*6.283,a2=Math.random()*6.283,a3=Math.random()*6.283,a4=Math.random()*6.283;
|
||
for(let y=0;y<GRID;y++)for(let x=0;x<GRID;x++){
|
||
const dx=x-cx,dy=y-cy,d=Math.hypot(dx,dy),ang=Math.atan2(dy,dx);
|
||
const wob=1+0.20*Math.sin(ang*3+a1)+0.13*Math.sin(ang*5+a2)+0.10*Math.sin(ang*2+a3)+0.06*Math.sin(ang*7+a4);
|
||
if(d>maxR*0.92*wob)S.tiles[y][x]="water";}}
|
||
if(isle.water>=1){
|
||
for(let pi=0;pi<isle.water;pi++){
|
||
if(pi===0&&isle.water>=2){ // a winding river
|
||
let ry=3+(Math.random()*(GRID-6)|0);
|
||
for(let x=0;x<GRID;x++){let yy=ry+Math.round(Math.sin(x*0.5+pi)*2.5);
|
||
for(let d=0;d<(isle.water>=3?2:1);d++){let py=yy+d;if(py>=0&&py<GRID)S.tiles[py][x]="water";}}
|
||
} else { // an organic pond
|
||
const cx=2+(Math.random()*(GRID-4)|0),cy=2+(Math.random()*(GRID-4)|0),rad=1.4+Math.random()*1.6;
|
||
for(let y=0;y<GRID;y++)for(let x=0;x<GRID;x++)
|
||
if(Math.hypot(x-cx,y-cy)<rad+Math.random()*0.7)S.tiles[y][x]="water";}}}
|
||
const DENS=Math.max(1,(GRID*GRID)/(18*18));
|
||
const w=isle.weights,entries=[];for(const k in w){const n=Math.round(w[k]*DENS);for(let i=0;i<n;i++)entries.push(k);}
|
||
const occ={},key=(x,y)=>x+","+y;let tries=entries.length*6,placed=0;
|
||
while(placed<entries.length&&tries-->0){const t=entries[placed],x=(Math.random()*GRID)|0,y=(Math.random()*GRID)|0;
|
||
const isWater=S.tiles[y][x]==="water",wantWater=NODE[t].water;
|
||
if(occ[key(x,y)]){placed++;continue;}
|
||
if(wantWater&&!isWater){continue;}if(!wantWater&&isWater){continue;}
|
||
occ[key(x,y)]=true;S.nodes.push({gx:x,gy:y,type:t,amount:NODE[t].max,dormant:false,respawn:0,seed:Math.random()});placed++;}
|
||
// found the village on the largest landmass (near its centre) so it's never stranded on a small fragment
|
||
let sx,sy;{
|
||
const seen=[];for(let y=0;y<GRID;y++)seen[y]=new Array(GRID).fill(false);
|
||
let best=null;
|
||
for(let y=0;y<GRID;y++)for(let x=0;x<GRID;x++){
|
||
if(S.tiles[y][x]!=="land"||seen[y][x])continue;
|
||
const comp=[],q=[[x,y]];seen[y][x]=true;
|
||
while(q.length){const c=q.pop();comp.push(c);
|
||
for(const d of [[1,0],[-1,0],[0,1],[0,-1]]){const nx=c[0]+d[0],ny=c[1]+d[1];
|
||
if(nx>=0&&ny>=0&&nx<GRID&&ny<GRID&&S.tiles[ny][nx]==="land"&&!seen[ny][nx]){seen[ny][nx]=true;q.push([nx,ny]);}}}
|
||
if(!best||comp.length>best.length)best=comp;}
|
||
best=best||[[(GRID/2)|0,(GRID/2)|0]];
|
||
const mx=best.reduce((a,t)=>a+t[0],0)/best.length, my=best.reduce((a,t)=>a+t[1],0)/best.length;
|
||
const fits=([x,y])=>x+1<GRID&&y+1<GRID&&S.tiles[y][x+1]==="land"&&S.tiles[y+1]&&S.tiles[y+1][x]==="land"&&S.tiles[y+1][x+1]==="land";
|
||
const cand=best.filter(fits).sort((a,b)=>Math.hypot(a[0]-mx,a[1]-my)-Math.hypot(b[0]-mx,b[1]-my));
|
||
[sx,sy]=cand[0]||best[0];}
|
||
S.nodes=S.nodes.filter(n=>Math.abs(n.gx-sx)>2||Math.abs(n.gy-sy)>2);
|
||
addBuilding("storage",sx,sy,true);addBuilding("house",sx+1,sy,true);
|
||
for(let i=0;i<4;i++){const m=newMellow(sx+(i-1.5)*0.55,sy+1);m.sex=i%2?"female":"male";S.mellows.push(m);} // start with 4 founders (2 male, 2 female)
|
||
assignHomes();weddingCheck(); // baseline the founder couple silently
|
||
const nw=isle.wolves||0;S.dens=[];
|
||
if(nw>0){for(let d=0;d<2;d++){let dx,dy,g=0;do{dx=1+(Math.random()*(GRID-2)|0);dy=1+(Math.random()*(GRID-2)|0);g++;}
|
||
while(g<80&&(S.tiles[dy][dx]==="water"||Math.abs(dx-sx)+Math.abs(dy-sy)<7));S.dens.push({gx:dx,gy:dy});}
|
||
for(let i=0;i<nw;i++){const p=i%2,den=S.dens[p];
|
||
S.wolves.push(newWolf(...wolfSpawnPos(den),{state:"wild",packId:p,adult:true}));}}
|
||
computeReach();overShown=false;cam.z=1;centerCamera(sx,sy);
|
||
}
|
||
function addBuilding(type,gx,gy,built){const b={id:S.nextId++,type,gx,gy,built:!!built,prog:built?1:0,phase:0};S.buildings.push(b);return b;}
|
||
|
||
/* ================= ISO ================= */
|
||
function isoX(gx,gy){return (gx-gy)*(TILE_W/2);}
|
||
function isoY(gx,gy){return (gx+gy)*(TILE_H/2);}
|
||
function worldToScreen(gx,gy){return {x:isoX(gx,gy),y:isoY(gx,gy)};} // world/iso space; render applies pan+zoom via ctx transform
|
||
function screenToGrid(sx,sy){const z=cam.z||1,x=(sx-cam.x)/z,y=(sy-cam.y)/z;
|
||
return {gx:(x/(TILE_W/2)+y/(TILE_H/2))/2,gy:(y/(TILE_H/2)-x/(TILE_W/2))/2};}
|
||
function centerCamera(gx,gy){const z=cam.z||1;cam.x=VW/2-isoX(gx,gy)*z;cam.y=VH/2-isoY(gx,gy)*z-30;}
|
||
function zoomAt(sx,sy,nz){nz=Math.max(0.6,Math.min(2.4,nz));const wx=(sx-cam.x)/cam.z,wy=(sy-cam.y)/cam.z;
|
||
cam.z=nz;cam.x=sx-wx*nz;cam.y=sy-wy*nz;}
|
||
|
||
/* ================= HELPERS ================= */
|
||
function difficulty(){return DIFFS[S.diff];}
|
||
function season(){return SEASONS[S.seasonIdx];}
|
||
function isWinter(){return season()==="Winter";}
|
||
function dist(ax,ay,bx,by){const dx=ax-bx,dy=ay-by;return Math.sqrt(dx*dx+dy*dy);}
|
||
function totalFood(){return FOOD_TYPES.reduce((s,f)=>s+(S.res[f]||0),0);}
|
||
function houseCapacity(){return S.buildings.filter(b=>b.built&&BUILD[b.type].house).reduce((s,b)=>s+BUILD[b.type].house,0);}
|
||
function countJob(j){return S.mellows.filter(m=>m.job===j&&!isChild(m)).length;}
|
||
function adultCount(){return S.mellows.filter(m=>!isChild(m)).length;}
|
||
function tamedWolves(){return S.wolves.filter(w=>w.state==="family").length;}
|
||
// wolf helpers ---------------------------------------------------------------
|
||
function newWolf(gx,gy,opt){opt=opt||{};const sl=difficulty().season;
|
||
return {id:S.nextId++,gx,gy,tx:gx,ty:gy,timer:0,atkCd:0,face:1,
|
||
sex:Math.random()<0.5?"male":"female",
|
||
bornAt:(S.tsec||0)-(opt.adult?sl*2:0),
|
||
state:opt.state||"wild", // wild | training | trained | family
|
||
bornTame:!!opt.bornTame,
|
||
trainT:opt.trainT||0, facId:opt.facId||null, homeId:opt.homeId||null,
|
||
packId:opt.packId==null?null:opt.packId};}
|
||
function wolfAge(w){return ((S.tsec||0)-(w.bornAt||0))/difficulty().season;} // in seasons
|
||
function wolfBaby(w){return w.bornTame&&wolfAge(w)<1;} // "baby" = facility/home-born, under 1 season
|
||
function wolfAdoptable(w){return w.state==="trained"||(w.state==="training"&&w.bornTame&&wolfAge(w)>=1);}
|
||
function facilityWolves(id){return S.wolves.filter(w=>w.facId===id&&(w.state==="training"||w.state==="trained"));}
|
||
function familyWolves(homeId){return S.wolves.filter(w=>w.state==="family"&&w.homeId===homeId);}
|
||
function homeHasWolf(homeId){return homeId&&S.wolves.some(w=>w.state==="family"&&w.homeId===homeId);}
|
||
function wolfCentres(){return S.buildings.filter(b=>b.built&&BUILD[b.type].wolfcentre);}
|
||
function isOrphan(m){return isChild(m)&&!(m.momId&&S.mellows.some(x=>x.id===m.momId))&&!(m.dadId&&S.mellows.some(x=>x.id===m.dadId));}
|
||
// one-tap adoption → the family that needs a wolf most (has children, has room, fewest wolves)
|
||
function adoptFromCentre(f){const ready=facilityWolves(f.id).filter(wolfAdoptable);
|
||
if(!ready.length){toast("No wolves are ready to adopt yet");return;}
|
||
let best=null,bestScore=-1;
|
||
for(const h of S.buildings.filter(b=>b.built&&BUILD[b.type].house)){const fam=familyWolves(h.id).length;
|
||
if(fam>=FAMILY_WOLF_CAP)continue;const res=S.mellows.filter(m=>m.home===h.id);if(!res.length)continue;
|
||
const score=(res.some(isChild)?100:0)+(FAMILY_WOLF_CAP-fam)*10;if(score>bestScore){bestScore=score;best=h;}}
|
||
if(!best){toast("No family has room for a wolf right now");return;}
|
||
const w=ready[0];w.state="family";w.homeId=best.id;w.facId=null;w.trainT=0;
|
||
const head=S.mellows.find(m=>m.home===best.id&&!isChild(m));
|
||
toast("A wolf joined "+(head?head.name+"'s family":"a family")+"!");logEvent("A family adopted a trained wolf");}
|
||
// If a family's home is demolished, its wolves return to a Wolf Den (else revert to the wild).
|
||
function reclaimOrphanWolves(){for(const w of S.wolves){if(w.state!=="family")continue;
|
||
if(S.buildings.find(b=>b.id===w.homeId&&b.built&&BUILD[b.type].house))continue; // home still stands
|
||
const c=wolfCentres().find(f=>facilityWolves(f.id).length<FACILITY_CAP);
|
||
if(c){w.state="trained";w.homeId=null;w.facId=c.id;w.trainT=0;logEvent("A wolf returned to the Wolf Den after its family's home was lost.");}
|
||
else{w.state="wild";w.homeId=null;w.facId=null;w.packId=0;}}}
|
||
function homeValid(m){return m.home&&S.buildings.some(b=>b.id===m.home&&b.built);}
|
||
function partnerOf(m){if(isChild(m)||!m.home)return null;return S.mellows.find(x=>x!==m&&x.home===m.home&&!isChild(x)&&x.sex!==m.sex)||null;}
|
||
function childrenOf(m){if(!m.home)return[];return S.mellows.filter(x=>x.home===m.home&&isChild(x));}
|
||
function friendsOf(m){return(m.friends||[]).map(id=>S.mellows.find(x=>x.id===id)).filter(Boolean);}
|
||
function friendNames(m){return friendsOf(m).map(f=>f.name).join(", ");}
|
||
// Light friendships: adults befriend a nearby non-family adult over time (max 2 each). Being near a friend lifts the mood.
|
||
function friendshipTick(dt){S._friendT=(S._friendT||0)-dt;if(S._friendT>0)return;S._friendT=6;
|
||
const adults=S.mellows.filter(x=>!isChild(x));
|
||
for(const m of adults){m.friends=m.friends||[];
|
||
if(m.friends.length>=2||Math.random()>0.3)continue;
|
||
const cand=adults.filter(o=>o.id!==m.id&&!m.friends.includes(o.id)&&!(m.home&&o.home===m.home)&&(o.friends||[]).length<2&&dist(m.gx,m.gy,o.gx,o.gy)<5)
|
||
.sort((a,b)=>dist(m.gx,m.gy,a.gx,a.gy)-dist(m.gx,m.gy,b.gx,b.gy));
|
||
const f=cand[0];
|
||
if(f){f.friends=f.friends||[];m.friends.push(f.id);f.friends.push(m.id);logEvent(m.name+" and "+f.name+" became friends.");}}}
|
||
function nearest(gx,gy,arr,pred){let best=null,bd=1e9;for(const o of arr){if(pred&&!pred(o))continue;
|
||
const d=dist(gx,gy,o.gx,o.gy);if(d<bd){bd=d;best=o;}}return best;}
|
||
function storages(){return S.buildings.filter(b=>b.built&&(BUILD[b.type].store||b.type==="storage"));}
|
||
function buildingsOfJob(job){const bt=JOBS[job]&&JOBS[job].bld;return S.buildings.filter(b=>b.built&&b.type===bt);}
|
||
function toast(msg){const t=document.getElementById("toast");t.textContent=msg;t.classList.add("show");
|
||
clearTimeout(t._t);t._t=setTimeout(()=>t.classList.remove("show"),1900);}
|
||
function logEvent(msg){S.log.unshift({d:S.day,m:msg});if(S.log.length>40)S.log.pop();}
|
||
|
||
/* ================= AI ================= */
|
||
function canGather(job){const j=JOBS[job];if(!j||j.kind!=="gather")return false;if(j.seasonal&&isWinter())return false;return true;}
|
||
function nodeAvailable(n){if(n.dormant||n.amount<=0)return false;const t=NODE[n.type];if(t.plant&&isWinter()&&!t.farmed)return false;
|
||
if(t.water)return [[1,0],[-1,0],[0,1],[0,-1]].some(d=>reachable(n.gx+d[0],n.gy+d[1])); // fish only if a reachable shore is adjacent
|
||
if(!reachable(n.gx,n.gy))return false;return true;}
|
||
function updateMellow(m,dt){
|
||
if(isChild(m)){m.age+=dt*0.22;m.hunger=Math.max(0,m.hunger-dt*0.8);
|
||
if(m.hunger<25&&totalFood()>0){for(const f of FOOD_TYPES)if(S.res[f]>0){S.res[f]--;m.hunger=Math.min(100,m.hunger+foodFill(f));dropSeed(f);break;}}
|
||
const canSchool=m.age>=6&&schoolInSession()&&schooling();
|
||
if(canSchool){
|
||
const sch=nearest(m.gx,m.gy,S.buildings.filter(b=>b.built&&b.type==="school"));
|
||
if(sch){if(dist(m.gx,m.gy,sch.gx,sch.gy)>1.1){m.tx=sch.gx+(hash2(m.id,1)-0.5)*1.5;m.ty=sch.gy+(hash2(m.id,2)-0.5)*1.5;}
|
||
else {m.schooled=true;let rate=0.34;if(m.trait==="bookish")rate*=1.4;
|
||
if(S.mellows.some(t=>t.job==="teacher"&&isElder(t)))rate*=1.4; // elder mentor
|
||
m.intel=Math.min(10,(m.intel||2)+dt*rate);}}else wanderStep(m,dt);
|
||
} else { // not in school — toddle near a living parent, else near home
|
||
const par=(m.momId&&S.mellows.find(x=>x.id===m.momId))||(m.dadId&&S.mellows.find(x=>x.id===m.dadId));
|
||
if(par){m.wander-=dt;if(m.wander<=0){m.wander=1+Math.random()*2;m.tx=par.gx+(Math.random()*1.5-0.75);m.ty=par.gy+(Math.random()*1.5-0.75);}}
|
||
else wanderStep(m,dt);
|
||
}
|
||
moveToward(m,dt);
|
||
if(m.age>=12){m.job="idle";m.home=null;
|
||
logEvent(m.name+" came of age"+((m.intel||0)>=7?" — bright and well-schooled!":"!"));
|
||
toast("🎂 "+m.name+" came of age!");m.happy=Math.min(100,(m.happy||60)+15);
|
||
if(S.festT<=0){S.festT=3;S.festKind="confetti";}if(ui.sound)Sound.sfx('festival');}return;}
|
||
m.age+=dt/90;const dk=difficulty().decay;
|
||
if(m.contraT>0)m.contraT=Math.max(0,m.contraT-dt);
|
||
m.hunger=Math.max(0,m.hunger-dt*1.7*dk);
|
||
if(isWinter())m.warmth+=(warmthProvided()?8:-6)*dt*dk;else m.warmth+=10*dt;
|
||
m.warmth=Math.max(0,Math.min(100,m.warmth));
|
||
let hd=0;if(m.hunger<=0)hd-=4;if(m.warmth<25)hd-=3;if(m.sick)hd-=3.5;
|
||
if(hd<0){if(m.trait==="hardy")hd*=0.6;else if(m.trait==="sickly")hd*=1.4;}
|
||
if(hd===0&&m.health<100)hd+=1.2;
|
||
m.health=Math.max(0,Math.min(100,m.health+hd*dt));
|
||
let tg=60;if(m.job!=="idle")tg+=12;if(totalFood()>S.mellows.length*2)tg+=10;else tg-=8;
|
||
if(homeHasWolf(m.home))tg+=8; // a family wolf brings joy
|
||
if(m.friends&&m.friends.length)for(const fid of m.friends){const f=S.mellows.find(x=>x.id===fid);if(f&&dist(m.gx,m.gy,f.gx,f.gy)<4){tg+=8;break;}} // a friend nearby lifts the mood
|
||
tg+=(S._beauty||0); // a beautiful, decorated village lifts everyone's spirits
|
||
if(m.hunger<25)tg-=20;if(isWinter()&&m.warmth<40)tg-=15;if(m.sick)tg-=20;if(S.res.wine>0)tg+=8;
|
||
if(m.trait==="cheery")tg+=12;
|
||
{const hb=HOBBIES[m.hobby];if(hb&&hb.job&&m.job===hb.job)tg+=6;} // doing the work they love lifts the spirits
|
||
m.happy=Math.max(0,Math.min(100,m.happy+(tg-m.happy)*Math.min(1,dt*0.3)));
|
||
if(m.health<=0){killMellow(m,"fell ill and passed");return;}
|
||
if(m.age>=75){killMellow(m,"passed of old age");return;} // hard ceiling — nobody lives past 75
|
||
if(m.age>=60){ // old age creeps in from 60; most pass in their 60s, the healthy & lucky reach 70
|
||
let h=0.0016*(1+(m.age-60)*0.06);
|
||
if(m.trait==="hardy")h*=0.7;else if(m.trait==="sickly")h*=1.5;
|
||
if(m.health>85)h*=0.55;else if(m.health<40)h*=1.6;
|
||
if(Math.random()<h*dt){killMellow(m,"passed of old age");return;}}
|
||
const j=JOBS[m.job];
|
||
if(m.hunger<28&&totalFood()>0&&m.state!=="goeat"){
|
||
const eatSpot=nearest(m.gx,m.gy,storages())||S.buildings.find(b=>b.built&&BUILD[b.type].house);
|
||
if(eatSpot){m.state="goeat";m.tx=eatSpot.gx;m.ty=eatSpot.gy;}}
|
||
if(m.state==="goeat"){moveToward(m,dt);if(arrived(m)){eat(m);m.state="idle";}return;}
|
||
behaveByJob(m,j,dt);
|
||
}
|
||
function behaveByJob(m,j,dt){
|
||
if(!j||m.job==="idle"){wanderStep(m,dt);moveToward(m,dt);return;}
|
||
if(j.kind==="gather")return gatherLoop(m,j,dt);
|
||
if(j.kind==="builder")return builderLoop(m,dt);
|
||
if(j.kind==="produce")return produceLoop(m,j,dt);
|
||
if(j.kind==="doctor")return serviceLoop(m,"medical",dt,()=>doctorTick(m,dt));
|
||
if(j.kind==="midwife")return serviceLoop(m,"nursery",dt,()=>{});
|
||
if(j.kind==="teacher")return serviceLoop(m,"school",dt,()=>{});
|
||
if(j.kind==="trainer")return serviceLoop(m,"wolfcentre",dt,()=>{});
|
||
wanderStep(m,dt);moveToward(m,dt);
|
||
}
|
||
function schooling(){return countJob("teacher")>0&&S.buildings.some(b=>b.built&&b.type==="school");}
|
||
function avgSkill(job){const w=S.mellows.filter(m=>m.job===job&&!isChild(m));return w.length?w.reduce((s,m)=>s+skill(m),0)/w.length:0;}
|
||
// basket sets carry capacity: none=6, basket=10, basket+schooled=15
|
||
function carryCap(m){return (S.res.basket||0)>0 ? (m.schooled?15:10) : 6;}
|
||
function gatherLoop(m,j,dt){
|
||
if(!canGather(m.job)){wanderStep(m,dt);moveToward(m,dt);return;}
|
||
if(m.carry>=carryCap(m)){const st=nearest(m.gx,m.gy,storages());
|
||
if(st){m.tx=st.gx;m.ty=st.gy;moveToward(m,dt);
|
||
if(arrived(m)){S.res[m.carryRes]=(S.res[m.carryRes]||0)+m.carry;m.carry=0;m.carryRes=null;}return;}}
|
||
if(!m.target||S.nodes.indexOf(m.target)<0||!nodeAvailable(m.target))
|
||
m.target=nearest(m.gx,m.gy,S.nodes,n=>n.type===j.node&&nodeAvailable(n));
|
||
if(!m.target){wanderStep(m,dt);moveToward(m,dt);return;}
|
||
m.tx=m.target.gx;m.ty=m.target.gy;moveToward(m,dt);
|
||
const atNode=NODE[m.target.type].water?dist(m.gx,m.gy,m.target.gx,m.target.gy)<1.6:arrived(m); // fishers fish from the adjacent shore
|
||
if(atNode){m.timer+=dt;const hb=0;let thr=Math.max(0.55,1.1-hb*0.18);
|
||
if(m.trait==="green"&&NODE[m.target.type].plant)thr*=0.7;
|
||
const tk=JOB_TOOL[m.job];if(tk&&(S.res[tk]||0)>0)thr*=0.6; // right tool in stock → faster
|
||
if(m.timer>thr){m.timer=0;
|
||
let base=2;if(m.target.type==="deer")base=2+(Math.random()<0.5?1:0);else if(m.target.type==="sheep")base=1+(Math.random()<0.5?1:0); // deer drop 2-3, sheep 1-2
|
||
const yld=base+hb;m.target.amount-=2;m.carry+=yld;m.carryRes=j.res;
|
||
if(m.job==="hunter"&&Math.random()<0.5)S.res.leather++;
|
||
if(m.target.amount<=0){m.target.amount=0; // collected / hunted out → despawn here and respawn at a random new spot
|
||
m.target.dormant=true;m.target.respawn=NODE[m.target.type].animal?25:14;
|
||
relocateNode(m.target);m.target=null;}}}
|
||
}
|
||
function builderLoop(m,dt){const site=nearest(m.gx,m.gy,S.buildings,b=>!b.built);
|
||
if(!site){wanderStep(m,dt);moveToward(m,dt);return;}
|
||
m.tx=site.gx;m.ty=site.gy;moveToward(m,dt);
|
||
const atSite=BUILD[site.type].bridge?dist(m.gx,m.gy,site.gx,site.gy)<1.7:arrived(m); // bridges are built from the adjacent shore (can't stand on open water)
|
||
if(atSite){site.prog+=dt*0.18;if(site.prog>=1){site.built=true;site.prog=1;logEvent("Built a "+BUILD[site.type].name);computeReach();if(ui.sound)Sound.sfx('build');}}}
|
||
function produceLoop(m,j,dt){const b=nearest(m.gx,m.gy,buildingsOfJob(m.job));
|
||
if(!b){wanderStep(m,dt);moveToward(m,dt);return;}
|
||
m.tx=b.gx;m.ty=b.gy;moveToward(m,dt);
|
||
if(arrived(m)){const info=BUILD[b.type];
|
||
if(info.craftTools){const tk=b.makeTool||"axe",rc=TOOLS[tk];
|
||
let can=true;for(const k in rc.cost)if((S.res[k]||0)<rc.cost[k])can=false;
|
||
if(can){m.timer+=dt*(1+skill(m)*1.2)*upgMult(b);if(m.timer>=(info.recipe.t||3.2)){m.timer=0;
|
||
for(const k in rc.cost)S.res[k]-=rc.cost[k];S.res[tk]=(S.res[tk]||0)+1;b.phase=(b.phase+1)%4;}}return;}
|
||
const r=info.recipe;if(!r)return;
|
||
let can=true;for(const k in r.in)if((S.res[k]||0)<r.in[k])can=false;
|
||
if(can){m.timer+=dt*(1+skill(m)*1.2)*upgMult(b);if(m.timer>=r.t){m.timer=0;
|
||
for(const k in r.in)S.res[k]-=r.in[k];for(const k in r.out)S.res[k]=(S.res[k]||0)+r.out[k];
|
||
if(r.bonus&&Math.random()<r.bonus.chance)S.res[r.bonus.res]=(S.res[r.bonus.res]||0)+1;b.phase=(b.phase+1)%4;}}}}
|
||
function serviceLoop(m,bld,dt,onTick){const b=nearest(m.gx,m.gy,S.buildings.filter(x=>x.built&&x.type===bld));
|
||
if(!b){wanderStep(m,dt);moveToward(m,dt);return;}
|
||
m.tx=b.gx;m.ty=b.gy;moveToward(m,dt);if(arrived(m))onTick();}
|
||
function doctorTick(m,dt){const sick=S.mellows.find(x=>x.sick);
|
||
if(sick&&S.res.potion>0){m.timer+=dt;if(m.timer>2/(1+skill(m)*1.2)){m.timer=0;S.res.potion--;sick.sick=false;
|
||
sick.health=Math.min(100,sick.health+40+skill(m)*35);logEvent(sick.name+" was healed");}}}
|
||
function foodFill(f){return f==="meals"?70:f==="meat"?33:45;} // meat is lean — ~3 pieces fully refill hunger
|
||
function eat(m){for(const f of FOOD_TYPES)if(S.res[f]>0){S.res[f]--;m.hunger=Math.min(100,m.hunger+foodFill(f));dropSeed(f);return;}}
|
||
// eating a crop drops seeds of its own kind: 1/5 none, 2/5 one, 2/5 two
|
||
function dropSeed(f){if(CROP_ORDER.indexOf(f)<0)return;S.seeds=S.seeds||startSeeds();
|
||
const r=Math.random(),n=r<0.2?0:(r<0.6?1:2);if(n)S.seeds[f]=(S.seeds[f]||0)+n;}
|
||
function killMellow(m,why){S.mellows=S.mellows.filter(x=>x!==m);
|
||
S.memorial=S.memorial||[];S.memorial.unshift({name:m.name,surname:m.surname||"",age:Math.floor(m.age),why,day:S.day});
|
||
if(S.memorial.length>80)S.memorial.pop();
|
||
const cem=S.buildings.find(b=>b.built&&BUILD[b.type].cemetery);
|
||
if(cem){S.graves=S.graves||[];const i=S.graves.length;
|
||
if(i<16){const col=i%4,row=(i/4|0); // neat 4×4 plot beside the cemetery — never scattered
|
||
S.graves.push({gx:cem.gx-0.54+col*0.36,gy:cem.gy+0.66+row*0.4,name:m.name,seed:Math.random()});}}
|
||
logEvent(m.name+" "+(m.surname||"")+" "+why);toast(m.name+" "+why);if(ui.sound)Sound.sfx('death');}
|
||
// ---- tile pathfinding: Mellows walk only on land (or a bridge), never across open water ----
|
||
function bridgeAt(x,y){return S.buildings.some(b=>b.built&&BUILD[b.type].bridge&&Math.round(b.gx)===x&&Math.round(b.gy)===y);}
|
||
function walkableTile(x,y){return x>=0&&y>=0&&x<GRID&&y<GRID&&(S.tiles[y][x]==="land"||bridgeAt(x,y));}
|
||
function nearestWalkableNeighbor(gx,gy,fx,fy){let best=null,bd=1e9;gx=Math.round(gx);gy=Math.round(gy);
|
||
for(const d of [[1,0],[-1,0],[0,1],[0,-1]]){const nx=gx+d[0],ny=gy+d[1];
|
||
if(reachable(nx,ny)&&walkableTile(nx,ny)){const dd=dist(fx,fy,nx,ny);if(dd<bd){bd=dd;best=[nx,ny];}}}return best;} // reachable shore only
|
||
function findPath(sx,sy,gx,gy){sx=Math.round(sx);sy=Math.round(sy);gx=Math.round(gx);gy=Math.round(gy);
|
||
if(sx===gx&&sy===gy)return [];
|
||
if(!walkableTile(sx,sy)||!walkableTile(gx,gy))return null;
|
||
const W=GRID,seen=new Uint8Array(W*W),prev=new Int32Array(W*W),q=new Int32Array(W*W);
|
||
let qh=0,qt=0;const si=sx+sy*W;seen[si]=1;prev[si]=-1;q[qt++]=si;
|
||
while(qh<qt){const c=q[qh++],x=c%W,y=(c/W)|0;
|
||
if(x===gx&&y===gy){const path=[];let cur=c;while(cur!==si&&cur!==-1){path.push([cur%W,(cur/W)|0]);cur=prev[cur];}return path.reverse();}
|
||
for(const d of [[1,0],[-1,0],[0,1],[0,-1]]){const nx=x+d[0],ny=y+d[1];if(nx<0||ny<0||nx>=W||ny>=W)continue;
|
||
const ni=nx+ny*W;if(!seen[ni]&&walkableTile(nx,ny)){seen[ni]=1;prev[ni]=c;q[qt++]=ni;}}}
|
||
return null;}
|
||
function moveToward(m,dt){const sp=1.4;
|
||
let goalX=m.tx,goalY=m.ty,exact=true;
|
||
if(!walkableTile(Math.round(m.tx),Math.round(m.ty))){ // target is on water (e.g. a fish): stand on the nearest shore
|
||
const n=nearestWalkableNeighbor(m.tx,m.ty,m.gx,m.gy);
|
||
if(n){goalX=n[0];goalY=n[1];exact=false;}}
|
||
const gk=Math.round(goalX)+Math.round(goalY)*GRID;
|
||
if(m._pg!==gk){m._pg=gk;m._path=findPath(m.gx,m.gy,goalX,goalY);m._pi=0;} // (re)route only when the goal tile changes
|
||
let tx,ty;
|
||
if(m._path&&m._path.length){ // follow waypoints around the water
|
||
while(m._pi<m._path.length&&dist(m.gx,m.gy,m._path[m._pi][0],m._path[m._pi][1])<0.3)m._pi++;
|
||
if(m._pi<m._path.length){tx=m._path[m._pi][0];ty=m._path[m._pi][1];}
|
||
else{tx=exact?m.tx:goalX;ty=exact?m.ty:goalY;}
|
||
} else {tx=exact?m.tx:goalX;ty=exact?m.ty:goalY;} // already on the goal tile, or no path → straight line (graceful fallback)
|
||
const dx=tx-m.gx,dy=ty-m.gy,d=Math.hypot(dx,dy);
|
||
if(d<0.05){m.gx=tx;m.gy=ty;return;}if(Math.abs(dx)>0.02)m.face=dx>0?1:-1;
|
||
const step=Math.min(d,sp*dt),nx=m.gx+dx/d*step,ny=m.gy+dy/d*step;
|
||
if(walkableTile(Math.round(nx),Math.round(ny))||!walkableTile(Math.round(m.gx),Math.round(m.gy))){m.gx=nx;m.gy=ny;}} // hard rule: never step onto open water
|
||
// A depleted node despawns and respawns at a random new spot — land nodes on reachable land, fish on water by a reachable shore.
|
||
function relocateNode(n){const t=NODE[n.type];
|
||
for(let tries=0;tries<100;tries++){const x=(Math.random()*GRID)|0,y=(Math.random()*GRID)|0;
|
||
const isWater=S.tiles[y][x]==="water";
|
||
if(t.water){if(!isWater)continue;
|
||
if(![[1,0],[-1,0],[0,1],[0,-1]].some(d=>reachable(x+d[0],y+d[1])))continue;} // fish: water beside a reachable shore
|
||
else{if(isWater||!reachable(x,y))continue;} // land node: reachable land
|
||
if(tileTaken(x,y))continue;if(S.nodes.some(o=>o!==n&&Math.round(o.gx)===x&&Math.round(o.gy)===y))continue;
|
||
n.gx=x;n.gy=y;n.seed=Math.random();return true;}
|
||
return false;}
|
||
function arrived(m){return Math.hypot(m.tx-m.gx,m.ty-m.gy)<0.15;}
|
||
function wanderStep(m,dt){m.wander-=dt;if(m.wander<=0){m.wander=1+Math.random()*2;
|
||
const h=S.buildings.find(b=>b.id===m.home)||S.buildings[0];const hx=h?h.gx:GRID/2,hy=h?h.gy:GRID/2;
|
||
m.tx=hx+(Math.random()*2-1);m.ty=hy+(Math.random()*2-1);}}
|
||
|
||
/* ================= SIM ================= */
|
||
function step(dt){if(!S)return;S.tsec+=dt;
|
||
if(S.tsec-(S._lastDay||0)>12){S._lastDay=S.tsec;S.day++;}
|
||
S.seasonT+=dt;if(S.seasonT>=difficulty().season){S.seasonT=0;S.seasonIdx=(S.seasonIdx+1)%4;S.seasonCount++;onSeasonChange();}
|
||
for(const n of S.nodes)if(n.dormant){n.respawn-=dt;if(n.respawn<=0){n.dormant=false;n.amount=NODE[n.type].max*(NODE[n.type].animal?0.6:1);}}
|
||
farmTick(dt);for(const m of S.mellows.slice())updateMellow(m,dt);
|
||
birthTick(dt);diseaseTick(dt);wolfTick(dt);burnFirewood(dt);friendshipTick(dt);
|
||
alertTick(dt);immigrationTick(dt);milestoneTick();questTick();
|
||
if(S.festT>0)S.festT-=dt;
|
||
if((S._weaT=(S._weaT||18)-dt)<=0){S._weaT=28+Math.random()*40;S.weather=(!isWinter()&&Math.random()<0.35)?"rain":"clear";}
|
||
if(isWinter())S.weather="clear"; // winter shows its own snow, not rain
|
||
{const vl=villageLevel();if(vl>(S.vlevel||1)){for(let L=(S.vlevel||1)+1;L<=vl;L++)onLevelUp(L);S.vlevel=vl;}}
|
||
S._beauty=Math.min(8,S.buildings.filter(b=>b.built&&BUILD[b.type].decor).length); // decorations beautify the village
|
||
if(ui.sound&&(S._ambT=(S._ambT||3)-dt)<=0){ // time-of-day ambient: crickets at night, birdsong by day
|
||
const aph=((S.tsec||0)%TOD_LEN)/TOD_LEN, anight=aph>0.6&&aph<0.9;
|
||
if(isWinter())S._ambT=7+Math.random()*4;
|
||
else if(anight){S._ambT=2+Math.random()*2.5;Sound.sfx('cricket');}
|
||
else {S._ambT=5+Math.random()*4;if(Math.random()<0.6)Sound.sfx('birds');}}
|
||
if((S._homeT=(S._homeT||0)-dt)<=0){S._homeT=1.5;assignHomes();weddingCheck();reclaimOrphanWolves();computeReach();}}
|
||
function onSeasonChange(){const s=season();toast(s+" has arrived");logEvent(s+" begins");
|
||
regrowTrees();growHerds();wolfSeason();
|
||
if(s==="Spring"){S._wintersSurvived=(S._wintersSurvived||0)+1;
|
||
for(const n of S.nodes)if(NODE[n.type].plant&&!NODE[n.type].farmed){n.dormant=false;n.amount=NODE[n.type].max;}
|
||
plantFarms();
|
||
for(const m of S.mellows)m.happy=Math.min(100,m.happy+15);S.festT=4;S.festKind="petal";
|
||
toast("Spring Bloom — flowers everywhere!");logEvent("Spring Bloom — blossoms open across the village.");if(ui.sound)Sound.sfx('festival');}
|
||
if(s==="Fall"){harvestFarms();
|
||
for(const m of S.mellows)m.happy=Math.min(100,m.happy+18);S.festT=4;S.festKind="leaf";
|
||
toast("The Harvest Festival — a bountiful autumn!");logEvent("The village held a Harvest Festival — the larder is full.");if(ui.sound)Sound.sfx('festival');}
|
||
if(s==="Winter"){for(const m of S.mellows)if(isChild(m))m.happy=Math.min(100,m.happy+10);
|
||
toast("❄ The first snow falls — bundle up!");logEvent("The first snow of winter falls — the children are delighted.");}
|
||
if(s==="Summer"){for(const m of S.mellows)m.happy=Math.min(100,m.happy+25);S.festT=4;S.festKind="confetti";
|
||
toast("The Summer Festival — everyone is merry!");logEvent("The village held a Summer Festival — spirits soar!");if(ui.sound)Sound.sfx('festival');}
|
||
if(S.seasonCount>=(S._nextNewcomer||3)){S._nextNewcomer=S.seasonCount+2+(Math.random()*2|0);newcomerEvent();} // newcomers every 2–3 seasons
|
||
}
|
||
// The village earns a grander title as population grows (celebration only — nothing is unlocked).
|
||
function onLevelUp(lv){const unlocked=BUILD_ORDER.filter(t=>bldLevel(t)===lv).map(t=>BUILD[t].name);
|
||
for(const m of S.mellows)m.happy=Math.min(100,m.happy+12);
|
||
S.festT=4;S.festKind="confetti"; // a celebratory burst on level-up
|
||
const title=VILLAGE_TITLES[Math.min(VILLAGE_TITLES.length-1,lv-1)];
|
||
toast("🎉 Level "+lv+" — now a "+title+"!");
|
||
logEvent((S.villageName||"The village")+" grew to Level "+lv+" — now a "+title+(unlocked.length?", unlocking "+unlocked.join(", "):"")+".");
|
||
if(ui.sound)Sound.sfx('milestone');}
|
||
// Farms need a Farmer to tend them: min(farmers,farms) get sown each spring, harvested each fall.
|
||
function plantFarms(){S.seeds=S.seeds||startSeeds();const farms=S.buildings.filter(b=>b.built&&BUILD[b.type].farm);
|
||
let hands=countJob("farmer"),sown=0;
|
||
for(const b of farms){if(hands<=0)break;if((b.stage||"empty")==="growing")continue;
|
||
const c=b.crop||"crops";if((S.seeds[c]||0)>=PLANT_COST){S.seeds[c]-=PLANT_COST;b.stage="growing";b.growing=c;hands--;sown++;}}
|
||
if(sown)logEvent("The Farmers sowed "+sown+" field"+(sown>1?"s":""));}
|
||
function harvestFarms(){let got=0;for(const b of S.buildings.filter(b=>b.built&&BUILD[b.type].farm)){
|
||
if((b.stage||"empty")==="growing"){const c=b.growing||"crops";const yld=Math.round(PLANT_YIELD*upgMult(b));S.res[c]=(S.res[c]||0)+yld;b.stage="empty";b.growing=null;got++;
|
||
logEvent("Harvested "+yld+" "+CROPS[c].name);}}
|
||
if(got)toast("Harvest time — "+got+" field"+(got>1?"s":"")+" brought in");}
|
||
// A tended pasture's herd grows by one each season, up to 6.
|
||
const HERD_CAP=6;
|
||
function growHerds(){if(countJob("shepherd")<=0)return;
|
||
for(const b of S.buildings.filter(b=>b.built&&BUILD[b.type].pasture))b.herd=Math.min(HERD_CAP,(b.herd||2)+1);}
|
||
// Chopped trees vanish and regrow next season on empty land (never on water, nodes or buildings).
|
||
function regrowTrees(){let n=S.treeRegrow||0;if(n<=0)return;const key=(x,y)=>x+","+y,occ={};
|
||
for(const nd of S.nodes)occ[key(nd.gx,nd.gy)]=true;
|
||
for(const b of S.buildings)for(const t of bldTiles(b.type,b.gx,b.gy,b.dir))occ[key(t[0],t[1])]=true;
|
||
let guard=n*50;
|
||
while(n>0&&guard-->0){const x=(Math.random()*GRID)|0,y=(Math.random()*GRID)|0;
|
||
if(S.tiles[y][x]!=="land"||occ[key(x,y)])continue;
|
||
occ[key(x,y)]=true;S.nodes.push({gx:x,gy:y,type:"tree",amount:NODE.tree.max,dormant:false,respawn:0,seed:Math.random()});n--;}
|
||
S.treeRegrow=n;}
|
||
function farmTick(dt){ // crops now grow on a seasonal plant→harvest cycle (see plantFarms/harvestFarms); pasture wool is continuous
|
||
if(isWinter())return;
|
||
const past=S.buildings.filter(b=>b.built&&BUILD[b.type].pasture);
|
||
const shepList=S.mellows.filter(m=>m.job==="shepherd"&&!isChild(m));
|
||
if(past.length&&shepList.length){let si=0; // one Shepherd tends one pasture; a family wolf gives that shepherd +50%
|
||
for(const b of past){if(si>=shepList.length)break;const shep=shepList[si++];
|
||
const herd=b.herd||2,boost=homeHasWolf(shep.home)?1.5:1;b._wa=(b._wa||0)+dt*0.12*herd*boost*upgMult(b);
|
||
while(b._wa>=1){b._wa-=1;
|
||
if((b.animal||"sheep")==="sheep"){S.res.wool++;if(Math.random()<0.5)S.res.meat++;if(Math.random()<0.2)S.res.leather++;} // sheep: wool + some meat/leather
|
||
else{S.res.meat+=1+(Math.random()<0.5?1:0);if(Math.random()<0.35)S.res.leather++;}}}}} // deer: meat (1-2) + leather
|
||
function warmthProvided(){const fires=S.buildings.filter(b=>b.built&&(BUILD[b.type].warmth||BUILD[b.type].warm));
|
||
if(fires.length===0)return false;if(S.res.firewood>0)return true;return S.buildings.some(b=>b.built&&BUILD[b.type].warm);}
|
||
function burnFirewood(dt){if(!isWinter())return;const fires=S.buildings.filter(b=>b.built&&BUILD[b.type].warmth).length;
|
||
if(fires>0&&S.res.firewood>0){S._burn=(S._burn||0)+dt*fires*0.25;while(S._burn>=1&&S.res.firewood>0){S._burn-=1;S.res.firewood--;}}}
|
||
function assignHomes(){
|
||
const houses=S.buildings.filter(b=>b.built&&BUILD[b.type].house);
|
||
const occ={};houses.forEach(h=>occ[h.id]={m:0,f:0,ad:0});
|
||
for(const mm of S.mellows){if(mm.home&&!occ[mm.home])mm.home=null;
|
||
if(mm.home&&!isChild(mm)){const o=occ[mm.home];o.ad++;if(mm.sex==="male")o.m++;else o.f++;}}
|
||
for(const mm of S.mellows){if(isChild(mm)||(mm.home&&occ[mm.home]))continue;
|
||
let best=null;
|
||
for(const h of houses){const o=occ[h.id];if(o.ad>=2)continue;
|
||
const pair=(mm.sex==="male"&&o.f>0&&o.m===0)||(mm.sex==="female"&&o.m>0&&o.f===0);
|
||
if(pair){best=h;break;}if(!best)best=h;}
|
||
if(best){mm.home=best.id;const o=occ[best.id];o.ad++;if(mm.sex==="male")o.m++;else o.f++;}}}
|
||
// Life event: when a new male+female couple settles in a home, celebrate a wedding (silent baseline on first run/load).
|
||
function weddingCheck(){const homes={};
|
||
for(const m of S.mellows){if(isChild(m)||!m.home)continue;(homes[m.home]=homes[m.home]||[]).push(m);}
|
||
for(const id in homes){const ad=homes[id],male=ad.find(x=>x.sex==="male"),female=ad.find(x=>x.sex==="female");
|
||
if(!male||!female)continue;
|
||
if(male.wedTo===female.id&&female.wedTo===male.id)continue; // already a couple
|
||
male.wedTo=female.id;female.wedTo=male.id;
|
||
if(S._weddingsInit){ // celebrate (not the initial founders / a fresh load)
|
||
male.happy=Math.min(100,male.happy+20);female.happy=Math.min(100,female.happy+20);
|
||
if(S.festT<=0){S.festT=4;S.festKind="petal";}
|
||
toast("💍 "+male.name+" & "+female.name+" wed!");
|
||
logEvent(male.name+" and "+female.name+" were married in "+(S.villageName||"the village")+".");
|
||
if(ui.sound)Sound.sfx('festival');}}
|
||
S._weddingsInit=true;}
|
||
function birthTick(dt){
|
||
for(const m of S.mellows){if(m.pregnant){
|
||
const h=S.buildings.find(b=>b.id===m.home&&b.built&&BUILD[b.type].house);
|
||
if(!h){m.pregnant=false;m.pregT=0;continue;} // a Mellow cannot be pregnant without a home
|
||
m.pregT-=dt;if(m.pregT<=0){m.pregnant=false;
|
||
const baby=newMellow(h.gx,h.gy,0);baby.home=m.home;
|
||
const dad=partnerOf(m);const pa=((m.intel||3)+((dad&&dad.intel)||3))/2;
|
||
baby.intel=Math.max(1,Math.min(10,Math.round(pa+(Math.random()*2-1))));
|
||
baby.surname=(dad&&dad.surname)||m.surname;
|
||
baby.parents={mom:m.name,dad:dad?dad.name:"—"};baby.momId=m.id;baby.dadId=dad?dad.id:null;
|
||
baby.gparents=[];if(m.parents)baby.gparents.push(m.parents.mom,m.parents.dad);
|
||
if(dad&&dad.parents)baby.gparents.push(dad.parents.mom,dad.parents.dad);
|
||
if(Math.random()<0.5)baby.trait=Math.random()<0.5?m.trait:(dad?dad.trait:m.trait); // trait partly inherited
|
||
if(baby.trait==="bookish")baby.intel=Math.min(10,baby.intel+1);
|
||
S.mellows.push(baby);S._everBaby=true;logEvent("Baby "+baby.name+" "+baby.surname+" was born!");toast("A baby Mellow was born!");if(ui.sound)Sound.sfx('birth');}}}
|
||
S.birthT-=dt;if(S.birthT>0)return;S.birthT=6;
|
||
if(totalFood()<=S.mellows.length*2)return;
|
||
const midwife=countJob("midwife")>0&&S.buildings.some(b=>b.built&&b.type==="nursery");
|
||
const fert=isWinter()?1.5:(season()==="Fall"?1.2:1.0); // more fertile in winter/fall -> more summer/fall births
|
||
for(const h of S.buildings.filter(b=>b.built&&BUILD[b.type].house)){
|
||
const res=S.mellows.filter(m=>m.home===h.id);
|
||
const males=res.filter(m=>!isChild(m)&&m.sex==="male").length;
|
||
const fem=res.filter(m=>!isChild(m)&&m.sex==="female"&&!m.pregnant&&!(m.contraT>0));
|
||
const kids=res.filter(m=>isChild(m)).length;
|
||
if(males>=1&&fem.length>=1&&kids<3&&Math.random()<(midwife?0.5:0.28)*fert){
|
||
fem[0].pregnant=true;fem[0].pregT=3*difficulty().season;}}}
|
||
function diseaseTick(dt){S.sickT-=dt;if(S.sickT<=0){S.sickT=90+Math.random()*90;
|
||
if(S.seasonCount<=4)return; // grace period: no illness for the first 4 seasons (year one)
|
||
if(Math.random()<0.16+(isWinter()?0.14:0)){const healthy=S.mellows.filter(m=>!m.sick&&!isChild(m));
|
||
if(healthy.length){let tot=0;const wts=healthy.map(m=>{const w=m.trait==="sickly"?3.2:m.trait==="hardy"?0.15:0.55;tot+=w;return w;});
|
||
let r=Math.random()*tot,idx=0;for(;idx<healthy.length-1;idx++){r-=wts[idx];if(r<=0)break;}
|
||
const v=healthy[idx];v.sick=true;logEvent(v.name+" caught a cold");}}}}
|
||
// wolves, like Mellows, never step onto open water
|
||
function stepOnLand(e,nx,ny){if(walkableTile(Math.round(nx),Math.round(ny))||!walkableTile(Math.round(e.gx),Math.round(e.gy))){e.gx=nx;e.gy=ny;}}
|
||
function wolfSpawnPos(den){for(let i=0;i<14;i++){const x=den.gx+(Math.random()*2-1),y=den.gy+(Math.random()*2-1);if(walkableTile(Math.round(x),Math.round(y)))return [x,y];}return [den.gx,den.gy];}
|
||
function wolfTick(dt){
|
||
for(const w of S.wolves){w.timer-=dt;if(w.atkCd>0)w.atkCd-=dt;
|
||
if(w.state==="family"){wolfFollow(w,dt,familyAnchor(w));continue;}
|
||
if(w.state==="training"||w.state==="trained"){wolfFollow(w,dt,facilityAnchor(w),0.9);continue;}
|
||
const den=(S.dens&&S.dens[w.packId])||{gx:w.gx,gy:w.gy}; // wild: roam near its den
|
||
if(w.timer<=0){w.timer=3+Math.random()*4;w.tx=den.gx+(Math.random()*5-2.5);w.ty=den.gy+(Math.random()*5-2.5);}
|
||
const dx=w.tx-w.gx,dy=w.ty-w.gy,d=Math.hypot(dx,dy)||1;
|
||
if(d>0.3){w.face=dx>0?1:-1;const st=Math.min(d,1.1*dt);stepOnLand(w,w.gx+dx/d*st,w.gy+dy/d*st);} // stop at the target (no overshoot/jitter, no face-flip)
|
||
wildWolfAttack(w);}
|
||
wolfRecruitTick(dt);}
|
||
function wolfFollow(w,dt,anchor,spd){spd=spd||1.4;if(!anchor)return;
|
||
const dx=anchor.gx-w.gx,dy=anchor.gy-w.gy,d=Math.hypot(dx,dy)||1;if(d>1.3){w.face=dx>0?1:-1;stepOnLand(w,w.gx+dx/d*spd*dt,w.gy+dy/d*spd*dt);}}
|
||
function familyAnchor(w){const res=S.mellows.filter(m=>m.home===w.homeId);const kid=res.find(isChild)||res[0];
|
||
const h=S.buildings.find(b=>b.id===w.homeId);return kid?{gx:kid.gx,gy:kid.gy}:(h?{gx:h.gx,gy:h.gy}:null);}
|
||
function facilityAnchor(w){const f=S.buildings.find(b=>b.id===w.facId);
|
||
return f?{gx:f.gx+(hash2(w.id,1)-0.5)*1.5,gy:f.gy+(hash2(w.id,2)-0.5)*1.5}:null;}
|
||
// one encounter roll when a wild wolf reaches a Mellow, then a cooldown (never per-tick)
|
||
function wildWolfAttack(w){if(w.atkCd>0)return;
|
||
let best=null,bd=1.4;for(const m of S.mellows){const dd=dist(w.gx,w.gy,m.gx,m.gy);if(dd<bd){bd=dd;best=m;}}
|
||
if(!best)return;w.atkCd=6+Math.random()*6;
|
||
if(protectedByWolf(best)){if(Math.random()<0.12){const fw=nearestFamilyWolf(best); // a family wolf may die defending
|
||
if(fw){S.wolves=S.wolves.filter(x=>x!==fw);logEvent("A family wolf died protecting "+best.name);toast("A family wolf gave its life protecting "+best.name);}}return;}
|
||
const r=Math.random(),worker=(best.job==="hunter"||best.job==="miner"||best.job==="ironminer"||best.job==="lumberjack");
|
||
if(isChild(best)){if(isOrphan(best)&&r<0.05){best.health-=20;toast("A wolf attacked "+best.name+"!");logEvent(best.name+" (orphaned) was attacked by a wolf");}return;}
|
||
if(worker){if(r<0.02)killMellow(best,"was killed by a wolf");else if(r<0.12){best.health-=18;toast("A wolf injured "+best.name+"!");logEvent(best.name+" was hurt by a wolf");}return;}
|
||
if(r<0.02){best.health-=12;toast("A wolf attacked "+best.name+"!");logEvent(best.name+" was attacked by a wolf");}} // lone villager
|
||
function nearestFamilyWolf(m){for(const w of S.wolves)if(w.state==="family"){if(w.homeId&&w.homeId===m.home)return w;if(dist(w.gx,w.gy,m.gx,m.gy)<2.2)return w;}return null;}
|
||
function protectedByWolf(m){return !!nearestFamilyWolf(m);}
|
||
// Centres in "train" mode pull in the nearest wild wolf for 8 meat (needs a Wolf Trainer)
|
||
function wolfRecruitTick(dt){S._recT=(S._recT||0)-dt;if(S._recT>0)return;S._recT=4;
|
||
if(countJob("trainer")<=0)return;
|
||
for(const f of wolfCentres()){if(f.mode==="house")continue;
|
||
if(facilityWolves(f.id).length>=FACILITY_CAP)continue;if((S.res.meat||0)<WOLF_TAME_MEAT)break;
|
||
const wild=S.wolves.filter(w=>w.state==="wild");if(!wild.length)break;
|
||
let best=null,bd=1e9;for(const w of wild){const dd=dist(w.gx,w.gy,f.gx,f.gy);if(dd<bd){bd=dd;best=w;}}
|
||
if(!best)break;S.res.meat-=WOLF_TAME_MEAT;best.state="training";best.facId=f.id;best.trainT=WOLF_TRAIN_SEASONS;best.packId=null;
|
||
logEvent("A wild wolf was brought in to the Wolf Den for training");}}
|
||
// seasonal wolf lifecycle: training completes, breeding, family surplus, wild pack growth
|
||
function wolfSeason(){const s=season();
|
||
for(const w of S.wolves)if(w.state==="training"){w.trainT-=1;if(w.trainT<=0)w.state="trained";}
|
||
for(const f of wolfCentres()){const list=facilityWolves(f.id),adults=list.filter(w=>!wolfBaby(w));
|
||
if(adults.length>=2&&list.length<FACILITY_CAP&&Math.random()<0.6){const fb=S.buildings.find(b=>b.id===f.id);
|
||
S.wolves.push(newWolf(fb.gx,fb.gy,{state:"training",bornTame:true,trainT:WOLF_TRAIN_SEASONS,facId:f.id}));}}
|
||
for(const h of S.buildings.filter(b=>b.built&&BUILD[b.type].house)){const fam=familyWolves(h.id);
|
||
if(fam.length>=2&&Math.random()<0.5){const litter=1+(Math.random()*3|0);
|
||
for(let i=0;i<litter;i++)S.wolves.push(newWolf(h.gx,h.gy,{state:"family",bornTame:true,homeId:h.id}));
|
||
logEvent("A family's wolves had "+litter+" pup"+(litter>1?"s":""));}}
|
||
for(const h of S.buildings.filter(b=>b.built&&BUILD[b.type].house)){const fam=familyWolves(h.id);
|
||
if(fam.length>FAMILY_WOLF_TOTAL){fam.sort((a,b)=>wolfAge(b)-wolfAge(a));const centre=wolfCentres()[0];
|
||
for(let i=FAMILY_WOLF_TOTAL;i<fam.length;i++){const w=fam[i];
|
||
if(centre&&facilityWolves(centre.id).length<FACILITY_CAP){w.state="trained";w.homeId=null;w.facId=centre.id;}
|
||
else{w.state="wild";w.homeId=null;w.facId=null;w.packId=0;}}}}
|
||
if(s==="Spring"&&S.dens&&S.dens.length){let add=2+(Math.random()*3|0);
|
||
while(add-->0){const wild=S.wolves.filter(w=>w.state==="wild");if(wild.length>=WOLF_MAX_WILD)break;
|
||
const counts=[0,0];for(const w of wild)if(w.packId!=null)counts[w.packId]++;
|
||
let p=counts[0]<=counts[1]?0:1;if(counts[p]>=WOLF_PACK_MAX){p=1-p;if(counts[p]>=WOLF_PACK_MAX)break;}
|
||
const den=S.dens[p]||S.dens[0];S.wolves.push(newWolf(...wolfSpawnPos(den),{state:"wild",packId:p,adult:true}));}}}
|
||
// ---- reachability: which land tiles can workers actually walk to from town (rivers need bridges) ----
|
||
function computeReach(){const start=S.buildings.find(b=>!BUILD[b.type].dock&&!BUILD[b.type].bridge&&!BUILD[b.type].boat)||S.buildings[0];
|
||
if(!start){S._reach=null;return;}
|
||
const R=[];for(let y=0;y<GRID;y++){R[y]=new Array(GRID).fill(false);}
|
||
const crossing=(x,y)=>S.buildings.some(b=>b.built&&(BUILD[b.type].bridge||BUILD[b.type].boat)&&b.gx===x&&b.gy===y);
|
||
const pass=(x,y)=>x>=0&&y>=0&&x<GRID&&y<GRID&&(S.tiles[y][x]==="land"||crossing(x,y));
|
||
const sx=Math.round(start.gx),sy=Math.round(start.gy),q=[[sx,sy]];if(pass(sx,sy))R[sy][sx]=true;
|
||
while(q.length){const c=q.pop(),x=c[0],y=c[1];
|
||
for(const d of [[1,0],[-1,0],[0,1],[0,-1]]){const nx=x+d[0],ny=y+d[1];
|
||
if(pass(nx,ny)&&!R[ny][nx]){R[ny][nx]=true;q.push([nx,ny]);}}}
|
||
S._reach=R;}
|
||
function reachable(gx,gy){if(!S._reach)return true;const x=Math.round(gx),y=Math.round(gy);
|
||
return x>=0&&y>=0&&x<GRID&&y<GRID&&!!S._reach[y][x];}
|
||
// footprint tiles a building covers (size N -> N×N block anchored at gx,gy)
|
||
function bldTiles(type,gx,gy,dir){if(BUILD[type].dock){const d=DOCK_DIRS[dir||0];return [[gx,gy],[gx+d[0],gy+d[1]]];} // dock = land anchor + water pier
|
||
const s=BUILD[type].size||1,t=[];for(let dy=0;dy<s;dy++)for(let dx=0;dx<s;dx++)t.push([gx+dx,gy+dy]);return t;}
|
||
function tileTaken(gx,gy,ignore){return S.buildings.some(b=>b!==ignore&&bldTiles(b.type,b.gx,b.gy,b.dir).some(t=>t[0]===gx&&t[1]===gy));}
|
||
// A dock spans 2 tiles: the tapped water tile (pier) + an adjacent reachable land tile (anchor). Returns {gx,gy=land, dir, wx,wy=water} or null.
|
||
function dockPlace(wx,wy){if(wx<0||wy<0||wx>=GRID||wy>=GRID||S.tiles[wy][wx]!=="water"||tileTaken(wx,wy))return null;
|
||
for(let d=0;d<4;d++){const lx=wx-DOCK_DIRS[d][0],ly=wy-DOCK_DIRS[d][1];
|
||
if(lx<0||ly<0||lx>=GRID||ly>=GRID)continue;
|
||
if(S.tiles[ly][lx]!=="land"||!reachable(lx,ly)||tileTaken(lx,ly))continue;
|
||
if(S.nodes.some(n=>n.gx===lx&&n.gy===ly&&!n.dormant&&n.amount>0))continue;
|
||
return {gx:lx,gy:ly,dir:d,wx,wy};}
|
||
return null;}
|
||
// ---- proactive alerts so problems never build up silently ----
|
||
function alertTick(dt){S.alertT=(S.alertT==null?5:S.alertT)-dt;if(S.alertT>0)return;S.alertT=9;
|
||
S._acd=S._acd||{};const now=S.tsec,pop=S.mellows.length;if(pop===0)return;
|
||
const fire=(k,msg,cd)=>{if((S._acd[k]||-1e9)+(cd||26)<now){S._acd[k]=now;toast(msg);logEvent(msg);}};
|
||
if(S.mellows.some(m=>m.hunger<=1&&!isChild(m)))fire('starve',"A Mellow is starving — get food fast!");
|
||
else if(totalFood()<=6&&totalFood()<pop)fire('food',"Food is running low — assign gatherers or a Farm.");
|
||
const foodJobs=S.mellows.filter(m=>!isChild(m)&&["hunter","fruit","fisher","farmer"].indexOf(m.job)>=0).length;
|
||
if(foodJobs===0&&totalFood()<pop*3)fire('nofood',"No one is gathering food.");
|
||
if(season()==="Fall"&&(S.res.firewood||0)<3&&!S.buildings.some(b=>b.built&&BUILD[b.type].warm))fire('winter',"Winter is coming — stock firewood or build warmth.",70);
|
||
if(S.buildings.some(b=>!b.built)&&countJob("builder")===0)fire('builder',"A building is waiting — assign a Builder.");
|
||
if(S.mellows.some(m=>m.sick)&&countJob("doctor")===0)fire('sick',"Someone is ill and there's no Doctor.");}
|
||
// ---- wandering Mellows arrive to keep the village recoverable ----
|
||
function immigrationTick(dt){if(S.mellows.length===0){gameOver();return;}
|
||
S.immT=(S.immT==null?45:S.immT)-dt;if(S.immT>0)return;S.immT=40+Math.random()*45;
|
||
// regular arrivals come via the seasonal Newcomers event; this only rescues a near-dead village
|
||
if(adultCount()>=2)return;
|
||
if(Math.random()<0.9){const b0=S.buildings.find(b=>b.built)||S.buildings[0];if(!b0)return;
|
||
const m=newMellow(b0.gx,b0.gy,20+(Math.random()*16|0));m.home=null;S.mellows.push(m);
|
||
logEvent("A wandering Mellow, "+m.name+" "+m.surname+", joined the village!");
|
||
toast(m.name+" wandered in to join the village!");if(ui.sound)Sound.sfx('arrive');}}
|
||
// A group of Mellows "dressed in white" arrives every few seasons; the player accepts or turns them away.
|
||
function landNear(cx,cy,rad){for(let i=0;i<50;i++){const x=Math.round(cx+(Math.random()*2-1)*rad),y=Math.round(cy+(Math.random()*2-1)*rad);
|
||
if(x>=0&&y>=0&&x<GRID&&y<GRID&&S.tiles[y][x]==="land"&&!tileTaken(x,y))return {x,y};}return {x:Math.max(0,Math.min(GRID-1,Math.round(cx))),y:Math.max(0,Math.min(GRID-1,Math.round(cy)))};}
|
||
function newcomerEvent(){
|
||
if((S.newcomers||[]).length||!S.mellows.length)return; // one pending group at a time
|
||
const b0=S.buildings.find(b=>b.built)||S.buildings[0];const cx=b0?b0.gx:GRID/2,cy=b0?b0.gy:GRID/2;
|
||
const n=2+(Math.random()*3|0); // 2–4 newcomers
|
||
S.newcomers=[];
|
||
for(let i=0;i<n;i++){const sp=landNear(cx,cy+2,3.5);
|
||
const m=newMellow(sp.x,sp.y,18+(Math.random()*22|0));m.home=null;m.job="idle";m.newcomer=true;m.bob=Math.random()*6;
|
||
S.newcomers.push(m);}
|
||
showNewcomerPopup();if(ui.sound)Sound.sfx('arrive');}
|
||
function showNewcomerPopup(){const n=(S.newcomers||[]).length;if(!n)return;
|
||
document.getElementById("newcomerOverlay")&&document.getElementById("newcomerOverlay").remove();
|
||
const ov=document.createElement("div");ov.className="overlay";ov.id="newcomerOverlay";ov.style.display="flex";
|
||
ov.innerHTML=`<div class="card parch" style="max-width:340px;text-align:center">
|
||
<h2>Newcomers have arrived</h2>
|
||
<div class="hint"><p><b>${n}</b> Mellow${n>1?"s":""} dressed all in white stand at the edge of ${S.villageName||"the village"}, hoping to join the community.</p></div>
|
||
<div class="row" style="justify-content:center;gap:10px;margin-top:12px">
|
||
<button class="btn sec" id="newcNo">Turn them away</button>
|
||
<button class="btn" id="newcYes">Welcome them in</button></div></div>`;
|
||
document.body.appendChild(ov);
|
||
document.getElementById("newcYes").onclick=acceptNewcomers;
|
||
document.getElementById("newcNo").onclick=declineNewcomers;}
|
||
function acceptNewcomers(){const list=S.newcomers||[],n=list.length;
|
||
for(const m of list){m.newcomer=false;S.mellows.push(m);} // change into normal clothes & seamlessly join
|
||
S.newcomers=[];assignHomes();weddingCheck();computeReach();
|
||
toast("🤍 "+n+" newcomer"+(n>1?"s":"")+" joined "+(S.villageName||"the village")+"!");
|
||
logEvent(n+" newcomer"+(n>1?"s were":" was")+" welcomed into the village.");
|
||
if(ui.sound)Sound.sfx('arrive');
|
||
const o=document.getElementById("newcomerOverlay");if(o)o.remove();}
|
||
function declineNewcomers(){const n=(S.newcomers||[]).length;S.newcomers=[];
|
||
toast("The newcomers moved on.");logEvent("The village turned away "+n+" newcomer"+(n>1?"s":"")+".");
|
||
const o=document.getElementById("newcomerOverlay");if(o)o.remove();}
|
||
function milestoneTick(){S.achieved=S.achieved||[];
|
||
for(const ms of MILESTONES){if(S.achieved.indexOf(ms.id)>=0)continue;if(ms.test(S)){S.achieved.push(ms.id);
|
||
toast("Milestone — "+ms.name+"!");logEvent("Milestone reached: "+ms.name);if(ui.sound)Sound.sfx('milestone');}}}
|
||
let overShown=false;
|
||
function gameOver(){if(overShown)return;overShown=true;ui.speed=0;
|
||
$("overStats").textContent=(S.villageName||"Your village")+" lasted "+S.seasonCount+" season"+(S.seasonCount===1?"":"s")+".";
|
||
$("overOverlay").style.display="flex";}
|
||
|
||
/* ================= ART (hand-drawn ink outline style) ================= */
|
||
const INK="#5a4835"; // softer warm brown outline (closer to Life of Mellow's gentle look)
|
||
function hx(c){if(c[0]==="#"){const n=parseInt(c.slice(1),16);return[(n>>16)&255,(n>>8)&255,n&255];}
|
||
const m=c.match(/\d+/g);return m?[+m[0],+m[1],+m[2]]:[0,0,0];}
|
||
function blend(a,b,t){const p=hx(a),q=hx(b);
|
||
return "rgb("+Math.round(p[0]+(q[0]-p[0])*t)+","+Math.round(p[1]+(q[1]-p[1])*t)+","+Math.round(p[2]+(q[2]-p[2])*t)+")";}
|
||
const lite=(c,t)=>blend(c,"#ffffff",t), dark=(c,t)=>blend(c,"#000000",t);
|
||
function inkStroke(g,w){g.strokeStyle=INK;g.lineWidth=(w||1.6)*0.85;g.lineJoin="round";g.lineCap="round";g.stroke();}
|
||
function poly(g,pts,fill){g.beginPath();g.moveTo(pts[0][0],pts[0][1]);for(let i=1;i<pts.length;i++)g.lineTo(pts[i][0],pts[i][1]);g.closePath();if(fill){g.fillStyle=fill;g.fill();}}
|
||
function fillP(g,pts,fill,w){poly(g,pts,fill);if(w!==0)inkStroke(g,w);}
|
||
function circ(g,x,y,r){g.beginPath();g.arc(x,y,r,0,7);}
|
||
function fillC(g,x,y,r,fill,w){g.beginPath();g.arc(x,y,r,0,7);if(fill){g.fillStyle=fill;g.fill();}if(w!==0)inkStroke(g,w);}
|
||
function fillE(g,x,y,rx,ry,fill,w){g.beginPath();g.ellipse(x,y,rx,ry,0,0,7);if(fill){g.fillStyle=fill;g.fill();}if(w!==0)inkStroke(g,w);}
|
||
function blobPath(g,x,y,r,n,seed){g.beginPath();
|
||
for(let i=0;i<=n;i++){const a=i/n*Math.PI*2,wob=0.82+0.18*Math.sin(i*2.7+(seed||0)*12);
|
||
const px=x+Math.cos(a)*r*wob,py=y+Math.sin(a)*r*wob*0.92;if(i===0)g.moveTo(px,py);else g.lineTo(px,py);}g.closePath();}
|
||
function shadow(g,x,y,rx){g.fillStyle="rgba(70,55,35,.12)";g.beginPath();g.ellipse(x,y,rx,rx*0.36,0,0,7);g.fill();}
|
||
|
||
function artTree(g,x,y,s,winter,seed){s=s||1;seed=seed||0;
|
||
shadow(g,x,y,12*s);
|
||
fillP(g,[[x-2.6*s,y+1],[x-2*s,y-9*s],[x+2*s,y-9*s],[x+2.6*s,y+1]],"#7a5230",1.5*s);
|
||
const cy=y-18*s,cr=13*s;
|
||
blobPath(g,x,cy,cr,9,seed);g.fillStyle=winter?"#dbe6da":"#6aa63e";g.fill();inkStroke(g,1.9*s);
|
||
g.strokeStyle=winter?"#c4d3c4":"#4d8a2c";g.lineWidth=1.5*s;
|
||
for(let i=0;i<4;i++){const a=seed*7+i*1.7;g.beginPath();g.arc(x+Math.cos(a)*cr*0.4,cy+Math.sin(a)*cr*0.28,cr*0.34,a,a+1.3);g.stroke();}
|
||
g.fillStyle=winter?"#ffffff":"rgba(255,255,255,.20)";g.beginPath();g.ellipse(x-cr*0.35,cy-cr*0.42,cr*0.3,cr*0.18,-0.6,0,7);g.fill();
|
||
if(winter){g.fillStyle="#fff";g.beginPath();g.ellipse(x,cy-cr*0.55,cr*0.7,cr*0.28,0,0,7);g.fill();}}
|
||
function artBush(g,x,y,s,berry,winter){s=s||1;shadow(g,x,y,9*s);
|
||
blobPath(g,x,y-5*s,7*s,8,x*0.3);g.fillStyle=winter?"#cdd8cd":"#569a3f";g.fill();inkStroke(g,1.6*s);
|
||
g.fillStyle="rgba(255,255,255,.18)";g.beginPath();g.ellipse(x-2*s,y-8*s,3*s,1.8*s,-0.5,0,7);g.fill();
|
||
if(!winter&&berry){g.fillStyle=berry;for(const p of [[-3,-5],[3,-6],[5,-3],[-1,-3]]){fillC(g,x+p[0]*s,y+p[1]*s,1.6*s,berry,1*s);}}}
|
||
function artHerb(g,x,y,s,winter){s=s||1;g.strokeStyle=winter?"#c4d3c4":INK;g.lineWidth=1.6*s;g.lineCap="round";
|
||
for(const a of [-0.5,0,0.5]){g.beginPath();g.moveTo(x,y);g.quadraticCurveTo(x+a*9*s,y-6*s,x+a*13*s,y-12*s);g.stroke();}
|
||
g.strokeStyle=winter?"#cdd8cd":"#57a03e";g.lineWidth=2.4*s;
|
||
for(const a of [-0.5,0,0.5]){g.beginPath();g.moveTo(x,y);g.quadraticCurveTo(x+a*9*s,y-6*s,x+a*13*s,y-12*s);g.stroke();}
|
||
if(!winter){g.fillStyle="#e7c94a";fillC(g,x,y-13*s,2*s,"#e7c94a",1*s);fillC(g,x-6*s,y-9*s,1.6*s,"#e7c94a",1*s);fillC(g,x+6*s,y-9*s,1.6*s,"#e7c94a",1*s);}}
|
||
function artRock(g,x,y,s){s=s||1;shadow(g,x,y,12*s);
|
||
g.beginPath();g.moveTo(x-11*s,y+1*s);g.quadraticCurveTo(x-13*s,y-7*s,x-4*s,y-11*s);
|
||
g.quadraticCurveTo(x+4*s,y-14*s,x+10*s,y-7*s);g.quadraticCurveTo(x+13*s,y-1*s,x+8*s,y+2*s);
|
||
g.quadraticCurveTo(x-2*s,y+4*s,x-11*s,y+1*s);g.closePath();
|
||
g.fillStyle="#a9afb6";g.fill();inkStroke(g,1.9*s);
|
||
g.strokeStyle=INK;g.lineWidth=1.3*s;g.beginPath();g.moveTo(x-2*s,y-10*s);g.lineTo(x+1*s,y-2*s);g.lineTo(x-6*s,y+0*s);g.stroke();
|
||
g.fillStyle="#c4cad0";g.beginPath();g.ellipse(x-4*s,y-7*s,3.4*s,2*s,-0.4,0,7);g.fill();}
|
||
function artOre(g,x,y,s){s=s||1;shadow(g,x,y,12*s);
|
||
g.beginPath();g.moveTo(x-10*s,y+1*s);g.quadraticCurveTo(x-12*s,y-8*s,x-3*s,y-12*s);
|
||
g.quadraticCurveTo(x+5*s,y-14*s,x+10*s,y-6*s);g.quadraticCurveTo(x+12*s,y-1*s,x+7*s,y+2*s);
|
||
g.quadraticCurveTo(x-2*s,y+4*s,x-10*s,y+1*s);g.closePath();
|
||
g.fillStyle="#5c5a5f";g.fill();inkStroke(g,1.9*s);
|
||
g.fillStyle="#6f6d72";g.beginPath();g.ellipse(x-3*s,y-8*s,3.4*s,2.2*s,-0.4,0,7);g.fill();
|
||
g.fillStyle="#d9a24a";fillC(g,x-2*s,y-6*s,1.7*s,"#d9a24a",1*s);fillC(g,x+4*s,y-2*s,1.4*s,"#d9a24a",1*s);}
|
||
function artDeer(g,x,y,s,f){s=s||1;f=f||1;shadow(g,x,y,10*s);const c="#b07c46",cl="#caa068";
|
||
g.strokeStyle=INK;g.lineWidth=2*s;g.lineCap="round";
|
||
for(const dx of [-3.5,-1,2.5,4.5]){g.beginPath();g.moveTo(x+dx*s,y-5*s);g.lineTo(x+dx*s,y);g.stroke();}
|
||
g.fillStyle="#f2e8d2";g.beginPath();g.ellipse(x-f*6*s,y-8*s,1.7*s,2.3*s,0.3,0,7);g.fill(); // tail
|
||
fillE(g,x,y-8*s,6.8*s,4.4*s,c,1.9*s); // body
|
||
g.fillStyle=cl;g.beginPath();g.ellipse(x-f*1*s,y-9*s,4*s,2.2*s,0,0,7);g.fill(); // back highlight
|
||
fillE(g,x+f*7.6*s,y-13*s,3.5*s,4.3*s,c,1.8*s); // head/neck
|
||
g.fillStyle=cl;g.beginPath();g.ellipse(x+f*8.6*s,y-11.2*s,2.5*s,2*s,0,0,7);g.fill(); // muzzle
|
||
fillE(g,x+f*5.7*s,y-16*s,1.4*s,2.3*s,c,1.3*s);fillE(g,x+f*9.5*s,y-16*s,1.4*s,2.3*s,c,1.3*s); // ears
|
||
g.strokeStyle="#d9c6a0";g.lineWidth=1.6*s;g.lineCap="round";
|
||
g.beginPath();g.moveTo(x+f*6.6*s,y-16.5*s);g.lineTo(x+f*5.4*s,y-20.5*s);g.moveTo(x+f*6.1*s,y-18*s);g.lineTo(x+f*4.2*s,y-19.5*s);
|
||
g.moveTo(x+f*8.6*s,y-16.5*s);g.lineTo(x+f*9.8*s,y-20.5*s);g.moveTo(x+f*9.1*s,y-18*s);g.lineTo(x+f*11*s,y-19.5*s);g.stroke();
|
||
g.fillStyle=INK;g.beginPath();g.ellipse(x+f*6.5*s,y-13.5*s,0.9*s,1.15*s,0,0,7);g.fill();g.beginPath();g.ellipse(x+f*8.9*s,y-13.5*s,0.9*s,1.15*s,0,0,7);g.fill();
|
||
g.fillStyle="#fff";circ(g,x+f*6.8*s,y-13.9*s,0.35*s);g.fill();circ(g,x+f*9.2*s,y-13.9*s,0.35*s);g.fill();
|
||
g.fillStyle=INK;circ(g,x+f*10.4*s,y-11*s,0.7*s);g.fill(); // nose
|
||
g.fillStyle="#f0e2c4";circ(g,x-f*2*s,y-7*s,1.2*s);g.fill();circ(g,x+f*1.5*s,y-8.5*s,1*s);g.fill();} // spots
|
||
function artSheep(g,x,y,s,f){s=s||1;f=f||1;shadow(g,x,y,10*s);
|
||
g.strokeStyle=INK;g.lineWidth=2*s;g.lineCap="round";
|
||
for(const dx of [-3.5,-1,2.5,4.5]){g.beginPath();g.moveTo(x+dx*s,y-4*s);g.lineTo(x+dx*s,y);g.stroke();}
|
||
blobPath(g,x,y-7*s,7.3*s,13,x*0.2);g.fillStyle="#f8f3ea";g.fill();inkStroke(g,1.9*s);
|
||
fillE(g,x+f*6.6*s,y-9.6*s,2.9*s,3.4*s,"#7c6858",1.7*s); // face
|
||
fillE(g,x+f*4.6*s,y-10.8*s,1.5*s,1.1*s,"#6a5648",1.2*s);fillE(g,x+f*8.6*s,y-10.8*s,1.5*s,1.1*s,"#6a5648",1.2*s); // ears
|
||
g.fillStyle="#f8f3ea";circ(g,x+f*6.6*s,y-11.8*s,2.3*s);g.fill(); // forelock
|
||
g.fillStyle="#fff";circ(g,x+f*5.6*s,y-9.6*s,1*s);g.fill();circ(g,x+f*7.6*s,y-9.6*s,1*s);g.fill();
|
||
g.fillStyle=INK;circ(g,x+f*5.75*s,y-9.5*s,0.45*s);g.fill();circ(g,x+f*7.75*s,y-9.5*s,0.45*s);g.fill();
|
||
circ(g,x+f*6.65*s,y-8*s,0.55*s);g.fill();} // nose
|
||
function artFish(g,x,y,s){s=s||1;
|
||
g.strokeStyle="rgba(255,255,255,.55)";g.lineWidth=1.4*s;g.beginPath();g.ellipse(x,y,10*s,3*s,0,0,7);g.stroke();
|
||
fillE(g,x,y-1*s,5*s,2.6*s,"#4a86c8",1.4*s);
|
||
fillP(g,[[x+4*s,y-1*s],[x+8*s,y-3*s],[x+8*s,y+1*s]],"#4a86c8",1.2*s);
|
||
g.fillStyle="#fff";circ(g,x-3*s,y-1.5*s,0.8*s);g.fill();}
|
||
function artWolf(g,x,y,s,f,tamed){s=s||1;f=f||1;shadow(g,x,y,10*s);
|
||
const gr=tamed?"#9a8a6e":"#82828c",gl=tamed?"#b2a184":"#9a9aa4";
|
||
g.strokeStyle=INK;g.lineWidth=2.1*s;g.lineCap="round";
|
||
for(const dx of [-3.5,-1,2.5,4.5]){g.beginPath();g.moveTo(x+dx*s,y-4.5*s);g.lineTo(x+dx*s,y);g.stroke();}
|
||
g.save();g.translate(x-f*6*s,y-8*s);g.rotate(-f*0.5);fillE(g,0,0,3.3*s,2.2*s,gr,1.5*s);g.restore(); // bushy tail
|
||
fillE(g,x,y-7.5*s,6.6*s,4.3*s,gr,1.9*s); // body
|
||
g.fillStyle=gl;g.beginPath();g.ellipse(x-f*1*s,y-8.5*s,3.6*s,2*s,0,0,7);g.fill(); // back highlight
|
||
fillE(g,x+f*7*s,y-11.5*s,4.1*s,3.8*s,gr,1.8*s); // head (forward)
|
||
g.fillStyle=gl;g.beginPath();g.ellipse(x+f*8.4*s,y-9.9*s,2.4*s,1.8*s,0,0,7);g.fill(); // muzzle
|
||
fillP(g,[[x+f*4.6*s,y-13*s],[x+f*4.2*s,y-17*s],[x+f*6.8*s,y-14*s]],gr,1.3*s); // ear
|
||
fillP(g,[[x+f*7.4*s,y-14*s],[x+f*9.8*s,y-17*s],[x+f*9.6*s,y-13*s]],gr,1.3*s); // ear
|
||
g.fillStyle=INK;circ(g,x+f*5.9*s,y-12*s,1.1*s);g.fill();circ(g,x+f*8.3*s,y-12*s,1.1*s);g.fill();
|
||
g.fillStyle="#fff";circ(g,x+f*6.2*s,y-12.4*s,0.4*s);g.fill();circ(g,x+f*8.6*s,y-12.4*s,0.4*s);g.fill();
|
||
g.fillStyle=INK;g.beginPath();g.ellipse(x+f*10*s,y-9.6*s,0.95*s,0.75*s,0,0,7);g.fill(); // nose
|
||
if(tamed){g.strokeStyle="#c0492f";g.lineWidth=1.9*s;g.beginPath();g.moveTo(x+f*3.6*s,y-6.6*s);g.quadraticCurveTo(x+f*7*s,y-5*s,x+f*10*s,y-7*s);g.stroke();
|
||
g.fillStyle="#e8c84a";circ(g,x+f*6.8*s,y-5.3*s,1*s);g.fill();}} // collar + tag
|
||
|
||
function artMellow(g,px,py,scarf,tool,opt){opt=opt||{};
|
||
const s=opt.s||1,bob=opt.bob||0,face=opt.face||1;
|
||
const bw=8*s,H=22.5*s,footY=py-bob,bodyBot=footY-3*s,bodyTop=bodyBot-H;
|
||
shadow(g,px,py,bw*1.15);
|
||
// legs + feet
|
||
g.strokeStyle=INK;g.lineWidth=2.2*s;g.lineCap="round";
|
||
g.beginPath();g.moveTo(px-bw*0.4,bodyBot-1*s);g.lineTo(px-bw*0.4,footY+1*s);
|
||
g.moveTo(px+bw*0.4,bodyBot-1*s);g.lineTo(px+bw*0.4,footY+1*s);g.stroke();
|
||
g.fillStyle=INK;g.beginPath();g.ellipse(px-bw*0.5,footY+1.6*s,2.2*s,1.4*s,0,0,7);g.fill();
|
||
g.beginPath();g.ellipse(px+bw*0.5,footY+1.6*s,2.2*s,1.4*s,0,0,7);g.fill();
|
||
// leaf backpack behind (two leaves)
|
||
const packX=px-face*bw*0.5,packY=bodyTop+H*0.52;
|
||
for(const dl of [-1,1]){g.save();g.translate(packX,packY);g.rotate(face*(-0.45+dl*0.45));
|
||
fillP(g,[[0,-bw*0.95],[bw*0.5,-bw*0.1],[0,bw*0.5],[-bw*0.22,-bw*0.1]],scarf,1.4*s);
|
||
g.strokeStyle=dark(scarf,0.22);g.lineWidth=1*s;g.beginPath();g.moveTo(0,-bw*0.85);g.lineTo(0,bw*0.35);g.stroke();g.restore();}
|
||
// body (marshmallow — soft pillow cylinder: flatter top, straighter sides)
|
||
const rTop=bw*0.55,rBot=bw*0.5,L=px-bw,R=px+bw,T=bodyTop,B=bodyBot+2.6*s;
|
||
const bodyPath=()=>{g.beginPath();
|
||
g.moveTo(L,T+rTop);g.quadraticCurveTo(L,T,px-bw+rTop,T);g.lineTo(px+bw-rTop,T);
|
||
g.quadraticCurveTo(R,T,R,T+rTop);g.lineTo(R,B-rBot);
|
||
g.quadraticCurveTo(R,B,px+bw-rBot,B);g.lineTo(px-bw+rBot,B);
|
||
g.quadraticCurveTo(L,B,L,B-rBot);g.closePath();};
|
||
bodyPath();
|
||
const bg=g.createLinearGradient(px,T,px,B);
|
||
bg.addColorStop(0,opt.sick?"#eef6e2":"#fffef9");bg.addColorStop(0.55,opt.sick?"#e4efd6":"#faf4e8");bg.addColorStop(1,opt.sick?"#cfe0bf":"#eee2ca");
|
||
g.fillStyle=bg;g.fill();
|
||
// clothes: little shorts on the lower body (Life of Mellow style) — job colour lives in the scarf
|
||
const shortsY=T+H*0.72,shortsC=opt.white?"#f4f4f4":"#c2a86a"; // newcomers are dressed all in white
|
||
g.save();bodyPath();g.clip();
|
||
g.fillStyle=shortsC;g.fillRect(L-1,shortsY,(R-L)+2,B-shortsY+2);
|
||
g.fillStyle="rgba(0,0,0,.10)";g.fillRect(px+0.5*s,shortsY,R-px,B-shortsY+2); // right-side shading
|
||
g.restore();
|
||
bodyPath();inkStroke(g,2*s); // body outline over white + shorts
|
||
g.strokeStyle=dark(shortsC,0.32);g.lineWidth=1*s;g.lineCap="round";
|
||
g.beginPath();g.moveTo(L+2*s,shortsY);g.lineTo(R-2*s,shortsY); // waistband
|
||
g.moveTo(px,shortsY+1.4*s);g.lineTo(px,B-0.6*s);g.stroke(); // leg split
|
||
g.fillStyle="rgba(255,255,255,.55)";g.beginPath();g.ellipse(px-bw*0.26,T+rTop*0.7,bw*0.46,bw*0.3,-0.4,0,7);g.fill();
|
||
// eyes (soft ovals — a touch smaller & calmer, less baby-cute)
|
||
// anchored near the top as a "head zone" so a taller body doesn't create a big forehead
|
||
const ey=bodyTop+7.5*s,ex=bw*0.33,eo=bw*0.05*face;
|
||
g.fillStyle=INK;
|
||
g.beginPath();g.ellipse(px-ex+eo,ey,1.5*s,1.85*s,0,0,7);g.fill();g.beginPath();g.ellipse(px+ex+eo,ey,1.5*s,1.85*s,0,0,7);g.fill();
|
||
g.fillStyle="#fff";circ(g,px-ex+eo+0.55*s,ey-0.85*s,0.5*s);g.fill();circ(g,px+ex+eo+0.55*s,ey-0.85*s,0.5*s);g.fill();
|
||
// tiny mouth
|
||
g.strokeStyle=INK;g.lineWidth=1.0*s;g.lineCap="round";g.beginPath();
|
||
g.moveTo(px+eo-1.4*s,ey+3.1*s);g.quadraticCurveTo(px+eo,ey+4.2*s,px+eo+1.4*s,ey+3.1*s);g.stroke();
|
||
// (no rosy cheeks — the real Mellows have a minimal dots + smile face)
|
||
// neckerchief (job colour) — small & tidy, sits like a collar just under the face
|
||
const sy=bodyTop+H*0.63;
|
||
fillP(g,[[px-bw*0.92,sy-1*s],[px+bw*0.92,sy-1*s],[px+bw*0.74,sy+2.4*s],[px-bw*0.74,sy+2.4*s]],scarf,1.4*s);
|
||
fillP(g,[[px+face*bw*0.5,sy+0.2*s],[px+face*bw*0.98,sy+1.8*s],[px+face*bw*0.7,sy+5*s],[px+face*bw*0.36,sy+2.6*s]],dark(scarf,0.12),1.2*s);
|
||
// thin stick arms (Life of Mellow) — back arm rests at the side, front arm reaches out to hold the tool
|
||
const armY=bodyTop+H*0.52,hasTool=tool&&tool!=="none";
|
||
g.strokeStyle=INK;g.lineWidth=1.9*s;g.lineCap="round";
|
||
g.beginPath();g.moveTo(px-face*bw*0.82,armY);g.lineTo(px-face*(bw*0.82+2.6*s),armY+3.6*s);g.stroke();
|
||
g.beginPath();g.moveTo(px+face*bw*0.82,armY);
|
||
if(hasTool)g.lineTo(px+face*(bw+2*s),bodyTop+H*0.5); else g.lineTo(px+face*(bw*0.82+2.6*s),armY+3.6*s);
|
||
g.stroke();
|
||
if(hasTool)artTool(g,px+face*(bw+2*s),bodyTop+H*0.5,tool,s,face);
|
||
}
|
||
function artTool(g,x,y,tool,s,f){g.save();g.lineCap="round";g.lineJoin="round";
|
||
const handle=(x1,y1,x2,y2)=>{g.strokeStyle="#7a5230";g.lineWidth=2*s;g.beginPath();g.moveTo(x1,y1);g.lineTo(x2,y2);g.stroke();
|
||
g.strokeStyle=INK;g.lineWidth=2.9*s;g.globalAlpha=0.001;g.stroke();g.globalAlpha=1;};
|
||
if(tool==="axe"){handle(x-2*s,y+6*s,x+2*s,y-8*s);fillP(g,[[x+1*s,y-10*s],[x+7*f*s,y-9*s],[x+6*f*s,y-4*s],[x+1*s,y-5*s]],"#c7ccd2",1.3*s);}
|
||
else if(tool==="pick"){handle(x-2*s,y+6*s,x+2*s,y-8*s);g.strokeStyle=INK;g.lineWidth=3.4*s;g.beginPath();g.moveTo(x-4*f*s,y-6*s);g.quadraticCurveTo(x+2*s,y-11*s,x+8*f*s,y-8*s);g.stroke();
|
||
g.strokeStyle="#b0b6bd";g.lineWidth=2*s;g.stroke();}
|
||
else if(tool==="hammer"){handle(x-1*s,y+6*s,x+2*s,y-7*s);fillP(g,[[x-2*s,y-11*s],[x+5*f*s,y-11*s],[x+5*f*s,y-7*s],[x-2*s,y-7*s]],"#9098a0",1.3*s);}
|
||
else if(tool==="bow"){g.strokeStyle=INK;g.lineWidth=2.4*s;g.beginPath();g.arc(x+2*f*s,y-2*s,8*s,-1.1,1.1);g.stroke();
|
||
g.strokeStyle="#8a5a2e";g.lineWidth=1.4*s;g.stroke();g.strokeStyle="#e8e0c8";g.lineWidth=1*s;g.beginPath();g.moveTo(x+2*f*s-6*s,y-9*s);g.lineTo(x+2*f*s-6*s,y+5*s);g.stroke();}
|
||
else if(tool==="rod"){handle(x-3*s,y+7*s,x+5*f*s,y-10*s);g.strokeStyle="rgba(255,255,255,.7)";g.lineWidth=0.9*s;g.beginPath();g.moveTo(x+5*f*s,y-10*s);g.lineTo(x+7*f*s,y-2*s);g.stroke();}
|
||
else if(tool==="scythe"){handle(x-2*s,y+7*s,x+3*s,y-9*s);g.strokeStyle=INK;g.lineWidth=2.8*s;g.beginPath();g.arc(x+3*f*s,y-9*s,6*s,-0.3,1.4);g.stroke();g.strokeStyle="#c7ccd2";g.lineWidth=1.6*s;g.stroke();}
|
||
else if(tool==="staff"){handle(x,y+7*s,x,y-10*s);g.strokeStyle="#8a6b45";g.lineWidth=1.6*s;g.beginPath();g.arc(x+1*f*s,y-10*s,3*s,-1.6,1.2);g.stroke();}
|
||
else if(tool==="saw"){handle(x-3*s,y+6*s,x-3*s,y-2*s);fillP(g,[[x-4*s,y-2*s],[x+8*f*s,y-6*s],[x+8*f*s,y-3*s],[x-4*s,y+1*s]],"#c7ccd2",1.3*s);}
|
||
g.restore();}
|
||
|
||
function artBubble(g,x,y,paint){const w=11,h=9,cy=y-15;
|
||
g.beginPath();
|
||
const r=4,x0=x-w,y0=cy-h,x1=x+w,y1=cy+h;
|
||
g.moveTo(x0+r,y0);g.arcTo(x1,y0,x1,y1,r);g.arcTo(x1,y1,x0,y1,r);g.arcTo(x0,y1,x0,y0,r);g.arcTo(x0,y0,x1,y0,r);g.closePath();
|
||
g.fillStyle="#fdf6e3";g.fill();inkStroke(g,1.5);
|
||
fillP(g,[[x-3,cy+h-1],[x+3,cy+h-1],[x,cy+h+5]],"#fdf6e3",1.5);
|
||
paint(g,x,cy,6);}
|
||
|
||
/* --- icon glyphs (used in-world small + UI) --- */
|
||
function artResIcon(g,key,x,y,r){const u=r/8;
|
||
switch(key){
|
||
case "wood":fillE(g,x,y,r*0.95,r*0.5,"#b3814d",1.2);fillE(g,x+r*0.55,y-r*0.3,r*0.34,r*0.44,"#c99a5a",1.1);
|
||
g.strokeStyle="#8a5f38";g.lineWidth=u;g.beginPath();g.ellipse(x+r*0.55,y-r*0.3,r*0.16,r*0.24,0,0,7);g.stroke();break;
|
||
case "planks":{const bw2=r*1.8;for(let i=0;i<3;i++){const yy=y-r*0.6+i*r*0.55;
|
||
fillP(g,[[x-bw2/2,yy],[x+bw2/2,yy],[x+bw2/2,yy+r*0.34],[x-bw2/2,yy+r*0.34]],"#d3a96a",1.1);
|
||
g.strokeStyle="#a9784a";g.lineWidth=u*0.7;g.beginPath();g.moveTo(x-bw2/2+u,yy+r*0.17);g.lineTo(x+bw2/2-u,yy+r*0.17);g.stroke();}}break;
|
||
case "stone":fillP(g,[[x-r,y+r*0.3],[x-r*0.5,y-r*0.7],[x+r*0.5,y-r*0.8],[x+r,y+r*0.1],[x,y+r*0.7]],"#9098a0",1.2);
|
||
g.fillStyle="#b3bac1";poly(g,[[x-r*0.5,y-r*0.7],[x+r*0.5,y-r*0.8],[x,y-r*0.2]]);g.fill();break;
|
||
case "iron":fillP(g,[[x-r,y+r*0.3],[x-r*0.4,y-r*0.8],[x+r*0.6,y-r*0.7],[x+r,y+r*0.2],[x,y+r*0.7]],"#5c5a5f",1.2);
|
||
g.fillStyle="#d9a24a";circ(g,x-r*0.2,y-r*0.2,u*1.3);g.fill();circ(g,x+r*0.4,y+r*0.1,u);g.fill();break;
|
||
case "fruit":fillC(g,x-r*0.35,y+r*0.1,r*0.5,"#c1443e",1.2);fillC(g,x+r*0.35,y+r*0.1,r*0.5,"#c1443e",1.2);fillC(g,x,y-r*0.3,r*0.5,"#c1443e",1.2);
|
||
g.fillStyle="#5aa03e";poly(g,[[x-u,y-r*0.7],[x+r*0.4,y-r],[x+u,y-r*0.4]]);g.fill();break;
|
||
case "meat":fillE(g,x+r*0.1,y,r*0.7,r*0.55,"#b96a44",1.2);g.strokeStyle="#f0e4d0";g.lineWidth=u*1.6;g.lineCap="round";
|
||
g.beginPath();g.moveTo(x-r*0.4,y+r*0.3);g.lineTo(x-r,y+r*0.7);g.stroke();g.fillStyle="#f0e4d0";circ(g,x-r,y+r*0.6,u*1.2);g.fill();circ(g,x-r*0.8,y+r*0.85,u*1.2);g.fill();break;
|
||
case "fish":artFish(g,x,y,r/9);break;
|
||
case "milk":fillP(g,[[x-r*0.45,y-r*0.35],[x-r*0.22,y-r*0.72],[x+r*0.22,y-r*0.72],[x+r*0.45,y-r*0.35],[x+r*0.45,y+r*0.7],[x-r*0.45,y+r*0.7]],"#f6f2e8",1.2);
|
||
g.fillStyle="#7fb8d8";g.fillRect(x-r*0.45,y+r*0.02,r*0.9,r*0.32);g.fillStyle="#cfc3ac";g.fillRect(x-r*0.2,y-r*0.85,r*0.4,r*0.18);break;
|
||
case "crops":g.strokeStyle="#a97f2e";g.lineWidth=u*1.4;g.lineCap="round";g.beginPath();g.moveTo(x,y+r*0.8);g.lineTo(x,y-r*0.9);g.stroke();
|
||
g.fillStyle="#e0b84a";for(let i=0;i<3;i++){const yy=y-r*0.5+i*r*0.5;g.beginPath();g.ellipse(x-r*0.35,yy,r*0.18,r*0.3,0.5,0,7);g.fill();g.beginPath();g.ellipse(x+r*0.35,yy,r*0.18,r*0.3,-0.5,0,7);g.fill();}break;
|
||
case "corn":g.save();g.translate(x,y);fillE(g,0,r*0.05,r*0.42,r*0.82,"#f2c94c",1.2);
|
||
g.fillStyle="#d9a72e";for(let a=-1;a<=1;a++)for(let b2=-2;b2<=2;b2++){circ(g,a*r*0.22,b2*r*0.28,r*0.07);g.fill();}
|
||
g.fillStyle="#6aa63e";poly(g,[[-r*0.4,r*0.2],[-r*0.75,-r*0.5],[-r*0.15,r*0.6]]);g.fill();
|
||
poly(g,[[r*0.4,r*0.2],[r*0.75,-r*0.5],[r*0.15,r*0.6]]);g.fill();g.restore();break;
|
||
case "pumpkin":fillE(g,x,y+r*0.1,r*0.92,r*0.75,"#e08a3a",1.2);
|
||
g.strokeStyle="#b96a24";g.lineWidth=u*0.9;for(const dx of [-0.45,0,0.45]){g.beginPath();g.ellipse(x+dx*r*0.7,y+r*0.1,r*0.2,r*0.7,0,-1.1,1.1);g.stroke();}
|
||
g.strokeStyle="#5f8a3a";g.lineWidth=u*1.4;g.lineCap="round";g.beginPath();g.moveTo(x,y-r*0.6);g.lineTo(x+r*0.12,y-r*0.95);g.stroke();break;
|
||
case "carrot":g.save();g.translate(x,y);fillP(g,[[-r*0.4,-r*0.35],[r*0.4,-r*0.35],[0,r*0.9]],"#e07a3a",1.2);
|
||
g.strokeStyle="#eaa06a";g.lineWidth=u*0.7;for(const yy of [-0.15,0.15,0.45]){g.beginPath();g.moveTo(-r*0.32,yy*r);g.lineTo(r*0.32,yy*r);g.stroke();}
|
||
g.strokeStyle="#5f9e3a";g.lineWidth=u*1.3;g.lineCap="round";for(const a of [-0.5,0,0.5]){g.beginPath();g.moveTo(0,-r*0.32);g.lineTo(Math.sin(a)*r*0.5,-r*0.32-Math.cos(a)*r*0.55);g.stroke();}g.restore();break;
|
||
case "cabbage":fillC(g,x,y,r*0.82,"#7fae54",1.2);g.strokeStyle="#5f8a3a";g.lineWidth=u*0.9;
|
||
for(const a of [0.3,1.4,2.5,3.6,4.7,5.8]){g.beginPath();g.arc(x,y,r*0.55,a,a+0.9);g.stroke();}
|
||
g.fillStyle="#a3c777";circ(g,x-r*0.12,y-r*0.1,r*0.3);g.fill();break;
|
||
case "seed":g.fillStyle="#8a6b45";for(const p of [[-r*0.3,r*0.15],[r*0.25,-r*0.1],[0,r*0.45]]){g.save();g.translate(x+p[0],y+p[1]);g.rotate(0.6);
|
||
g.beginPath();g.ellipse(0,0,r*0.24,r*0.42,0,0,7);g.fill();inkStroke(g,0.9);g.restore();}break;
|
||
case "meals":g.fillStyle="#e0d6c0";g.beginPath();g.arc(x,y,r*0.85,0,Math.PI);g.fill();inkStroke(g,1.2);
|
||
g.fillStyle="#c0563e";g.beginPath();g.ellipse(x,y,r*0.72,r*0.24,0,0,Math.PI,true);g.fill();
|
||
g.strokeStyle="rgba(180,140,90,.9)";g.lineWidth=u;g.beginPath();g.moveTo(x-r*0.3,y-r*0.3);g.quadraticCurveTo(x-r*0.1,y-r*0.7,x-r*0.3,y-r);g.moveTo(x+r*0.3,y-r*0.3);g.quadraticCurveTo(x+r*0.5,y-r*0.7,x+r*0.3,y-r);g.stroke();break;
|
||
case "herbs":artHerb(g,x,y+r*0.6,r/11);break;
|
||
case "contra":g.strokeStyle="#6a9e52";g.lineWidth=u*1.2;g.lineCap="round";g.beginPath();g.moveTo(x,y+r*0.85);g.lineTo(x,y-r*0.35);g.stroke();
|
||
g.fillStyle="#cf7fb0";for(const sgn of [-1,1]){g.save();g.translate(x,y+r*0.1);g.rotate(sgn*0.55);g.beginPath();g.ellipse(sgn*r*0.33,0,r*0.32,r*0.16,0,0,7);g.fill();g.restore();}
|
||
g.fillStyle="#ecb3d6";circ(g,x,y-r*0.55,r*0.28);g.fill();inkStroke(g,1);g.fillStyle="#fff";circ(g,x-r*0.08,y-r*0.63,r*0.08);g.fill();break;
|
||
case "potion":g.fillStyle="#8e5cc0";g.beginPath();g.arc(x,y+r*0.15,r*0.62,Math.PI*0.85,Math.PI*0.15);g.closePath();g.fill();inkStroke(g,1.2);
|
||
g.fillStyle="#cfc6b8";g.fillRect(x-u*0.8,y-r,u*1.6,r*0.7);inkStroke(g,1);break;
|
||
case "wine":fillP(g,[[x-r*0.28,y-r],[x+r*0.28,y-r],[x+r*0.28,y+r*0.7],[x-r*0.28,y+r*0.7]],"#5a7a3a",1.2);
|
||
g.fillStyle="#8a2f3a";g.fillRect(x-r*0.24,y+r*0.05,r*0.48,r*0.55);g.fillStyle=INK;g.fillRect(x-u*0.7,y-r-u*1.2,u*1.4,u*1.4);break;
|
||
case "firewood":{const fl=g.createLinearGradient(x,y+r,x,y-r);fl.addColorStop(0,"#f2c14a");fl.addColorStop(0.6,"#e8862f");fl.addColorStop(1,"#d5432a");
|
||
poly(g,[[x-r*0.6,y+r*0.7],[x,y-r],[x+r*0.6,y+r*0.7],[x+r*0.25,y-r*0.1],[x-r*0.25,y-r*0.1]]);g.fillStyle=fl;g.fill();inkStroke(g,1.1);
|
||
g.fillStyle="#ffe08a";poly(g,[[x-u,y+r*0.4],[x+u*0.5,y-r*0.5],[x+u*1.5,y+r*0.4]]);g.fill();}break;
|
||
case "tools":g.strokeStyle="#7a5230";g.lineWidth=u*1.7;g.lineCap="round";g.beginPath();g.moveTo(x-r*0.5,y+r*0.6);g.lineTo(x+r*0.2,y-r*0.3);g.stroke();
|
||
g.save();g.translate(x+r*0.35,y-r*0.5);g.rotate(-0.7);fillP(g,[[-r*0.5,-u*1.4],[r*0.5,-u*1.4],[r*0.5,u*1.4],[-r*0.5,u*1.4]],"#9098a0",1.1);g.restore();break;
|
||
case "wool":fillC(g,x,y,r*0.82,"#efe7d6",1.2);g.strokeStyle="#cfc3ac";g.lineWidth=u;
|
||
for(const a of [0.4,1.2,2.0,2.8]){g.beginPath();g.arc(x,y,r*0.55,a,a+1.6);g.stroke();}break;
|
||
case "leather":fillP(g,[[x-r*0.8,y-r*0.5],[x+r*0.7,y-r*0.7],[x+r*0.8,y+r*0.5],[x-r*0.6,y+r*0.7]],"#a06a3e",1.2);break;
|
||
case "axe":g.strokeStyle="#7a5230";g.lineWidth=u*1.7;g.lineCap="round";g.beginPath();g.moveTo(x-r*0.45,y+r*0.75);g.lineTo(x+r*0.35,y-r*0.6);g.stroke();
|
||
g.save();g.translate(x+r*0.4,y-r*0.55);g.rotate(-0.5);fillP(g,[[-u*0.6,-r*0.5],[r*0.55,-r*0.3],[r*0.55,r*0.05],[-u*0.6,r*0.15]],"#c7ccd2",1.1);g.restore();break;
|
||
case "pickaxe":g.strokeStyle="#7a5230";g.lineWidth=u*1.7;g.lineCap="round";g.beginPath();g.moveTo(x,y+r*0.85);g.lineTo(x,y-r*0.45);g.stroke();
|
||
g.strokeStyle=INK;g.lineWidth=u*2.4;g.beginPath();g.moveTo(x-r*0.8,y-r*0.05);g.quadraticCurveTo(x,y-r*0.78,x+r*0.8,y-r*0.05);g.stroke();
|
||
g.strokeStyle="#b0b6bd";g.lineWidth=u*1.3;g.stroke();break;
|
||
case "bow":{g.strokeStyle="#8a5a2e";g.lineWidth=u*1.5;g.lineCap="round";const bx=x-r*0.2;g.beginPath();g.arc(bx,y,r*0.85,-1.0,1.0);g.stroke();
|
||
g.strokeStyle="#e8e0c8";g.lineWidth=u*0.7;g.beginPath();g.moveTo(bx+r*0.85*Math.cos(-1.0),y+r*0.85*Math.sin(-1.0));g.lineTo(bx+r*0.85*Math.cos(1.0),y+r*0.85*Math.sin(1.0));g.stroke();
|
||
g.strokeStyle="#9098a0";g.lineWidth=u*0.9;g.beginPath();g.moveTo(x-r*0.4,y);g.lineTo(x+r*0.7,y);g.stroke();
|
||
fillP(g,[[x+r*0.78,y],[x+r*0.48,y-r*0.24],[x+r*0.48,y+r*0.24]],"#c7ccd2",0);}break;
|
||
case "basket":fillP(g,[[x-r*0.72,y-r*0.15],[x+r*0.72,y-r*0.15],[x+r*0.52,y+r*0.72],[x-r*0.52,y+r*0.72]],"#c2914e",1.2);
|
||
g.strokeStyle="#8a5f38";g.lineWidth=u*0.7;for(const dx of [-0.35,0,0.35]){g.beginPath();g.moveTo(x+dx*r*0.85,y-r*0.15);g.lineTo(x+dx*r*0.6,y+r*0.72);g.stroke();}
|
||
g.beginPath();g.moveTo(x-r*0.66,y+r*0.28);g.lineTo(x+r*0.66,y+r*0.28);g.stroke();
|
||
g.strokeStyle="#a06a3e";g.lineWidth=u*1.1;g.beginPath();g.arc(x,y-r*0.15,r*0.5,Math.PI,0);g.stroke();
|
||
g.fillStyle="#c1443e";circ(g,x-r*0.18,y-r*0.02,u*1.1);g.fill();circ(g,x+r*0.16,y-r*0.02,u*1.1);g.fill();break;
|
||
case "hoe":g.strokeStyle="#7a5230";g.lineWidth=u*1.6;g.lineCap="round";g.beginPath();g.moveTo(x-r*0.5,y+r*0.7);g.lineTo(x+r*0.35,y-r*0.55);g.stroke();
|
||
g.strokeStyle=INK;g.lineWidth=u*2.6;g.beginPath();g.moveTo(x+r*0.3,y-r*0.55);g.lineTo(x+r*0.75,y-r*0.35);g.stroke();
|
||
g.strokeStyle="#9098a0";g.lineWidth=u*1.5;g.stroke();break;
|
||
default:fillC(g,x,y,r*0.6,"#b98a4a",1.2);}}
|
||
function artBldIcon(g,key,x,y,r){const u=r/8;
|
||
switch(key){
|
||
case "storage":fillP(g,[[x-r*0.7,y-r*0.5],[x+r*0.7,y-r*0.5],[x+r*0.7,y+r*0.6],[x-r*0.7,y+r*0.6]],"#c99a5a",1.3);
|
||
g.strokeStyle=INK;g.lineWidth=u;g.beginPath();g.moveTo(x-r*0.7,y);g.lineTo(x+r*0.7,y);g.moveTo(x,y-r*0.5);g.lineTo(x,y+r*0.6);g.stroke();break;
|
||
case "house":fillP(g,[[x-r*0.6,y-r*0.1],[x+r*0.6,y-r*0.1],[x+r*0.6,y+r*0.7],[x-r*0.6,y+r*0.7]],"#e8d9b0",1.3);
|
||
fillP(g,[[x-r*0.8,y-r*0.1],[x,y-r*0.9],[x+r*0.8,y-r*0.1]],"#b06a44",1.3);
|
||
g.fillStyle=INK;g.fillRect(x-r*0.15,y+r*0.2,r*0.3,r*0.5);break;
|
||
case "fire":artResIcon(g,"firewood",x,y,r);break;
|
||
case "saw":g.strokeStyle="#7a5230";g.lineWidth=u*1.5;g.beginPath();g.moveTo(x-r*0.8,y+r*0.5);g.lineTo(x-r*0.4,y+r*0.5);g.stroke();
|
||
fillP(g,[[x-r*0.5,y+r*0.5],[x+r*0.8,y-r*0.4],[x+r*0.8,y-r*0.05],[x-r*0.5,y+r*0.85]],"#c7ccd2",1.2);break;
|
||
case "hammer":artResIcon(g,"tools",x,y,r);break;
|
||
case "flask":artResIcon(g,"potion",x,y,r);break;
|
||
case "wine":artResIcon(g,"wine",x,y,r);break;
|
||
case "pot":g.fillStyle="#6b6b72";g.beginPath();g.arc(x,y+r*0.1,r*0.7,0,Math.PI);g.fill();inkStroke(g,1.2);
|
||
fillP(g,[[x-r*0.75,y-r*0.05],[x+r*0.75,y-r*0.05],[x+r*0.75,y+r*0.2],[x-r*0.75,y+r*0.2]],"#7a7a82",1.1);break;
|
||
case "crops":artResIcon(g,"crops",x,y,r);break;
|
||
case "milk":artResIcon(g,"milk",x,y,r);break;
|
||
case "cleaver":artResIcon(g,"meat",x-r*0.2,y,r*0.8);g.strokeStyle="#8a929a";g.lineWidth=r*0.18;g.lineCap="round";g.beginPath();g.moveTo(x+r*0.2,y-r*0.7);g.lineTo(x+r*0.7,y+r*0.6);g.stroke();break;
|
||
case "sheep":artSheep(g,x,y+r*0.5,r/10,1);break;
|
||
case "cross":fillC(g,x,y,r*0.82,"#f4ecd4",1.2);g.fillStyle="#cf5a5a";g.fillRect(x-r*0.15,y-r*0.5,r*0.3,r);g.fillRect(x-r*0.5,y-r*0.15,r,r*0.3);break;
|
||
case "baby":fillC(g,x,y-r*0.1,r*0.55,"#f4ecd4",1.2);g.fillStyle=INK;circ(g,x-r*0.18,y-r*0.15,u*0.9);g.fill();circ(g,x+r*0.18,y-r*0.15,u*0.9);g.fill();
|
||
fillP(g,[[x-r*0.55,y+r*0.7],[x,y+r*0.2],[x+r*0.55,y+r*0.7]],"#d98ab4",1.2);break;
|
||
case "school":fillP(g,[[x-r*0.9,y-r*0.5],[x,y-r*0.25],[x,y+r*0.7],[x-r*0.9,y+r*0.45]],"#f3ecd8",1.2);
|
||
fillP(g,[[x+r*0.9,y-r*0.5],[x,y-r*0.25],[x,y+r*0.7],[x+r*0.9,y+r*0.45]],"#e8dcc0",1.2);
|
||
g.strokeStyle="#b98a4a";g.lineWidth=u*0.7;g.beginPath();g.moveTo(x-r*0.7,y-r*0.15);g.lineTo(x-r*0.15,y+r*0.05);g.moveTo(x-r*0.7,y+r*0.15);g.lineTo(x-r*0.15,y+r*0.35);
|
||
g.moveTo(x+r*0.7,y-r*0.15);g.lineTo(x+r*0.15,y+r*0.05);g.moveTo(x+r*0.7,y+r*0.15);g.lineTo(x+r*0.15,y+r*0.35);g.stroke();break;
|
||
case "grave":g.beginPath();g.moveTo(x-r*0.5,y+r*0.7);g.lineTo(x-r*0.5,y-r*0.3);g.arc(x,y-r*0.3,r*0.5,Math.PI,0);g.lineTo(x+r*0.5,y+r*0.7);g.closePath();g.fillStyle="#cccfd3";g.fill();inkStroke(g,1.2);
|
||
g.strokeStyle="#9aa0a6";g.lineWidth=r*0.12;g.lineCap="round";g.beginPath();g.moveTo(x,y-r*0.5);g.lineTo(x,y+r*0.3);g.moveTo(x-r*0.22,y-r*0.2);g.lineTo(x+r*0.22,y-r*0.2);g.stroke();break;
|
||
case "wolf":{const gr="#8a8a94";fillP(g,[[x-r*0.5,y-r*0.75],[x-r*0.2,y-r*0.25],[x-r*0.6,y-r*0.2]],gr,1);fillP(g,[[x+r*0.5,y-r*0.75],[x+r*0.2,y-r*0.25],[x+r*0.6,y-r*0.2]],gr,1); // ears
|
||
fillP(g,[[x-r*0.6,y-r*0.35],[x+r*0.6,y-r*0.35],[x+r*0.4,y+r*0.3],[x,y+r*0.7],[x-r*0.4,y+r*0.3]],gr,1.3); // head+snout
|
||
g.fillStyle="#f0f0f4";circ(g,x,y+r*0.55,r*0.14);g.fill();g.fillStyle=INK;circ(g,x-r*0.22,y-r*0.02,u*0.9);g.fill();circ(g,x+r*0.22,y-r*0.02,u*0.9);g.fill();circ(g,x,y+r*0.32,u*1.1);g.fill();}break;
|
||
case "boat":fillP(g,[[x-r*0.8,y+r*0.1],[x+r*0.8,y+r*0.1],[x+r*0.5,y+r*0.6],[x-r*0.5,y+r*0.6]],"#8a5f38",1.2);
|
||
g.strokeStyle=INK;g.lineWidth=u;g.beginPath();g.moveTo(x,y+r*0.1);g.lineTo(x,y-r*0.7);g.stroke();fillP(g,[[x,y-r*0.7],[x+r*0.5,y-r*0.2],[x,y-r*0.15]],"#d5715a",1.1);break;
|
||
case "bridge":g.strokeStyle="#a98a5a";g.lineWidth=u*1.6;g.beginPath();g.arc(x,y+r*0.5,r*0.8,Math.PI,0);g.stroke();
|
||
g.strokeStyle=INK;g.lineWidth=u*0.9;g.beginPath();g.moveTo(x-r*0.8,y-r*0.3);g.lineTo(x+r*0.8,y-r*0.3);g.stroke();break;
|
||
case "flower":{var fc=["#e0607a","#f2b84a","#6aa9e0"];g.strokeStyle="#5f8a3a";g.lineWidth=u;g.beginPath();g.moveTo(x,y+r*0.7);g.lineTo(x,y-r*0.2);g.stroke();
|
||
for(var fi=0;fi<3;fi++){var fx=x+(fi-1)*r*0.5,fy=y-r*0.2-Math.abs(fi-1)*r*0.1;g.fillStyle=fc[fi];for(var fk=0;fk<5;fk++){var fa=fk/5*6.283;circ(g,fx+Math.cos(fa)*r*0.22,fy+Math.sin(fa)*r*0.22,r*0.16);g.fill();}g.fillStyle="#fff3c0";circ(g,fx,fy,r*0.11);g.fill();}break;}
|
||
case "lantern":g.strokeStyle="#5a4835";g.lineWidth=u*1.3;g.lineCap="round";g.beginPath();g.moveTo(x,y+r*0.8);g.lineTo(x,y-r*0.3);g.stroke();
|
||
fillP(g,[[x-r*0.4,y-r*0.3],[x+r*0.4,y-r*0.3],[x+r*0.4,y+r*0.35],[x-r*0.4,y+r*0.35]],"#ffe89a",1.1);
|
||
fillP(g,[[x-r*0.5,y-r*0.3],[x+r*0.5,y-r*0.3],[x+r*0.3,y-r*0.65],[x-r*0.3,y-r*0.65]],"#6b5640",1);break;
|
||
case "well":fillE(g,x,y+r*0.3,r*0.7,r*0.35,"#9aa0a6",1.3);g.fillStyle="#3a4550";g.beginPath();g.ellipse(x,y+r*0.25,r*0.45,r*0.2,0,0,7);g.fill();
|
||
g.strokeStyle="#6b5640";g.lineWidth=u*1.1;g.beginPath();g.moveTo(x-r*0.5,y+r*0.2);g.lineTo(x-r*0.45,y-r*0.6);g.moveTo(x+r*0.5,y+r*0.2);g.lineTo(x+r*0.45,y-r*0.6);g.stroke();
|
||
fillP(g,[[x-r*0.65,y-r*0.55],[x+r*0.65,y-r*0.55],[x+r*0.35,y-r*0.95],[x-r*0.35,y-r*0.95]],"#a35a4a",1.1);break;
|
||
case "bench":g.strokeStyle="#5a4835";g.lineWidth=u*1.1;g.lineCap="round";g.beginPath();g.moveTo(x-r*0.55,y+r*0.1);g.lineTo(x-r*0.55,y+r*0.6);g.moveTo(x+r*0.55,y+r*0.1);g.lineTo(x+r*0.55,y+r*0.6);g.stroke();
|
||
fillP(g,[[x-r*0.7,y],[x+r*0.7,y],[x+r*0.7,y+r*0.18],[x-r*0.7,y+r*0.18]],"#b0895a",1.1);
|
||
g.strokeStyle="#5a4835";g.lineWidth=u*0.9;g.beginPath();g.moveTo(x-r*0.55,y);g.lineTo(x-r*0.55,y-r*0.55);g.moveTo(x+r*0.55,y);g.lineTo(x+r*0.55,y-r*0.55);g.stroke();
|
||
fillP(g,[[x-r*0.7,y-r*0.5],[x+r*0.7,y-r*0.5],[x+r*0.7,y-r*0.32],[x-r*0.7,y-r*0.32]],"#b0895a",1);break;
|
||
case "statue":fillP(g,[[x-r*0.5,y+r*0.7],[x+r*0.5,y+r*0.7],[x+r*0.4,y+r*0.3],[x-r*0.4,y+r*0.3]],"#aab0b6",1.2);
|
||
g.fillStyle="#c4c9ce";g.beginPath();g.moveTo(x-r*0.32,y+r*0.3);g.quadraticCurveTo(x-r*0.42,y-r*0.7,x,y-r*0.7);g.quadraticCurveTo(x+r*0.42,y-r*0.7,x+r*0.32,y+r*0.3);g.closePath();g.fill();inkStroke(g,1.2);
|
||
g.fillStyle="#868c92";circ(g,x-r*0.12,y-r*0.35,u*0.7);g.fill();circ(g,x+r*0.12,y-r*0.35,u*0.7);g.fill();break;
|
||
default:fillP(g,[[x-r*0.6,y-r*0.5],[x+r*0.6,y-r*0.5],[x+r*0.6,y+r*0.5],[x-r*0.6,y+r*0.5]],"#c99a5a",1.2);}}
|
||
function artSeasonIcon(g,name,x,y,r){
|
||
if(name==="Spring"){artHerb(g,x,y+r*0.7,r/9);}
|
||
else if(name==="Summer"){fillC(g,x,y,r*0.5,"#f2b83a",1.4);g.strokeStyle="#f2b83a";g.lineWidth=r*0.16;g.lineCap="round";
|
||
for(let i=0;i<8;i++){const a=i/8*7;g.beginPath();g.moveTo(x+Math.cos(a)*r*0.72,y+Math.sin(a)*r*0.72);g.lineTo(x+Math.cos(a)*r,y+Math.sin(a)*r);g.stroke();}}
|
||
else if(name==="Fall"){fillP(g,[[x,y-r],[x+r*0.7,y-r*0.2],[x+r*0.4,y+r*0.8],[x-r*0.4,y+r*0.8],[x-r*0.7,y-r*0.2]],"#d5772f",1.3);
|
||
g.strokeStyle="#8a4e1f";g.lineWidth=r*0.1;g.beginPath();g.moveTo(x,y-r*0.6);g.lineTo(x,y+r*0.8);g.stroke();}
|
||
else{g.strokeStyle="#7fb8d8";g.lineWidth=r*0.16;g.lineCap="round";for(let i=0;i<6;i++){const a=i/6*7;g.beginPath();g.moveTo(x,y);g.lineTo(x+Math.cos(a)*r,y+Math.sin(a)*r);g.stroke();}}}
|
||
function artMelHead(g,x,y,r){fillE(g,x,y+r*0.15,r*0.78,r*0.9,"#f3ecd8",1.3);
|
||
g.fillStyle=INK;circ(g,x-r*0.24,y,r*0.13);g.fill();circ(g,x+r*0.24,y,r*0.13);g.fill();
|
||
fillP(g,[[x-r*0.7,y+r*0.5],[x+r*0.7,y+r*0.5],[x+r*0.55,y+r*0.72],[x-r*0.55,y+r*0.72]],"#6ab04a",1.1);}
|
||
function artNeedIcon(g,key,x,y,r){
|
||
if(key==="home")artBldIcon(g,"house",x,y,r);
|
||
else if(key==="idle"){g.fillStyle=INK;g.font="bold "+(r*1.7)+"px sans-serif";g.textAlign="center";g.textBaseline="middle";g.fillText("?",x,y);}
|
||
else if(key==="preg"){g.beginPath();g.moveTo(x,y+r*0.7);g.bezierCurveTo(x-r,y-r*0.2,x-r*0.4,y-r*0.95,x,y-r*0.3);
|
||
g.bezierCurveTo(x+r*0.4,y-r*0.95,x+r,y-r*0.2,x,y+r*0.7);g.closePath();g.fillStyle="#d98ab4";g.fill();inkStroke(g,1.2);}
|
||
else if(key==="cold")artSeasonIcon(g,"Winter",x,y,r);
|
||
else if(key==="food")artResIcon(g,"meat",x,y,r);
|
||
else if(key==="sick")artBldIcon(g,"cross",x,y,r);
|
||
else if(key==="tool")artResIcon(g,"tools",x,y,r);
|
||
else artMelHead(g,x,y,r);}
|
||
|
||
const _ic={};
|
||
function icon(kind,key,px){const k=kind+":"+key+":"+px;if(_ic[k])return _ic[k];
|
||
const c=document.createElement("canvas");c.width=px;c.height=px;const g=c.getContext("2d");
|
||
const r=px*0.36,cx=px/2,cy=px/2;
|
||
if(kind==="res")artResIcon(g,key,cx,cy,r);
|
||
else if(kind==="bld")artBldIcon(g,BUILD[key].icon,cx,cy,r);
|
||
else if(kind==="season")artSeasonIcon(g,key,cx,cy,r);
|
||
else if(kind==="need")artNeedIcon(g,key,cx,cy,r);
|
||
else if(kind==="mel")artMelHead(g,cx,cy,r);
|
||
else if(kind==="job"){const j=JOBS[key];
|
||
if(j.res)artResIcon(g,j.res,cx,cy,r);
|
||
else if(key==="builder"||key==="craftsman")artResIcon(g,"tools",cx,cy,r);
|
||
else if(key==="herbalist")artResIcon(g,"potion",cx,cy,r);
|
||
else if(key==="brewer")artResIcon(g,"wine",cx,cy,r);
|
||
else if(key==="cook")artBldIcon(g,"pot",cx,cy,r);
|
||
else if(key==="doctor")artBldIcon(g,"cross",cx,cy,r);
|
||
else if(key==="midwife")artBldIcon(g,"baby",cx,cy,r);
|
||
else artMelHead(g,cx,cy,r);}
|
||
const u=c.toDataURL();_ic[k]=u;return u;}
|
||
function img(kind,key,cls){return `<img class="ic${cls?' '+cls:''}" src="${icon(kind,key,44)}">`;}
|
||
|
||
/* ================= RENDER ================= */
|
||
function resize(){DPR=Math.min(2,window.devicePixelRatio||1);VW=window.innerWidth;VH=window.innerHeight;
|
||
cv.width=VW*DPR;cv.height=VH*DPR;cv.style.width=VW+"px";cv.style.height=VH+"px";ctx.setTransform(DPR,0,0,DPR,0,0);}
|
||
function seasonTint(){switch(season()){case "Spring":return null;case "Summer":return null;
|
||
case "Fall":return "rgba(216,150,70,0.07)";case "Winter":return "rgba(200,225,255,0.19)";}}
|
||
// gentle time-of-day wash: warm dawn → clear noon → amber dusk → soft-cool "night" (never fully dark). ~90s per cycle.
|
||
const TOD_LEN=90, TOD_KEYS=[[0.00,247,188,158,0.12],[0.27,255,255,255,0.0],[0.5,240,158,92,0.11],[0.72,120,142,198,0.14],[1.0,247,188,158,0.12]];
|
||
function timeTint(){const p=((S.tsec||0)%TOD_LEN)/TOD_LEN;let a=TOD_KEYS[0],b=TOD_KEYS[TOD_KEYS.length-1];
|
||
for(let i=0;i<TOD_KEYS.length-1;i++)if(p>=TOD_KEYS[i][0]&&p<TOD_KEYS[i+1][0]){a=TOD_KEYS[i];b=TOD_KEYS[i+1];break;}
|
||
const t=(p-a[0])/((b[0]-a[0])||1),L=(x,y)=>x+(y-x)*t;
|
||
return `rgba(${Math.round(L(a[1],b[1]))},${Math.round(L(a[2],b[2]))},${Math.round(L(a[3],b[3]))},${L(a[4],b[4]).toFixed(3)})`;}
|
||
function hash2(x,y){let n=(x*73856093)^(y*19349663);n=(n^(n>>>13))>>>0;return (n%1000)/1000;}
|
||
function drawTileBlock(gx,gy){
|
||
const p=worldToScreen(gx,gy),isle=ISLANDS[S.island],kind=S.tiles[gy][gx],winter=isWinter();
|
||
const hw=TILE_W/2,hh=TILE_H/2;
|
||
let topC,sideC;
|
||
if(kind==="water"){
|
||
const coastal=[[1,0],[-1,0],[0,1],[0,-1]].some(d=>{const nx=gx+d[0],ny=gy+d[1];return S.tiles[ny]&&S.tiles[ny][nx]==="land";});
|
||
topC=coastal?(winter?"#d8e6ee":"#accdd8"):(winter?"#c4d7e3":"#8db2cb");sideC=winter?"#a5bcca":"#7299b5";} // paler shallows at the shore
|
||
else{topC=((gx+gy)&1)?isle.grass:isle.grass2;sideC=isle.side;
|
||
if(winter){topC=blend(topC,"#eef6fc",0.55);sideC=blend(sideC,"#cdd8e0",0.4);}}
|
||
poly(ctx,[[p.x-hw,p.y],[p.x,p.y+hh],[p.x,p.y+hh+TILE_TH],[p.x-hw,p.y+TILE_TH]],dark(sideC,0.05));
|
||
poly(ctx,[[p.x+hw,p.y],[p.x,p.y+hh],[p.x,p.y+hh+TILE_TH],[p.x+hw,p.y+TILE_TH]],dark(sideC,0.18));
|
||
poly(ctx,[[p.x,p.y-hh],[p.x+hw,p.y],[p.x,p.y+hh],[p.x-hw,p.y]],topC);
|
||
ctx.strokeStyle=kind==="water"?"rgba(255,255,255,.14)":"rgba(50,70,30,.16)";ctx.lineWidth=1;ctx.stroke();
|
||
if(kind==="water"){ctx.strokeStyle="rgba(255,255,255,.3)";ctx.lineWidth=1;
|
||
ctx.beginPath();ctx.moveTo(p.x-hw*0.4,p.y);ctx.quadraticCurveTo(p.x,p.y-3,p.x+hw*0.4,p.y);ctx.stroke();return;}
|
||
// doodads (grass tufts / flowers) — deterministic
|
||
const r=hash2(gx,gy);
|
||
if(!winter){
|
||
if(r<0.55){ctx.strokeStyle=dark(topC,0.28);ctx.lineWidth=1.3;ctx.lineCap="round";
|
||
const ox=p.x+(r-0.3)*20,oy=p.y+(hash2(gy,gx)-0.5)*10;
|
||
for(const dx of [-3,0,3]){ctx.beginPath();ctx.moveTo(ox+dx,oy+2);ctx.lineTo(ox+dx*0.6,oy-3);ctx.stroke();}}
|
||
else if(r>0.9){const ox=p.x+(r-0.5)*16,oy=p.y+(hash2(gy,gx)-0.5)*8;
|
||
ctx.strokeStyle=dark(topC,0.2);ctx.lineWidth=1;ctx.beginPath();ctx.moveTo(ox,oy+2);ctx.lineTo(ox,oy-2);ctx.stroke();
|
||
ctx.fillStyle=r>0.95?"#d98ab4":"#e6d34a";circ(ctx,ox,oy-3,1.6);ctx.fill();}
|
||
} else if(r<0.3){ctx.fillStyle="rgba(255,255,255,.6)";circ(ctx,p.x+(r-0.15)*20,p.y+2,1.4);ctx.fill();}
|
||
}
|
||
function islandCorners(pad){const hw=TILE_W/2,hh=TILE_H/2;
|
||
const A=worldToScreen(0,0),B=worldToScreen(GRID-1,0),C=worldToScreen(GRID-1,GRID-1),D=worldToScreen(0,GRID-1);
|
||
const pts=[[A.x,A.y-hh],[B.x+hw,B.y],[C.x,C.y+hh+TILE_TH],[D.x-hw,D.y]];
|
||
const cx=(pts[0][0]+pts[2][0])/2,cy=(pts[1][1]+pts[3][1])/2;
|
||
return pts.map(p=>{const dx=p[0]-cx,dy=p[1]-cy,d=Math.hypot(dx,dy)||1;return [p[0]+dx/d*pad,p[1]+dy/d*pad];});}
|
||
function render(){
|
||
const isle=ISLANDS[S.island],winter=isWinter();
|
||
ctx.clearRect(0,0,VW,VH);
|
||
// ocean (inaccessible boundary)
|
||
const oc1=winter?"#b0c2cf":"#84a3bb",oc2=winter?"#98afbe":"#6990af";
|
||
const og=ctx.createLinearGradient(0,0,0,VH);og.addColorStop(0,oc1);og.addColorStop(1,oc2);
|
||
ctx.fillStyle=og;ctx.fillRect(0,0,VW,VH);
|
||
ctx.save();ctx.translate(cam.x,cam.y);ctx.scale(cam.z,cam.z); // pan + zoom for the world
|
||
// (organic coastline now carved into the tile map — the ocean gradient + shallows show the shape; no square beach frame)
|
||
for(let gy=0;gy<GRID;gy++)for(let gx=0;gx<GRID;gx++){const p=worldToScreen(gx,gy);
|
||
const sx=p.x*cam.z+cam.x,sy=p.y*cam.z+cam.y,mg=TILE_W*cam.z;
|
||
if(sx<-mg||sx>VW+mg||sy<-mg||sy>VH+mg)continue;drawTileBlock(gx,gy);}
|
||
if(ui.placing){const g=screenToGrid(ui.hoverX||VW/2,ui.hoverY||VH/2),gx=Math.round(g.gx),gy=Math.round(g.gy),hw=TILE_W/2,hh=TILE_H/2;
|
||
let ptiles;if(BUILD[ui.placing].dock){const dp=dockPlace(gx,gy);ptiles=dp?[[dp.gx,dp.gy],[dp.wx,dp.wy]]:[[gx,gy]];}else ptiles=bldTiles(ui.placing,gx,gy);
|
||
for(const [tx,ty] of ptiles){if(tx<0||ty<0||tx>=GRID||ty>=GRID)continue;const p=worldToScreen(tx,ty);
|
||
poly(ctx,[[p.x,p.y-hh],[p.x+hw,p.y],[p.x,p.y+hh],[p.x-hw,p.y]],"rgba(255,230,150,0.55)");ctx.strokeStyle="#fff";ctx.lineWidth=2;ctx.stroke();}}
|
||
const draw=[];
|
||
for(const n of S.nodes)draw.push({d:n.gx+n.gy,t:"node",o:n});
|
||
for(const b of S.buildings)draw.push({d:b.gx+b.gy+0.15,t:"bld",o:b});
|
||
for(const gr of (S.graves||[]))draw.push({d:gr.gx+gr.gy+0.1,t:"grave",o:gr});
|
||
for(const m of S.mellows)draw.push({d:m.gx+m.gy+0.25,t:"mel",o:m});
|
||
for(const m of (S.newcomers||[]))draw.push({d:m.gx+m.gy+0.25,t:"newc",o:m});
|
||
for(const w of S.wolves)draw.push({d:w.gx+w.gy+0.25,t:"wolf",o:w});
|
||
draw.sort((a,b)=>a.d-b.d);
|
||
for(const it of draw){if(it.t==="node")renderNode(it.o,winter);
|
||
else if(it.t==="bld")renderBuilding(it.o);else if(it.t==="mel")renderMellow(it.o);
|
||
else if(it.t==="newc")renderNewcomer(it.o);
|
||
else if(it.t==="grave"){const p=worldToScreen(it.o.gx,it.o.gy);artGrave(ctx,p.x,p.y,it.o.seed);}
|
||
else if(it.t==="wolf"){const w=it.o,p=worldToScreen(w.gx,w.gy); // pups at the old adult size; adults 1.5× bigger
|
||
artWolf(ctx,p.x,p.y,wolfBaby(w)?0.72:1.08,w.face||(w.tx>w.gx?1:-1),w.state&&w.state!=="wild");}}
|
||
// hearts over homes that are expecting a baby
|
||
if(ui.anim)for(const b of S.buildings)if(b.built&&BUILD[b.type].house&&S.mellows.some(m=>m.home===b.id&&m.pregnant)){
|
||
const p=worldToScreen(b.gx,b.gy);artHeart(ctx,p.x+1,p.y-46+Math.sin(S.tsec*2+b.id)*2,3.4,"rgba(224,110,120,.92)");}
|
||
ctx.restore(); // end world transform
|
||
const st=seasonTint();if(st){ctx.fillStyle=st;ctx.fillRect(0,0,VW,VH);}
|
||
const tt=timeTint();if(tt){ctx.fillStyle=tt;ctx.fillRect(0,0,VW,VH);}
|
||
if(winter&&ui.anim)drawSnow();
|
||
else if(S.weather==="rain"&&ui.anim)drawRain();
|
||
drawAmbient();
|
||
if(S.festT>0&&ui.anim)drawFestival(S.festKind||"confetti");
|
||
}
|
||
function renderNode(n,winter){if(n.dormant||n.amount<=0)return; // a hunted-out / collected node just disappears (respawns fresh elsewhere)
|
||
ctx.globalAlpha=1; // resources & animals are ALWAYS full opacity — never faded, no matter how many times they're gathered/hunted
|
||
const p=worldToScreen(n.gx,n.gy);const s=0.85+(n.seed||0)*0.35;
|
||
switch(NODE[n.type].kind){
|
||
case "tree":artTree(ctx,p.x,p.y,s,winter,n.seed);break;case "rock":artRock(ctx,p.x,p.y,s);break;
|
||
case "ore":artOre(ctx,p.x,p.y,s);break;case "bush":artBush(ctx,p.x,p.y,s,"#c1443e",winter);break;
|
||
case "herb":artHerb(ctx,p.x,p.y,s,winter);break;case "deer":artDeer(ctx,p.x,p.y,s*1.2,n.seed>0.5?1:-1);break;
|
||
case "sheep":artSheep(ctx,p.x,p.y,s*1.2,n.seed>0.5?1:-1);break;case "fish":artFish(ctx,p.x,p.y,1.15);break;}}
|
||
function renderBuilding(b){const info0=BUILD[b.type];
|
||
if(info0.dock){const d=DOCK_DIRS[b.dir||0],lp=worldToScreen(b.gx,b.gy),wp=worldToScreen(b.gx+d[0],b.gy+d[1]);
|
||
if(!b.built)artBuilding(ctx,(lp.x+wp.x)/2,(lp.y+wp.y)/2,b);else artDock2(ctx,lp,wp);
|
||
if(ui.selBld===b){const hw=TILE_W/2,hh=TILE_H/2;ctx.strokeStyle="#ffd24a";ctx.lineWidth=2.5;
|
||
for(const [tx,ty] of bldTiles(b.type,b.gx,b.gy,b.dir)){const q=worldToScreen(tx,ty);poly(ctx,[[q.x,q.y-hh],[q.x+hw,q.y],[q.x,q.y+hh],[q.x-hw,q.y]]);ctx.stroke();}}
|
||
return;}
|
||
const sz=BUILD[b.type].size||1;const p=worldToScreen(b.gx+(sz-1)/2,b.gy+(sz-1)/2);artBuilding(ctx,p.x,p.y,b);
|
||
if(b.built&&BUILD[b.type].job){const n=S.mellows.filter(m=>m.job===BUILD[b.type].job&&!isChild(m)).length;
|
||
if(n>0){fillC(ctx,p.x+15,p.y-34,8,"#f4ecd4",2);ctx.fillStyle="#4a3826";ctx.font="bold 10px sans-serif";
|
||
ctx.textAlign="center";ctx.textBaseline="middle";ctx.fillText(n,p.x+15,p.y-33);}}
|
||
if(ui.selBld===b){const hw=TILE_W/2,hh=TILE_H/2;ctx.strokeStyle="#ffd24a";ctx.lineWidth=2.5;
|
||
for(const [tx,ty] of bldTiles(b.type,b.gx,b.gy)){const q=worldToScreen(tx,ty);
|
||
poly(ctx,[[q.x,q.y-hh],[q.x+hw,q.y],[q.x,q.y+hh],[q.x-hw,q.y]]);ctx.stroke();}}}
|
||
function renderMellow(m){const p=worldToScreen(m.gx,m.gy),j=JOBS[m.job];
|
||
const bobv=ui.anim?Math.sin((S.tsec+m.bob)*6)*1.3:0;
|
||
const childScale=isChild(m)?(0.52+Math.min(1,m.age/12)*0.48):1;
|
||
artMellow(ctx,p.x,p.y-6,j?j.cloak:"#9fb0a0",isChild(m)?"none":(j?j.tool:"none"),
|
||
{s:childScale,bob:bobv,face:m.face,sick:m.sick,carry:m.carry>0?m.carryRes:null});
|
||
// need bubble
|
||
if(!isChild(m)){let need=null;
|
||
if(m.sick)need=g2=>artBldIcon(g2,"cross",g2._bx,g2._by,g2._br);
|
||
else if(m.hunger<32&&totalFood()>0)need=g2=>artResIcon(g2,"meat",g2._bx,g2._by,g2._br);
|
||
else if(m.hunger<32)need=g2=>artResIcon(g2,"crops",g2._bx,g2._by,g2._br);
|
||
else if(m.job==="idle")need=g2=>{g2.fillStyle=INK;g2.font="bold 11px sans-serif";g2.textAlign="center";g2.textBaseline="middle";g2.fillText("?",g2._bx,g2._by);};
|
||
else if(m.happy>=82&&(Math.floor(S.tsec/6)+(m.id||0))%7===0)need=g2=>artHeart(g2,g2._bx,g2._by,g2._br*0.72,"#e06e78"); // a content Mellow, now and then, shows the love
|
||
if(need)artBubble(ctx,p.x,p.y-33-Math.abs(bobv),(g2,bx,by,br)=>{g2._bx=bx;g2._by=by;g2._br=br;need(g2);});}
|
||
if(ui.selMel===m){ctx.strokeStyle="#ffd24a";ctx.lineWidth=2.5;circ(ctx,p.x,p.y-16,15);ctx.stroke();}}
|
||
// a pending newcomer — drawn all in white with a soft glow, awaiting the player's decision
|
||
function renderNewcomer(m){const p=worldToScreen(m.gx,m.gy),bobv=ui.anim?Math.sin((S.tsec+(m.bob||0))*4)*1.1:0;
|
||
ctx.save();ctx.globalAlpha=0.45+(ui.anim?Math.sin(S.tsec*2+(m.bob||0))*0.12:0);
|
||
const glow=ctx.createRadialGradient(p.x,p.y-14,2,p.x,p.y-14,16);glow.addColorStop(0,"rgba(255,255,255,.85)");glow.addColorStop(1,"rgba(255,255,255,0)");
|
||
ctx.fillStyle=glow;ctx.beginPath();ctx.ellipse(p.x,p.y-14,13,17,0,0,7);ctx.fill();ctx.restore();
|
||
artMellow(ctx,p.x,p.y-6,"#f4f4f4","none",{s:1,bob:bobv,face:m.face||1,white:true});}
|
||
|
||
function artBuilding(g,px,py,b){const info=BUILD[b.type];
|
||
if(!b.built){g.strokeStyle="#8a6b45";g.lineWidth=2;const w=info.small?12:18;
|
||
for(const dx of [-w,w])for(const dy of [-w/2,w/2]){g.beginPath();g.moveTo(px+dx,py-2);g.lineTo(px+dx,py-16);g.stroke();}
|
||
g.fillStyle="rgba(0,0,0,.25)";g.fillRect(px-18,py-30,36,6);g.fillStyle="#7bbf55";g.fillRect(px-18,py-30,36*b.prog,6);
|
||
g.fillStyle="#f4ecd4";g.font="bold 9px sans-serif";g.textAlign="center";g.textBaseline="middle";g.fillText(Math.round(b.prog*100)+"%",px,py-40);return;}
|
||
if(BLDART[b.type]){BLDART[b.type](g,px,py,isWinter());return;}
|
||
if(info.tent){artTent(g,px,py,info.roof);return;}
|
||
if(info.cemetery){artCemetery(g,px,py);return;}
|
||
if(info.store){artStorehouse(g,px,py,info);return;}
|
||
if(info.farm){const growing=(b.stage||"empty")==="growing";
|
||
artFarm(g,px,py,isWinter()||!growing,info.size||1,growing?(b.growing||b.crop||"crops"):null);return;}
|
||
if(info.pasture){artPasture(g,px,py,info.size||1,b.animal||"sheep",b.herd||2);return;}
|
||
if(info.dock){artDock(g,px,py);return;}
|
||
if(info.bridge){artBridge(g,px,py);return;}
|
||
if(info.boat){artBoat(g,px,py);return;}
|
||
if(info.small){shadow(g,px,py,13);g.strokeStyle="#7a5230";g.lineWidth=3.4;g.lineCap="round";
|
||
g.beginPath();g.moveTo(px-8,py-1);g.lineTo(px+8,py-3);g.moveTo(px-8,py-3);g.lineTo(px+8,py-1);g.stroke();
|
||
g.strokeStyle=INK;g.lineWidth=1.2;g.stroke();
|
||
const fl=g.createLinearGradient(px,py-4,px,py-20);fl.addColorStop(0,"#f2c14a");fl.addColorStop(1,"#e0562f");
|
||
poly(g,[[px-5,py-3],[px,py-19],[px+5,py-3],[px+2,py-7],[px-2,py-7]]);g.fillStyle=fl;g.fill();
|
||
g.fillStyle="#ffe08a";poly(g,[[px-2,py-4],[px+1,py-12],[px+3,py-4]]);g.fill();return;}
|
||
// cabin
|
||
const bw=20,bh=10,wallH=17,roofH=15,winter=isWinter();
|
||
const baseY=py;
|
||
const S_=[px,baseY+bh],E=[px+bw,baseY],W=[px-bw,baseY],N=[px,baseY-bh];
|
||
const St=[px,baseY+bh-wallH],Et=[px+bw,baseY-wallH],Wt=[px-bw,baseY-wallH],Nt=[px,baseY-bh-wallH];
|
||
shadow(g,px,baseY+bh*0.5,bw*1.05);
|
||
const wall=info.stone?"#cfd2d6":"#e6cf9c";
|
||
fillP(g,[W,S_,St,Wt],wall,1.8); // SW face
|
||
fillP(g,[S_,E,Et,St],dark(wall,0.1),1.8); // SE face
|
||
// wall texture: stone-block masonry (Stone House) vs stacked-log courses (Wood House)
|
||
g.strokeStyle=blend(wall,INK,info.stone?0.34:0.3);g.lineWidth=1.1;g.lineCap="butt";
|
||
for(const t of [0.25,0.5,0.75]){
|
||
g.beginPath();g.moveTo(W[0]+(Wt[0]-W[0])*t,W[1]+(Wt[1]-W[1])*t);g.lineTo(S_[0]+(St[0]-S_[0])*t,S_[1]+(St[1]-S_[1])*t);g.stroke();
|
||
g.beginPath();g.moveTo(S_[0]+(St[0]-S_[0])*t,S_[1]+(St[1]-S_[1])*t);g.lineTo(E[0]+(Et[0]-E[0])*t,E[1]+(Et[1]-E[1])*t);g.stroke();}
|
||
if(info.stone){ // staggered vertical joints → reads as cut-stone blocks
|
||
g.lineWidth=0.9;const joints=(A,B,C)=>{for(let r=0;r<3;r++){const v0=r/3,v1=(r+1)/3,sh=r%2?0.17:0;
|
||
for(const u of [0.28,0.6]){const uu=u+sh;
|
||
g.beginPath();g.moveTo(A[0]+(B[0]-A[0])*uu+(C[0]-A[0])*v0,A[1]+(B[1]-A[1])*uu+(C[1]-A[1])*v0);
|
||
g.lineTo(A[0]+(B[0]-A[0])*uu+(C[0]-A[0])*v1,A[1]+(B[1]-A[1])*uu+(C[1]-A[1])*v1);g.stroke();}}};
|
||
joints(W,S_,Wt);joints(S_,E,St);}
|
||
// front corner post (notched log end)
|
||
g.strokeStyle=dark(wall,0.3);g.lineWidth=2.6;g.lineCap="round";g.beginPath();g.moveTo(S_[0],S_[1]-1);g.lineTo(St[0],St[1]);g.stroke();
|
||
// warm-lit window on the SW face
|
||
{const wc=[W[0]+(S_[0]-W[0])*0.4+(Wt[0]-W[0])*0.56, W[1]+(S_[1]-W[1])*0.4+(Wt[1]-W[1])*0.56];
|
||
const wu=[(S_[0]-W[0])*0.15,(S_[1]-W[1])*0.15],wv=[(Wt[0]-W[0])*0.2,(Wt[1]-W[1])*0.2];
|
||
fillP(g,[[wc[0]-wu[0]-wv[0],wc[1]-wu[1]-wv[1]],[wc[0]+wu[0]-wv[0],wc[1]+wu[1]-wv[1]],[wc[0]+wu[0]+wv[0],wc[1]+wu[1]+wv[1]],[wc[0]-wu[0]+wv[0],wc[1]-wu[1]+wv[1]]],"#f6cb5c",1.2);
|
||
g.strokeStyle="rgba(120,80,40,.5)";g.lineWidth=0.9;g.beginPath();g.moveTo(wc[0]-wv[0],wc[1]-wv[1]);g.lineTo(wc[0]+wv[0],wc[1]+wv[1]);g.stroke();}
|
||
// door on SE face
|
||
const dcx=(S_[0]+E[0])/2, dcy=(S_[1]+E[1])/2;
|
||
fillP(g,[[dcx-3,dcy-1],[dcx+3,dcy-4],[dcx+3,dcy-15],[dcx-3,dcy-12]],dark(info.roof,0.28),1.3);
|
||
g.fillStyle="#e9c56a";circ(g,dcx+1.6,dcy-8,1.0);g.fill(); // door knob
|
||
// roof (hip)
|
||
const apex=[px,baseY-bh-wallH-roofH];
|
||
fillP(g,[Wt,St,apex],info.roof,1.8);
|
||
fillP(g,[St,Et,apex],dark(info.roof,0.12),1.8);
|
||
fillP(g,[Wt,Nt,apex],lite(info.roof,0.1),1.8);
|
||
fillP(g,[Nt,Et,apex],info.roof,1.8);
|
||
// shingle lines
|
||
g.strokeStyle=dark(info.roof,0.25);g.lineWidth=1;
|
||
for(const t of [0.4,0.7]){g.beginPath();g.moveTo(Wt[0]+(apex[0]-Wt[0])*t,Wt[1]+(apex[1]-Wt[1])*t);
|
||
g.lineTo(St[0]+(apex[0]-St[0])*t,St[1]+(apex[1]-St[1])*t);g.stroke();}
|
||
if(winter){g.fillStyle="rgba(255,255,255,.85)";poly(g,[Wt,Nt,apex]);g.fill();poly(g,[Nt,Et,apex]);g.fill();
|
||
g.fillStyle="rgba(255,255,255,.6)";poly(g,[Wt,St,[apex[0],apex[1]+4]]);g.fill();}
|
||
// stone chimney + smoke puffs (cozy)
|
||
const chx=px+8,chy=apex[1]+8,chC=info.stone?"#9aa0a6":"#8a6b52";
|
||
fillP(g,[[chx-2.6,chy],[chx+2.6,chy],[chx+2.6,chy-10],[chx-2.6,chy-10]],chC,1.4);
|
||
fillP(g,[[chx-3.3,chy-10],[chx+3.3,chy-10],[chx+3.3,chy-12.6],[chx-3.3,chy-12.6]],dark(chC,0.22),1.2);
|
||
g.fillStyle="rgba(246,246,250,.72)";circ(g,chx,chy-16,2.1);g.fill();circ(g,chx+1.7,chy-20,2.7);g.fill();circ(g,chx-0.6,chy-25,3.2);g.fill();}
|
||
function artTent(g,px,py,color){shadow(g,px,py+2,22);
|
||
fillP(g,[[px-18,py],[px,py-30],[px+18,py]],dark(color,0.1),1.8);
|
||
g.fillStyle=dark(color,0.28);poly(g,[[px-6,py],[px,py-14],[px+6,py]]);g.fill();
|
||
g.strokeStyle=INK;g.lineWidth=1.6;g.beginPath();g.moveTo(px,py-30);g.lineTo(px,py-34);g.stroke();
|
||
fillP(g,[[px,py-34],[px+7,py-32],[px,py-30]],"#d5715a",1.2);}
|
||
function isoPlot(g,px,py,hw,hh,fill){fillP(g,[[px,py-hh],[px+hw,py],[px,py+hh],[px-hw,py]],fill,1.6);}
|
||
// a distinct little sprite per crop, drawn sitting on the soil at (x,y); f scales it
|
||
function cropSprite(g,x,y,crop,f){f=f||1;g.lineCap="round";g.lineJoin="round";
|
||
if(crop==="corn"){ // tall stalks + yellow cob
|
||
g.strokeStyle="#4e8b3a";g.lineWidth=1.5*f;g.beginPath();g.moveTo(x,y);g.lineTo(x,y-9*f);g.stroke();
|
||
g.strokeStyle="#5aa03e";g.lineWidth=1.2*f;g.beginPath();g.moveTo(x,y-4.5*f);g.lineTo(x-3*f,y-8*f);g.moveTo(x,y-6*f);g.lineTo(x+3*f,y-9.5*f);g.stroke();
|
||
g.fillStyle="#f2c94c";g.beginPath();g.ellipse(x+1.1*f,y-6.3*f,1.2*f,2.5*f,-0.3,0,7);g.fill();inkStroke(g,0.7*f);
|
||
}else if(crop==="pumpkin"){ // orange gourd on the soil
|
||
g.fillStyle="#e08a3a";g.beginPath();g.ellipse(x,y-2.1*f,3.4*f,2.6*f,0,0,7);g.fill();inkStroke(g,1*f);
|
||
g.strokeStyle="#c06a26";g.lineWidth=0.7*f;g.beginPath();g.moveTo(x,y-4.5*f);g.lineTo(x,y+0.3*f);g.moveTo(x-1.9*f,y-4*f);g.lineTo(x-1.9*f,y);g.moveTo(x+1.9*f,y-4*f);g.lineTo(x+1.9*f,y);g.stroke();
|
||
g.strokeStyle="#4e8b3a";g.lineWidth=1.2*f;g.beginPath();g.moveTo(x,y-4.4*f);g.lineTo(x+1.5*f,y-6.4*f);g.stroke();
|
||
}else if(crop==="carrot"){ // feathery tops + orange root peeking
|
||
g.fillStyle="#e07a3a";fillP(g,[[x-1.6*f,y-1*f],[x+1.6*f,y-1*f],[x,y+2.4*f]],"#e07a3a",0.8*f);
|
||
g.strokeStyle="#4e8b3a";g.lineWidth=1.1*f;for(const a of [-0.7,-0.24,0.24,0.7]){g.beginPath();g.moveTo(x,y-0.8*f);g.lineTo(x+Math.sin(a)*5*f,y-0.8*f-Math.cos(a)*7*f);g.stroke();}
|
||
}else if(crop==="cabbage"){ // round blue-green leaf head
|
||
g.fillStyle="#7fae54";circ(g,x,y-2.4*f,3.2*f);g.fill();inkStroke(g,1*f);
|
||
g.fillStyle="#9ec773";circ(g,x-0.3*f,y-2.7*f,1.7*f);g.fill();
|
||
g.strokeStyle="#5f8a3a";g.lineWidth=0.6*f;g.beginPath();g.arc(x,y-2.4*f,2.6*f,-2.5,-0.6);g.stroke();
|
||
}else if(crop==="fruit"){ // berry bush with red dots
|
||
g.fillStyle="#4e8b3a";circ(g,x,y-2.6*f,3.2*f);g.fill();inkStroke(g,1*f);
|
||
g.fillStyle="#c1443e";for(const [dx,dy] of [[-1.4,-3.2],[1.3,-3.5],[0,-1.7],[-1.8,-1.3],[1.7,-1.5]]){circ(g,x+dx*f,y+dy*f,0.9*f);g.fill();}
|
||
}else{ // crops = golden wheat
|
||
for(const dx of [-1.8,0,1.8]){g.strokeStyle="#d9a63a";g.lineWidth=1.2*f;g.beginPath();g.moveTo(x+dx*f,y);g.quadraticCurveTo(x+dx*f+f,y-6*f,x+dx*f+2*f,y-9*f);g.stroke();
|
||
g.fillStyle="#e8c65a";g.beginPath();g.ellipse(x+dx*f+2*f,y-9.6*f,0.9*f,1.9*f,0.35,0,7);g.fill();}
|
||
}}
|
||
// a cute little picket fence around the plot — `front` picks the near or far pair of edges (for depth)
|
||
function farmFence(g,px,py,hw,hh,f,front){
|
||
const N=[px,py-hh],E=[px+hw,py],S=[px,py+hh],W=[px-hw,py];
|
||
const edges=front?[[W,S],[S,E]]:[[W,N],[N,E]];
|
||
const ph=7*f;
|
||
for(const [A,B] of edges){
|
||
const tops=[],bases=[];
|
||
for(let i=0;i<=3;i++){const t=i/3,bx=A[0]+(B[0]-A[0])*t,by=A[1]+(B[1]-A[1])*t;bases.push([bx,by]);tops.push([bx,by-ph]);}
|
||
const rail=()=>{g.beginPath();g.moveTo(tops[0][0],tops[0][1]);for(let i=1;i<tops.length;i++)g.lineTo(tops[i][0],tops[i][1]);};
|
||
g.lineCap="round";
|
||
rail();g.strokeStyle=INK;g.lineWidth=2.3*f;g.stroke();
|
||
rail();g.strokeStyle="#b98a5a";g.lineWidth=1.2*f;g.stroke();
|
||
for(let i=0;i<bases.length;i++){
|
||
g.beginPath();g.moveTo(bases[i][0],bases[i][1]);g.lineTo(tops[i][0],tops[i][1]);g.strokeStyle=INK;g.lineWidth=2.7*f;g.stroke();
|
||
g.beginPath();g.moveTo(bases[i][0],bases[i][1]);g.lineTo(tops[i][0],tops[i][1]);g.strokeStyle="#c49a62";g.lineWidth=1.4*f;g.stroke();
|
||
g.fillStyle="#d0a86e";circ(g,tops[i][0],tops[i][1],1.2*f);g.fill();}}}
|
||
// a tiny 5-petal flower for cute little accents
|
||
function tinyFlower(g,x,y,col,f){f=f||1;g.fillStyle=col;
|
||
for(let k=0;k<5;k++){const a=k/5*6.2832-1.2;circ(g,x+Math.cos(a)*1.5*f,y+Math.sin(a)*1.5*f,1.05*f);g.fill();}
|
||
g.fillStyle="#ffd94a";circ(g,x,y,0.95*f);g.fill();}
|
||
function artFarm(g,px,py,winter,sz,crop){sz=sz||1;const hw=sz>1?58:24,hh=sz>1?29:12,rows=sz>1?7:4,f=sz>1?1.5:1;
|
||
shadow(g,px,py+hh*0.4,hw*0.95);
|
||
isoPlot(g,px,py,hw,hh,"#b07f4c"); // tilled soil (warmer)
|
||
isoPlot(g,px,py,hw-3,hh-1.5,"#c1925a"); // lighter inner
|
||
farmFence(g,px,py,hw,hh,f,false); // far fence (behind the crops)
|
||
// furrow rows (parallel to the W→N edge); planted rows carry per-crop sprites
|
||
const spriteF=sz>1?1.05:0.8, n=sz>1?3:2;
|
||
for(let i=1;i<=rows;i++){const k=i/(rows+1);
|
||
const ax=px-hw+hw*k, ay=py+hh*k, bx=px+hw*k, by=py-hh+hh*k;
|
||
g.strokeStyle="#8a5f38";g.lineWidth=1.4;g.lineCap="round";g.beginPath();g.moveTo(ax,ay);g.lineTo(bx,by);g.stroke();
|
||
if(!winter&&crop){for(let j=1;j<=n;j++){const t=j/(n+1),sx=ax+(bx-ax)*t,sy=ay+(by-ay)*t;
|
||
cropSprite(g,sx,sy,crop,spriteF);}}}
|
||
if(winter){g.fillStyle="rgba(255,255,255,.6)";g.beginPath();g.moveTo(px,py-hh);g.lineTo(px+hw,py);g.lineTo(px,py+hh);g.lineTo(px-hw,py);g.closePath();g.fill();}
|
||
// a cheerful little scarecrow at the back corner
|
||
const sx=px-hw*0.42,sy=py-hh*0.42;g.lineCap="round";
|
||
g.strokeStyle=INK;g.lineWidth=2.7*f;g.beginPath();g.moveTo(sx,sy);g.lineTo(sx,sy-13*f);g.moveTo(sx-5.5*f,sy-9.5*f);g.lineTo(sx+5.5*f,sy-9.5*f);g.stroke();
|
||
g.strokeStyle="#a97e4c";g.lineWidth=1.4*f;g.beginPath();g.moveTo(sx,sy);g.lineTo(sx,sy-13*f);g.moveTo(sx-5.5*f,sy-9.5*f);g.lineTo(sx+5.5*f,sy-9.5*f);g.stroke();
|
||
g.fillStyle="#d8b877";g.beginPath();g.ellipse(sx,sy-8*f,3.3*f,4.1*f,0,0,7);g.fill();inkStroke(g,1.1); // plump burlap body
|
||
g.strokeStyle="#b08a4a";g.lineWidth=0.7*f;g.beginPath();g.moveTo(sx-2.3*f,sy-6*f);g.lineTo(sx+2.3*f,sy-6*f);g.stroke();
|
||
g.fillStyle="#f0d9a2";circ(g,sx,sy-15*f,3*f);g.fill();inkStroke(g,1.1); // round head
|
||
g.fillStyle=INK;circ(g,sx-1*f,sy-15.4*f,0.5*f);g.fill();circ(g,sx+1*f,sy-15.4*f,0.5*f);g.fill(); // eyes
|
||
g.strokeStyle=INK;g.lineWidth=0.7*f;g.beginPath();g.arc(sx,sy-14.7*f,1.3*f,0.2,Math.PI-0.2);g.stroke(); // smile
|
||
g.fillStyle="rgba(228,138,150,.5)";circ(g,sx-1.9*f,sy-14.2*f,0.75*f);g.fill();circ(g,sx+1.9*f,sy-14.2*f,0.75*f);g.fill(); // rosy cheeks
|
||
fillP(g,[[sx-4.2*f,sy-17*f],[sx+4.2*f,sy-17*f],[sx,sy-22*f]],"#c79a52",1.1); // straw hat cone
|
||
fillP(g,[[sx-4.7*f,sy-16.7*f],[sx+4.7*f,sy-16.7*f],[sx+3*f,sy-18*f],[sx-3*f,sy-18*f]],"#b8863f",1); // hat brim
|
||
const bxx=sx+5.5*f,byy=sy-9.5*f; // a little bird perched on the arm
|
||
g.fillStyle="#7fa8c9";g.beginPath();g.ellipse(bxx,byy-1.7*f,1.9*f,1.5*f,0,0,7);g.fill();inkStroke(g,0.8);
|
||
fillP(g,[[bxx+1.6*f,byy-2*f],[bxx+3*f,byy-1.5*f],[bxx+1.6*f,byy-1*f]],"#e8a24a",0.5); // beak
|
||
g.fillStyle=INK;circ(g,bxx+0.5*f,byy-2.1*f,0.42*f);g.fill(); // eye
|
||
const fcols=["#f2a0c0","#fdfdfd","#c9a0e0","#ffd0e0","#f6b84a"]; // cheerful little flowers dotted around the bed
|
||
[[-0.72,0.08],[0.72,0.08],[0.0,0.72],[-0.36,0.5],[0.36,0.5]].forEach((s,i)=>tinyFlower(g,px+s[0]*hw,py+s[1]*hh,fcols[i%fcols.length],f*0.9));
|
||
farmFence(g,px,py,hw,hh,f,true);} // near fence (in front)
|
||
function artPasture(g,px,py,sz,animal,herd){sz=sz||1;animal=animal||"sheep";herd=herd||2;const hw=sz>1?58:24,hh=sz>1?29:12;
|
||
shadow(g,px,py+hh*0.4,hw*0.95);
|
||
isoPlot(g,px,py,hw,hh,"#7faa54");isoPlot(g,px,py,hw-3,hh-1.5,"#8cb85f");
|
||
g.strokeStyle="#5f8a3a";g.lineWidth=1.2;g.lineCap="round";
|
||
const tufts=sz>1?[[-26,4],[16,-6],[0,10],[28,6],[-12,-8],[10,13],[-32,0],[24,-11],[-6,16]]:[[-10,2],[7,-3],[0,5],[12,3],[-4,-4],[4,6]];
|
||
for(const [dx,dy] of tufts){for(const o of [-2,0,2]){g.beginPath();g.moveTo(px+dx+o,py+dy);g.lineTo(px+dx+o*0.6,py+dy-3);g.stroke();}}
|
||
// low rail fence around the plot
|
||
const cor=[[px,py-hh],[px+hw,py],[px,py+hh],[px-hw,py]];g.strokeStyle="#a9784a";g.lineWidth=1.5;g.lineCap="round";const posts=sz>1?6:3;
|
||
for(let e=0;e<4;e++){const a=cor[e],b=cor[(e+1)%4];for(let i=0;i<=posts;i++){const t=i/posts,fx=a[0]+(b[0]-a[0])*t,fy=a[1]+(b[1]-a[1])*t;g.beginPath();g.moveTo(fx,fy);g.lineTo(fx,fy-5);g.stroke();}
|
||
g.beginPath();g.moveTo(a[0],a[1]-3.6);g.lineTo(b[0],b[1]-3.6);g.stroke();}
|
||
// scatter one animal per head of herd (capped), back-to-front so they overlap nicely
|
||
const drawA=animal==="deer"?artDeer:artSheep;
|
||
const spots=[[-0.30,-0.14],[0.28,-0.10],[0.05,-0.02],[-0.34,0.12],[0.30,0.16],[-0.06,0.28]];
|
||
const nA=Math.max(1,Math.min(sz>1?6:2,Math.round(herd))),asc=sz>1?0.72:0.6;
|
||
spots.slice(0,nA).sort((a,b)=>a[1]-b[1]).forEach(([fx,fy],i)=>drawA(g,px+fx*hw,py+fy*hh,asc,i%2?-1:1));}
|
||
// 2-tile dock: a plank walkway from the land anchor (lp) out to the pier platform on water (wp)
|
||
function artDock2(g,lp,wp){
|
||
g.strokeStyle="#8a6b45";g.lineWidth=6;g.lineCap="round";g.beginPath();g.moveTo(lp.x,lp.y);g.lineTo(wp.x,wp.y);g.stroke();
|
||
g.strokeStyle="#b08a58";g.lineWidth=3.4;g.beginPath();g.moveTo(lp.x,lp.y);g.lineTo(wp.x,wp.y);g.stroke();
|
||
// cross-planks along the walkway
|
||
g.strokeStyle="#7a5230";g.lineWidth=1;const n=4;for(let i=1;i<n;i++){const t=i/n,cx=lp.x+(wp.x-lp.x)*t,cy=lp.y+(wp.y-lp.y)*t,px=-(wp.y-lp.y),py=(wp.x-lp.x),pl=Math.hypot(px,py)||1;
|
||
g.beginPath();g.moveTo(cx-px/pl*4,cy-py/pl*4);g.lineTo(cx+px/pl*4,cy+py/pl*4);g.stroke();}
|
||
artDock(g,wp.x,wp.y);}
|
||
function artDock(g,px,py){const hw=18,hh=9;
|
||
isoPlot(g,px,py,hw,hh,"#b98a4a");g.strokeStyle="#8a5f38";g.lineWidth=1;
|
||
for(let i=1;i<4;i++){const k=i/4;g.beginPath();g.moveTo(px-hw+hw*k,py+hh*k);g.lineTo(px+hw*k,py-hh+hh*k);g.stroke();}
|
||
g.strokeStyle="#7a5230";g.lineWidth=2.4;g.lineCap="round";
|
||
for(const c of [[px-hw,py],[px+hw,py],[px,py+hh]]){g.beginPath();g.moveTo(c[0],c[1]);g.lineTo(c[0],c[1]+5);g.stroke();}
|
||
artBarrel(g,px+7,py-2,3);}
|
||
function artBridge(g,px,py){const hw=20,hh=10;
|
||
isoPlot(g,px,py,hw,hh,"#a9784a");g.strokeStyle="#7a5230";g.lineWidth=1.2;
|
||
for(let i=1;i<5;i++){const k=i/5;g.beginPath();g.moveTo(px-hw+hw*k,py+hh*k);g.lineTo(px+hw*k,py-hh+hh*k);g.stroke();}
|
||
g.strokeStyle="#8a6b45";g.lineWidth=1.5;g.lineCap="round";g.beginPath();g.moveTo(px-hw,py-4);g.lineTo(px,py-hh-4);g.lineTo(px+hw,py-4);g.stroke();}
|
||
function artBoat(g,px,py){shadow(g,px,py+4,15);
|
||
fillP(g,[[px-14,py-2],[px+14,py-2],[px+8,py+7],[px-8,py+7]],"#a06a3e",1.7); // hull
|
||
fillP(g,[[px-14,py-2],[px+14,py-2],[px+9,py+1],[px-9,py+1]],"#c08a54",1.1); // rim
|
||
g.strokeStyle="#8a5f38";g.lineWidth=1;g.beginPath();g.moveTo(px-9,py+1);g.lineTo(px-5,py+7);g.moveTo(px,py+1);g.lineTo(px,py+7);g.moveTo(px+9,py+1);g.lineTo(px+5,py+7);g.stroke();
|
||
g.strokeStyle="#7a5230";g.lineWidth=2;g.lineCap="round";g.beginPath();g.moveTo(px+1,py-2);g.lineTo(px+1,py-17);g.stroke();
|
||
fillP(g,[[px+2,py-16],[px+11,py-5],[px+2,py-5]],"#f2ead6",1.3);} // sail
|
||
function artGrave(g,x,y,seed){shadow(g,x,y+1,6);
|
||
g.fillStyle="#84a35f";g.beginPath();g.ellipse(x,y+1,6.5,2.6,0,0,7);g.fill(); // grassy mound
|
||
g.beginPath();g.moveTo(x-3.6,y);g.lineTo(x-3.6,y-6);g.arc(x,y-6,3.6,Math.PI,0);g.lineTo(x+3.6,y);g.closePath();
|
||
g.fillStyle=(seed||0)>0.5?"#cccfd3":"#c4c1b8";g.fill();inkStroke(g,1.3);
|
||
g.strokeStyle="#9aa0a6";g.lineWidth=1.1;g.lineCap="round";
|
||
g.beginPath();g.moveTo(x,y-7.4);g.lineTo(x,y-2.6);g.moveTo(x-1.8,y-5.8);g.lineTo(x+1.8,y-5.8);g.stroke();}
|
||
function artHeart(g,x,y,r,color){g.save();g.fillStyle=color||"#e06e78";g.beginPath();
|
||
g.moveTo(x,y+r*0.9);g.bezierCurveTo(x-r*1.4,y-r*0.3,x-r*0.5,y-r*1.2,x,y-r*0.35);
|
||
g.bezierCurveTo(x+r*0.5,y-r*1.2,x+r*1.4,y-r*0.3,x,y+r*0.9);g.closePath();g.fill();g.restore();}
|
||
function artCemetery(g,px,py){shadow(g,px,py+3,20);
|
||
const hw=17,hh=8.5;
|
||
fillP(g,[[px,py-hh],[px+hw,py],[px,py+hh],[px-hw,py]],"#6f7d55",1.5); // earth plot
|
||
fillP(g,[[px,py-hh+2],[px+hw-3,py],[px,py+hh-2],[px-hw+3,py]],"#78875e",1); // inner
|
||
// low picket fence along the two back edges
|
||
g.strokeStyle="#8a6b45";g.lineWidth=1.5;g.lineCap="round";
|
||
for(let i=0;i<=5;i++){const t=i/5;const ax=px-hw+hw*t,ay=py-hh*(1-t);g.beginPath();g.moveTo(ax,ay);g.lineTo(ax,ay-6);g.stroke();
|
||
const bx=px+hw*t,by=py-hh+hh*t;g.beginPath();g.moveTo(bx,by);g.lineTo(bx,by-6);g.stroke();}
|
||
g.lineWidth=1.3;g.beginPath();g.moveTo(px-hw,py-6);g.lineTo(px,py-hh-6);g.lineTo(px+hw,py-6);g.stroke();
|
||
// a few headstones on the plot
|
||
artGrave(g,px-6,py-1,0.2);artGrave(g,px+6,py+1,0.8);artGrave(g,px+1,py-4,0.6);}
|
||
function artCrate(g,x,y,s){shadow(g,x,y+1,s*1.15);
|
||
fillP(g,[[x-s,y-s*1.2],[x+s,y-s*1.2],[x+s,y+s*0.4],[x-s,y+s*0.4]],"#c69a5c",1.4); // front
|
||
fillP(g,[[x-s,y-s*1.2],[x,y-s*1.75],[x+s,y-s*1.2],[x,y-s*0.65]],"#d9b072",1.3); // top
|
||
g.strokeStyle="#8a6238";g.lineWidth=1.1;g.lineCap="round";
|
||
g.beginPath();g.moveTo(x-s,y-s*1.2);g.lineTo(x+s,y+s*0.4);g.moveTo(x+s,y-s*1.2);g.lineTo(x-s,y+s*0.4);g.stroke();}
|
||
function artBarrel(g,x,y,s){shadow(g,x,y+1,s*1.1);
|
||
fillP(g,[[x-s*0.82,y-s*1.4],[x+s*0.82,y-s*1.4],[x+s*0.68,y+s*0.3],[x-s*0.68,y+s*0.3]],"#a9784a",1.4);
|
||
g.fillStyle="#c9a066";g.beginPath();g.ellipse(x,y-s*1.4,s*0.82,s*0.34,0,0,7);g.fill();inkStroke(g,1.2);
|
||
g.strokeStyle="#6f4a2c";g.lineWidth=1.1;g.beginPath();g.moveTo(x-s*0.8,y-s*0.62);g.lineTo(x+s*0.8,y-s*0.62);g.moveTo(x-s*0.75,y-s*0.12);g.lineTo(x+s*0.75,y-s*0.12);g.stroke();}
|
||
function artStorehouse(g,px,py,info){
|
||
const bw=21,bh=10.5,wallH=15,th=6,baseY=py;
|
||
const S_=[px,baseY+bh],E=[px+bw,baseY],W=[px-bw,baseY];
|
||
const St=[px,baseY+bh-wallH],Et=[px+bw,baseY-wallH],Wt=[px-bw,baseY-wallH],Nt=[px,baseY-bh-wallH];
|
||
shadow(g,px,baseY+bh*0.5,bw*1.08);
|
||
const wall="#e0c793";
|
||
fillP(g,[W,S_,St,Wt],wall,1.8); // SW face
|
||
fillP(g,[S_,E,Et,St],dark(wall,0.12),1.8); // SE face
|
||
// horizontal plank courses
|
||
g.strokeStyle=blend(wall,INK,0.3);g.lineWidth=1;g.lineCap="butt";
|
||
for(const t of [0.33,0.66]){
|
||
g.beginPath();g.moveTo(W[0]+(Wt[0]-W[0])*t,W[1]+(Wt[1]-W[1])*t);g.lineTo(S_[0]+(St[0]-S_[0])*t,S_[1]+(St[1]-S_[1])*t);g.stroke();
|
||
g.beginPath();g.moveTo(S_[0]+(St[0]-S_[0])*t,S_[1]+(St[1]-S_[1])*t);g.lineTo(E[0]+(Et[0]-E[0])*t,E[1]+(Et[1]-E[1])*t);g.stroke();}
|
||
// wide loading bay (dark opening) on SE face
|
||
const bcx=(S_[0]+E[0])/2, bcy=(S_[1]+E[1])/2;
|
||
fillP(g,[[bcx-6,bcy-1],[bcx+6,bcy-6],[bcx+6,bcy-15],[bcx-6,bcy-10]],"#5a4632",1.4);
|
||
g.strokeStyle="rgba(230,215,180,.35)";g.lineWidth=1;g.beginPath();g.moveTo(bcx,bcy-3.5);g.lineTo(bcx,bcy-12.5);g.stroke();
|
||
// FLAT roof slab — clearly not a peaked house roof
|
||
const roof=info.roof||"#b98a4a";
|
||
const NtR=[Nt[0],Nt[1]-th],EtR=[Et[0],Et[1]-th],StR=[St[0],St[1]-th],WtR=[Wt[0],Wt[1]-th];
|
||
fillP(g,[Wt,St,StR,WtR],dark(roof,0.16),1.6); // slab SW edge
|
||
fillP(g,[St,Et,EtR,StR],dark(roof,0.3),1.6); // slab SE edge
|
||
fillP(g,[NtR,EtR,StR,WtR],roof,1.6); // slab top
|
||
// rooftop hatch
|
||
fillP(g,[[px-4,NtR[1]+9],[px+2,NtR[1]+6],[px+2,NtR[1]+12],[px-4,NtR[1]+15]],dark(roof,0.34),1.2);
|
||
if(isWinter()){g.fillStyle="rgba(255,255,255,.82)";poly(g,[NtR,EtR,StR,WtR]);g.fill();}
|
||
// crates + barrel stacked out front (drawn last → in front)
|
||
artCrate(g,px-11,baseY+bh+2,5);artCrate(g,px-3,baseY+bh+4,5);artCrate(g,px-7,baseY+bh-3.5,5);
|
||
artBarrel(g,px+10,baseY+bh+2,5);}
|
||
|
||
// ---- distinct building silhouettes (unique shape + signature prop, shared doodle style) ----
|
||
// Two visible iso wall faces (SW + SE); returns wall-top corners for adding roofs/props.
|
||
function isoWalls(g,px,py,bw,bh,wallH,wall){
|
||
const S_=[px,py+bh],E=[px+bw,py],W=[px-bw,py],N=[px,py-bh];
|
||
const St=[px,py+bh-wallH],Et=[px+bw,py-wallH],Wt=[px-bw,py-wallH],Nt=[px,py-bh-wallH];
|
||
fillP(g,[W,S_,St,Wt],wall,1.8);
|
||
fillP(g,[S_,E,Et,St],dark(wall,0.12),1.8);
|
||
return {S_,E,W,N,St,Et,Wt,Nt,bw,bh,wallH};
|
||
}
|
||
// Long ridged (barn/gable) roof over the diamond footprint; ridge runs front↔back.
|
||
function ridgeRoof(g,c,roofH,col,winter){
|
||
const Rf=[c.St[0],c.St[1]-roofH],Rb=[c.Nt[0],c.Nt[1]-roofH];
|
||
fillP(g,[c.St,c.Wt,c.Nt,Rb,Rf],lite(col,0.06),1.7); // SW slope
|
||
fillP(g,[c.St,c.Et,c.Nt,Rb,Rf],dark(col,0.14),1.7); // SE slope
|
||
g.strokeStyle=dark(col,0.32);g.lineWidth=1.1;g.beginPath();g.moveTo(Rf[0],Rf[1]);g.lineTo(Rb[0],Rb[1]);g.stroke();
|
||
if(winter){g.fillStyle="rgba(255,255,255,.72)";poly(g,[c.St,c.Wt,c.Nt,Rb,Rf]);g.fill();poly(g,[c.St,c.Et,c.Nt,Rb,Rf]);g.fill();}
|
||
return {Rf,Rb};
|
||
}
|
||
// Hip roof to a single apex (cozy cabin style); returns apex.
|
||
function hipRoof(g,c,roofH,col,winter){
|
||
const ap=[c.St[0],(c.St[1]+c.Nt[1])/2-roofH];
|
||
fillP(g,[c.Wt,c.St,ap],col,1.7);fillP(g,[c.St,c.Et,ap],dark(col,0.12),1.7);
|
||
fillP(g,[c.Wt,c.Nt,ap],lite(col,0.08),1.7);fillP(g,[c.Nt,c.Et,ap],col,1.7);
|
||
if(winter){g.fillStyle="rgba(255,255,255,.76)";poly(g,[c.Wt,c.Nt,ap]);g.fill();poly(g,[c.Nt,c.Et,ap]);g.fill();
|
||
g.fillStyle="rgba(255,255,255,.5)";poly(g,[c.Wt,c.St,ap]);g.fill();}
|
||
return ap;
|
||
}
|
||
// Small hanging shop sign on a post at (x,y=ground); iconFn(cx,cy) draws the emblem.
|
||
function shopSign(g,x,y,boardCol,iconFn){
|
||
g.strokeStyle="#7a5230";g.lineWidth=2.2;g.lineCap="round";
|
||
g.beginPath();g.moveTo(x,y);g.lineTo(x,y-19);g.lineTo(x+8,y-19);g.stroke();
|
||
g.strokeStyle="#5a3f28";g.lineWidth=1;g.beginPath();g.moveTo(x+8,y-19);g.lineTo(x+8,y-15.5);g.stroke();
|
||
fillP(g,[[x+2.5,y-15],[x+13.5,y-15],[x+13.5,y-6],[x+2.5,y-6]],boardCol,1.3);
|
||
if(iconFn)iconFn(x+8,y-10.5);
|
||
}
|
||
function artCraftShop(g,px,py,winter){shadow(g,px,py+5,22);
|
||
const wall="#d8c39a",c=isoWalls(g,px,py,17,8.5,15,wall);
|
||
timberFace(g,c.W,c.S_,c.Wt,"#6b4a30");
|
||
const Psw=faceP(c.W,c.S_,c.Wt),Pse=timberFace(g,c.S_,c.E,c.St,"#5f4128");
|
||
ridgeRoof(g,c,12,"#8a5a3c",winter);
|
||
chimney(g,px-8,c.St[1]-7,"#8f8a82"); // stone forge chimney
|
||
warmWin(g,Psw,0.52,0.32,0.8,0.62,"#f6cb5c"); // side window
|
||
// door + open forge bay on the front
|
||
doorway(g,Pse,0.1,0.28,0.5,"#5a4130");
|
||
fillP(g,[Pse(0.42,0.02),Pse(0.9,0.02),Pse(0.9,0.54),Pse(0.42,0.54)],"#3f2f22",1.3);
|
||
{const bc=Pse(0.66,0.26);const fg=g.createRadialGradient(bc[0],bc[1],0.5,bc[0],bc[1],7);fg.addColorStop(0,"#ffcf72");fg.addColorStop(1,"rgba(224,86,47,0)");g.fillStyle=fg;g.beginPath();g.ellipse(bc[0],bc[1],6.4,6,0,0,7);g.fill();}
|
||
// anvil on a stump out front (signature)
|
||
const axx=px-12,ayy=py+12;shadow(g,axx,ayy+2.5,6);
|
||
fillP(g,[[axx-3,ayy+1],[axx+3,ayy+1],[axx+3,ayy+4],[axx-3,ayy+4]],"#8a6b45",1.2);
|
||
fillP(g,[[axx-5,ayy-3.4],[axx+3.5,ayy-3.4],[axx+7,ayy-2.4],[axx+3.5,ayy-1.4],[axx+1.6,ayy-1.4],[axx+1.6,ayy+1],[axx-1.6,ayy+1],[axx-1.6,ayy-1.4],[axx-5,ayy-1.4]],"#54545c",1.4);
|
||
g.fillStyle="#767680";fillP(g,[[axx-5,ayy-3.4],[axx+3.5,ayy-3.4],[axx+3.5,ayy-2.5],[axx-5,ayy-2.5]],"#767680",0);
|
||
g.fillStyle="#ffd66b";circ(g,axx+2,ayy-5.6,0.9);g.fill();circ(g,axx+5.4,ayy-7,0.7);g.fill();
|
||
// hanging hammer sign
|
||
shopSign(g,px+15,py+6,"#e9d3a0",(cx,cy)=>{g.strokeStyle="#7a5230";g.lineWidth=2;g.lineCap="round";g.beginPath();g.moveTo(cx-2,cy+3);g.lineTo(cx+1,cy-2);g.stroke();g.fillStyle="#8a8f98";fillP(g,[[cx-1,cy-4],[cx+4,cy-4],[cx+4,cy-1.5],[cx-1,cy-1.5]],"#8a8f98",1);});}
|
||
function artSawmill(g,px,py,winter){shadow(g,px,py+5,23);
|
||
const wall="#b98a54",c=isoWalls(g,px+3,py,14,8,13,wall);
|
||
const Psw=plankFace(g,c.W,c.S_,c.Wt,dark(wall,0.2),4),Pse=plankFace(g,c.S_,c.E,c.St,dark(wall,0.28),4);
|
||
ridgeRoof(g,c,11,"#9c6b3a",winter);
|
||
warmWin(g,Pse,0.44,0.32,0.68,0.62,"#f6cb5c"); // mill window
|
||
doorway(g,Pse,0.74,0.92,0.5,"#6f4a2c");
|
||
// big water wheel on the left (signature) in its mill-race
|
||
const wx=px-15,wy=py+2,wr=10;
|
||
g.fillStyle="#9fb9c9";g.beginPath();g.ellipse(wx-2,wy+7,11,3.2,0,0,7);g.fill();
|
||
g.fillStyle="rgba(255,255,255,.5)";g.beginPath();g.ellipse(wx-4,wy+6.5,3,1,0,0,7);g.fill();
|
||
fillC(g,wx,wy,wr,"#8a6b45",1.6);fillC(g,wx,wy,wr*0.42,"#c79b62",1.3);
|
||
g.strokeStyle="#6f4a2c";g.lineWidth=1.4;
|
||
for(let i=0;i<8;i++){const a=i/8*Math.PI*2;g.beginPath();g.moveTo(wx+Math.cos(a)*wr*0.42,wy+Math.sin(a)*wr*0.42);g.lineTo(wx+Math.cos(a)*wr,wy+Math.sin(a)*wr);g.stroke();
|
||
const a2=(i+0.5)/8*Math.PI*2;g.strokeStyle="#5a3f28";g.lineWidth=2.2;g.beginPath();g.moveTo(wx+Math.cos(a2)*wr*0.86,wy+Math.sin(a2)*wr*0.86);g.lineTo(wx+Math.cos(a2)*(wr+2),wy+Math.sin(a2)*(wr+2));g.stroke();g.lineWidth=1.4;g.strokeStyle="#6f4a2c";}
|
||
// fresh-cut log pile out front
|
||
for(const [lx,ly] of [[px+7,py+13],[px+14,py+11],[px+10.5,py+8]]){fillC(g,lx,ly,3.2,"#c9a066",1.3);fillC(g,lx,ly,1.5,"#e0c793",1);}}
|
||
function artKitchen(g,px,py,winter){shadow(g,px,py+5,22);
|
||
const wall="#efd9a4",c=isoWalls(g,px-2,py,15,8,14,wall);
|
||
timberFace(g,c.W,c.S_,c.Wt,"#7a5230");
|
||
const Pse=timberFace(g,c.S_,c.E,c.St,"#6b4a2a");
|
||
ridgeRoof(g,c,12,"#c06636",winter);
|
||
// door + glowing shopfront window (with pies on the sill)
|
||
doorway(g,Pse,0.1,0.28,0.5,"#8a5f38");
|
||
warmWin(g,Pse,0.36,0.1,0.72,0.5,"#f6cb5c");
|
||
["#c76b3a","#b0894a","#c76b3a"].forEach((cc,i)=>{const b=Pse(0.44+i*0.11,0.17);g.fillStyle=cc;g.beginPath();g.arc(b[0],b[1],1.3,Math.PI,0);g.fill();});
|
||
// massive stone oven-chimney on the right (signature)
|
||
const ox=px+14,oy=py+3;
|
||
fillP(g,[[ox-5,oy-2],[ox+5,oy-2],[ox+5,oy-21],[ox-5,oy-21]],"#bdb8b0",1.5);
|
||
fillP(g,[[ox-6,oy-21],[ox+6,oy-21],[ox+6,oy-24],[ox-6,oy-24]],"#a4a29b",1.3);
|
||
g.strokeStyle="#948f86";g.lineWidth=0.8;for(const yy of [-7,-12,-17]){g.beginPath();g.moveTo(ox-5,oy+yy);g.lineTo(ox+5,oy+yy);g.stroke();}
|
||
g.beginPath();g.moveTo(ox,oy-2);g.lineTo(ox,oy-7);g.moveTo(ox-2.5,oy-7);g.lineTo(ox-2.5,oy-12);g.moveTo(ox+2.5,oy-12);g.lineTo(ox+2.5,oy-17);g.stroke();
|
||
// arched glowing oven mouth
|
||
g.beginPath();g.moveTo(ox-3.4,oy-3);g.lineTo(ox-3.4,oy-6);g.arc(ox,oy-6,3.4,Math.PI,0);g.lineTo(ox+3.4,oy-3);g.closePath();
|
||
const fg=g.createLinearGradient(ox,oy-9,ox,oy-2);fg.addColorStop(0,"#ffd66b");fg.addColorStop(1,"#e0562f");g.fillStyle=fg;g.fill();inkStroke(g,1.2);
|
||
g.strokeStyle="#7a3410";g.lineWidth=1;g.beginPath();g.moveTo(ox-2,oy-3.4);g.lineTo(ox+2,oy-3.4);g.stroke();
|
||
g.fillStyle="rgba(246,246,250,.62)";for(const [sx,sy,sr] of [[ox,oy-28,2.2],[ox+2,oy-32,2.7],[ox-1,oy-37,3]]){circ(g,sx,sy,sr);g.fill();}
|
||
// hanging pot sign
|
||
shopSign(g,px-15,py+6,"#efe0b6",(cx,cy)=>{g.fillStyle="#5a5a62";g.beginPath();g.arc(cx,cy+0.8,3,0,Math.PI);g.fill();inkStroke(g,1.1);
|
||
g.strokeStyle=INK;g.lineWidth=1.1;g.beginPath();g.moveTo(cx-3.4,cy+0.6);g.lineTo(cx+3.4,cy+0.6);g.stroke();
|
||
g.strokeStyle="#6b6b72";g.beginPath();g.arc(cx,cy-0.4,1.2,Math.PI,0);g.stroke();});}
|
||
// Tudor half-timber framing on an iso wall face (A=bottom-left, B=bottom-right, C=top-left corner).
|
||
function timberFace(g,A,B,C,beam){
|
||
const P=(u,v)=>[A[0]+(B[0]-A[0])*u+(C[0]-A[0])*v, A[1]+(B[1]-A[1])*u+(C[1]-A[1])*v];
|
||
g.strokeStyle=beam;g.lineCap="round";
|
||
g.lineWidth=1.7;for(const u of [0.03,0.5,0.97]){const a=P(u,0),b=P(u,1);g.beginPath();g.moveTo(a[0],a[1]);g.lineTo(b[0],b[1]);g.stroke();} // studs
|
||
for(const v of [0.5,0.97]){const a=P(0,v),b=P(1,v);g.beginPath();g.moveTo(a[0],a[1]);g.lineTo(b[0],b[1]);g.stroke();} // rails
|
||
g.lineWidth=1.3; // braces meeting at the mid-panel
|
||
for(const u of [0.03,0.97]){const a=P(u,0),b=P(0.5,0.5);g.beginPath();g.moveTo(a[0],a[1]);g.lineTo(b[0],b[1]);g.stroke();}
|
||
return P;
|
||
}
|
||
// Barycentric point-fn for an iso wall face (A=bottom-left, B=bottom-right, C=top-left corner).
|
||
function faceP(A,B,C){return (u,v)=>[A[0]+(B[0]-A[0])*u+(C[0]-A[0])*v, A[1]+(B[1]-A[1])*u+(C[1]-A[1])*v];}
|
||
// Horizontal plank/siding courses on a face.
|
||
function plankFace(g,A,B,C,col,rows){const P=faceP(A,B,C);g.strokeStyle=col;g.lineWidth=0.8;
|
||
for(let i=1;i<(rows||3);i++){const v=i/(rows||3),a=P(0,v),b=P(1,v);g.beginPath();g.moveTo(a[0],a[1]);g.lineTo(b[0],b[1]);g.stroke();}return P;}
|
||
// Warm-lit window (quad + mullion cross) on a face given its point-fn.
|
||
function warmWin(g,P,u0,v0,u1,v1,col){fillP(g,[P(u0,v0),P(u1,v0),P(u1,v1),P(u0,v1)],col||"#f6cb5c",1.1);
|
||
g.strokeStyle="rgba(90,60,30,.42)";g.lineWidth=0.7;
|
||
let a=P((u0+u1)/2,v0),b=P((u0+u1)/2,v1);g.beginPath();g.moveTo(a[0],a[1]);g.lineTo(b[0],b[1]);g.stroke();
|
||
a=P(u0,(v0+v1)/2);b=P(u1,(v0+v1)/2);g.beginPath();g.moveTo(a[0],a[1]);g.lineTo(b[0],b[1]);g.stroke();}
|
||
// Plank door on a face.
|
||
function doorway(g,P,u0,u1,vTop,col,knob){const q=[P(u0,0.02),P(u1,0.02),P(u1,vTop),P(u0,vTop)];fillP(g,q,col||"#6f4a2c",1.3);
|
||
if(knob!==false){const k=P(u1-0.04,vTop*0.5);g.fillStyle="#e9c56a";circ(g,k[0],k[1],0.9);g.fill();}return q;}
|
||
// Brick chimney with smoke; (x,baseY)=where it meets the roof, grows upward.
|
||
function chimney(g,x,baseY,col){col=col||"#9c6b5a";
|
||
fillP(g,[[x-2.2,baseY],[x+2.2,baseY],[x+2.2,baseY-10],[x-2.2,baseY-10]],col,1.3);
|
||
fillP(g,[[x-2.8,baseY-10],[x+2.8,baseY-10],[x+2.8,baseY-12.4],[x-2.8,baseY-12.4]],dark(col,0.18),1.1);
|
||
g.fillStyle="rgba(246,246,250,.58)";circ(g,x+0.3,baseY-14.5,1.6);g.fill();circ(g,x+1.6,baseY-18,2);g.fill();}
|
||
function artApothecary(g,px,py,winter){shadow(g,px,py+5,20);
|
||
// a proper half-timbered alchemist's shop (walls, framing, shopfront, roof) — themed by details, not a giant bottle
|
||
const wall="#ece0c4",c=isoWalls(g,px,py,14,7.5,21,wall);
|
||
timberFace(g,c.W,c.S_,c.Wt,"#6b4a30");
|
||
const Pse=timberFace(g,c.S_,c.E,c.St,"#5f4128");
|
||
// gabled shingle roof (muted slate-violet keeps a subtle apothecary mood)
|
||
ridgeRoof(g,c,12,"#877e97",winter);
|
||
// brick chimney + smoke on the SW slope
|
||
const chx=px-8,chy=c.St[1]-13;
|
||
fillP(g,[[chx-2,chy],[chx+2.4,chy-1.2],[chx+2.4,chy-9.5],[chx-2,chy-8.3]],"#9c6b5a",1.3);
|
||
fillP(g,[[chx-2.4,chy-8.3],[chx+2.8,chy-9.6],[chx+2.8,chy-11.4],[chx-2.4,chy-10.1]],"#7a5246",1.1);
|
||
g.fillStyle="rgba(246,246,250,.6)";circ(g,chx+0.4,chy-14,1.7);g.fill();circ(g,chx+1.8,chy-17.4,2.1);g.fill();
|
||
// door on the SE shopfront
|
||
fillP(g,[Pse(0.08,0.02),Pse(0.24,0.02),Pse(0.24,0.44),Pse(0.08,0.44)],"#5a4130",1.3);
|
||
{const k=Pse(0.215,0.22);g.fillStyle="#e9c56a";circ(g,k[0],k[1],0.9);g.fill();}
|
||
// glowing bay shop-window full of potion bottles (the real identity)
|
||
const w=[Pse(0.32,0.06),Pse(0.74,0.06),Pse(0.74,0.5),Pse(0.32,0.5)];
|
||
fillP(g,w,"#f6c23f",1.3);
|
||
g.strokeStyle="rgba(90,60,30,.45)";g.lineWidth=0.8;
|
||
let a=Pse(0.53,0.06),b=Pse(0.53,0.5);g.beginPath();g.moveTo(a[0],a[1]);g.lineTo(b[0],b[1]);g.stroke();
|
||
a=Pse(0.32,0.28);b=Pse(0.74,0.28);g.beginPath();g.moveTo(a[0],a[1]);g.lineTo(b[0],b[1]);g.stroke();
|
||
// little coloured bottles on the sill
|
||
["#8e5cc0","#4b86b6","#6ab04a","#c94b6f"].forEach((cc,i)=>{const bb=Pse(0.38+i*0.095,0.13);g.fillStyle=cc;g.beginPath();g.ellipse(bb[0],bb[1],1.05,1.7,0,0,7);g.fill();});
|
||
// dried herb bundle hanging by the door
|
||
const hb=Pse(0.28,0.52);g.strokeStyle="#6f8a3a";g.lineWidth=1.3;g.lineCap="round";
|
||
for(const dx of [-1.2,0.2,1.4]){g.beginPath();g.moveTo(hb[0],hb[1]);g.lineTo(hb[0]+dx,hb[1]+5.5);g.stroke();}
|
||
// hanging mortar-&-pestle sign
|
||
shopSign(g,px+15,py+5,"#e7dcf2",(cx,cy)=>{fillP(g,[[cx-3,cy-0.3],[cx+3,cy-0.3],[cx+2,cy+3],[cx-2,cy+3]],"#b3a6c7",1.1);
|
||
g.fillStyle="#dcd3e6";g.beginPath();g.ellipse(cx,cy-0.3,3,1,0,0,7);g.fill();
|
||
g.strokeStyle="#6b4a30";g.lineWidth=1.5;g.lineCap="round";g.beginPath();g.moveTo(cx+0.5,cy-0.8);g.lineTo(cx+3,cy-4);g.stroke();});}
|
||
function artBrewery(g,px,py,winter){shadow(g,px,py+5,23);
|
||
// a real brewhouse: stone base + timbered upper, gabled roof, steaming copper flue
|
||
const wall="#c9b59a",c=isoWalls(g,px-1,py,16,8.5,15,wall);
|
||
// stone base course
|
||
fillP(g,[c.W,c.S_,faceP(c.W,c.S_,c.Wt)(1,0.3),faceP(c.W,c.S_,c.Wt)(0,0.3)],"#b7b2ab",1.5);
|
||
fillP(g,[c.S_,c.E,faceP(c.S_,c.E,c.St)(1,0.3),faceP(c.S_,c.E,c.St)(0,0.3)],dark("#b7b2ab",0.12),1.5);
|
||
timberFace(g,c.W,c.S_,c.Wt,"#6b4a30");
|
||
const Pse=timberFace(g,c.S_,c.E,c.St,"#5f4128");
|
||
ridgeRoof(g,c,12,"#8a5a3c",winter);
|
||
// copper flue with rising steam (the boil)
|
||
const fx=px-8,fy=c.St[1]-7;
|
||
fillP(g,[[fx-2.4,fy],[fx+2.4,fy],[fx+2,fy-11],[fx-2,fy-11]],"#b5793f",1.3);
|
||
fillE(g,fx,fy-11,2.6,1.1,"#caa06a",1.1);
|
||
g.fillStyle="rgba(246,246,250,.6)";for(const [sx,sy,sr] of [[fx,fy-15,1.8],[fx+1.4,fy-18.5,2.2],[fx-0.4,fy-22,2.6]]){circ(g,sx,sy,sr);g.fill();}
|
||
// arched brewhouse doorway + a warm window
|
||
g.beginPath();g.moveTo(Pse(0.12,0.02)[0],Pse(0.12,0.02)[1]);g.lineTo(Pse(0.12,0.4)[0],Pse(0.12,0.4)[1]);
|
||
{const t=Pse(0.24,0.52),r=Pse(0.36,0.4);g.quadraticCurveTo(t[0],t[1],r[0],r[1]);}
|
||
g.lineTo(Pse(0.36,0.02)[0],Pse(0.36,0.02)[1]);g.closePath();g.fillStyle="#4a3826";g.fill();inkStroke(g,1.3);
|
||
warmWin(g,Pse,0.5,0.28,0.74,0.56,"#f6cb5c");
|
||
// barrels stacked outside (the signature)
|
||
artBarrel(g,px+15,py+13,4.6);artBarrel(g,px+20,py+11,4.2);artBarrel(g,px+17.5,py+7,4.4);
|
||
fillP(g,[[px-20,py+13],[px-13,py+13],[px-14,py+5.5],[px-19,py+5.5]],"#d8c79a",1.3); // grain sack
|
||
g.strokeStyle="#b6a374";g.lineWidth=0.8;g.beginPath();g.moveTo(px-18.4,py+7.2);g.lineTo(px-13.9,py+7.2);g.stroke();}
|
||
function artRanch(g,px,py,winter){shadow(g,px,py+5,24);
|
||
const wall="#b0533f",c=isoWalls(g,px,py,18,9,14,wall); // barn-red
|
||
const Psw=plankFace(g,c.W,c.S_,c.Wt,dark(wall,0.16),5),Pse=faceP(c.S_,c.E,c.St);
|
||
ridgeRoof(g,c,13,"#8a8f98",winter);
|
||
// white gable trim + hayloft door high on the SW gable
|
||
fillP(g,[Psw(0.4,0.62),Psw(0.6,0.62),Psw(0.6,0.9),Psw(0.4,0.9)],"#c9a066",1.2);
|
||
g.strokeStyle="#f2ead6";g.lineWidth=1;let a=Psw(0.5,0.62),b=Psw(0.5,0.9);g.beginPath();g.moveTo(a[0],a[1]);g.lineTo(b[0],b[1]);g.stroke();
|
||
// big barn door on SE — brown planks, clean white frame + X
|
||
const dcx=(c.S_[0]+c.E[0])/2,dcy=(c.S_[1]+c.E[1])/2;
|
||
const D=[[dcx-7,dcy-1],[dcx+7,dcy-8],[dcx+7,dcy-19],[dcx-7,dcy-12]];
|
||
fillP(g,D,"#8a5a3c",1.4);
|
||
g.strokeStyle="#f2ead6";g.lineWidth=1.6;g.lineCap="round";
|
||
g.beginPath();g.moveTo(D[0][0],D[0][1]);g.lineTo(D[2][0],D[2][1]);g.moveTo(D[1][0],D[1][1]);g.lineTo(D[3][0],D[3][1]);g.stroke();
|
||
g.lineWidth=1.1;g.beginPath();g.moveTo((D[0][0]+D[3][0])/2,(D[0][1]+D[3][1])/2);g.lineTo((D[1][0]+D[2][0])/2,(D[1][1]+D[2][1])/2);g.stroke();
|
||
// weathervane on the ridge
|
||
g.strokeStyle="#4b4b52";g.lineWidth=1.2;g.lineCap="round";g.beginPath();g.moveTo(c.St[0],c.St[1]-13);g.lineTo(c.St[0],c.St[1]-19);g.stroke();
|
||
fillP(g,[[c.St[0],c.St[1]-19],[c.St[0]+5,c.St[1]-18],[c.St[0],c.St[1]-16.5]],"#4b4b52",1);
|
||
// round hay bale out front
|
||
const hx=px+15,hy=py+12;
|
||
fillP(g,[[hx-5,hy-3],[hx+5,hy-3],[hx+5,hy+2],[hx-5,hy+2]],"#dcc373",1.3);
|
||
fillE(g,hx,hy-3,5,2.2,"#e6d38a",1.2);
|
||
g.strokeStyle="#c2ab5e";g.lineWidth=0.8;for(const o of [-2.6,0,2.6]){g.beginPath();g.moveTo(hx+o,hy-2.4);g.lineTo(hx+o,hy+1.6);g.stroke();}}
|
||
function artButcher(g,px,py,winter){shadow(g,px,py+5,22);
|
||
const wall="#ecd8b6",c=isoWalls(g,px,py,16,8,14,wall);
|
||
timberFace(g,c.W,c.S_,c.Wt,"#7a5230");
|
||
const Pse=timberFace(g,c.S_,c.E,c.St,"#6b4a2a");
|
||
ridgeRoof(g,c,11,"#9c4f42",winter);
|
||
chimney(g,px-8,c.St[1]-7,"#9c6b5a");
|
||
const S_=c.S_,E=c.E;
|
||
// dark shopfront window with hanging meat behind glass
|
||
fillP(g,[Pse(0.2,0.06),Pse(0.82,0.06),Pse(0.82,0.46),Pse(0.2,0.46)],"#5a4632",1.3);
|
||
["#c96b6f","#d98a86","#b0533f"].forEach((cc,i)=>{const b=Pse(0.32+i*0.19,0.34);g.strokeStyle="#3a2f26";g.lineWidth=0.7;g.beginPath();g.moveTo(b[0],b[1]);g.lineTo(b[0],b[1]-4);g.stroke();g.fillStyle=cc;g.beginPath();g.ellipse(b[0],b[1],1.6,1.1,0,0,7);g.fill();});
|
||
// striped awning over the shopfront — scalloped bottom edge (signature)
|
||
const a0=[S_[0]-1,S_[1]-7],a1=[E[0]+1,E[1]-7],b0=[S_[0]+1,S_[1]+4],b1=[E[0]-1,E[1]+4];
|
||
for(let i=0;i<7;i++){const t0=i/7,t1=(i+1)/7,col=i%2?"#f4ecd8":"#c94b3f";
|
||
const p0=[a0[0]+(a1[0]-a0[0])*t0,a0[1]+(a1[1]-a0[1])*t0],p1=[a0[0]+(a1[0]-a0[0])*t1,a0[1]+(a1[1]-a0[1])*t1];
|
||
const q0=[b0[0]+(b1[0]-b0[0])*t0,b0[1]+(b1[1]-b0[1])*t0],q1=[b0[0]+(b1[0]-b0[0])*t1,b0[1]+(b1[1]-b0[1])*t1];
|
||
fillP(g,[p0,p1,q1,q0],col,1.1);
|
||
g.fillStyle=col;g.beginPath();g.ellipse((q0[0]+q1[0])/2,(q0[1]+q1[1])/2+1.3,2.2,1.7,0,0,7);g.fill();inkStroke(g,1);}
|
||
// hanging ham sign
|
||
shopSign(g,px-15,py+5,"#efe0b6",(cx,cy)=>{g.fillStyle="#e39a94";g.beginPath();g.ellipse(cx+0.6,cy+0.2,3.4,2.5,0.3,0,7);g.fill();inkStroke(g,1.1);
|
||
g.fillStyle="#f4ecd8";circ(g,cx-2.6,cy-1.2,1.3);g.fill();inkStroke(g,0.9);});}
|
||
function artNursery(g,px,py,winter){shadow(g,px,py+5,20);
|
||
const wall="#f3d4de",c=isoWalls(g,px,py,15,8,13,wall); // soft pink plaster
|
||
const Psw=faceP(c.W,c.S_,c.Wt),Pse=faceP(c.S_,c.E,c.St);
|
||
const rr=ridgeRoof(g,c,11,"#cf6f9c",winter);
|
||
chimney(g,px-8,c.St[1]-7,"#c98aa8");
|
||
// arched door + warm windows
|
||
const dc=Pse(0.5,0.02);
|
||
g.beginPath();g.moveTo(Pse(0.4,0.02)[0],Pse(0.4,0.02)[1]);g.lineTo(Pse(0.4,0.34)[0],Pse(0.4,0.34)[1]);
|
||
{const t=Pse(0.5,0.46),r=Pse(0.6,0.34);g.quadraticCurveTo(t[0],t[1],r[0],r[1]);}
|
||
g.lineTo(Pse(0.6,0.02)[0],Pse(0.6,0.02)[1]);g.closePath();g.fillStyle="#c98aa8";g.fill();inkStroke(g,1.3);
|
||
warmWin(g,Pse,0.16,0.3,0.34,0.56,"#f7e6ef");
|
||
warmWin(g,Psw,0.5,0.3,0.72,0.56,"#f7e6ef");
|
||
// bunting flags across the SE eaves
|
||
const flg=["#e06e78","#f2c14a","#6ab04a","#4b86b6"];
|
||
for(let i=0;i<4;i++){const t=0.12+i*0.24,p=Pse(t,0.96);fillP(g,[[p[0],p[1]],[p[0]+3,p[1]-0.4],[p[0]+1.5,p[1]+3.2]],flg[i],1);}
|
||
// hanging heart above the ridge
|
||
g.strokeStyle="#7a5230";g.lineWidth=1.3;g.beginPath();g.moveTo(rr.Rf[0],rr.Rf[1]);g.lineTo(rr.Rf[0],rr.Rf[1]-6);g.stroke();
|
||
artHeart(g,rr.Rf[0],rr.Rf[1]-8,3,"#e06e78");}
|
||
function artSchool(g,px,py,winter){shadow(g,px,py+5,22);
|
||
const wall="#efe3c4",c=isoWalls(g,px,py,16,8.5,15,wall);
|
||
plankFace(g,c.W,c.S_,c.Wt,dark(wall,0.14),4);
|
||
const Pse=faceP(c.S_,c.E,c.St);
|
||
const rr=ridgeRoof(g,c,11,"#b7863a",winter);
|
||
// white bell tower over the FRONT gable (classic schoolhouse)
|
||
const bx=rr.Rf[0],by=rr.Rf[1];
|
||
fillP(g,[[bx-3.4,by+1],[bx+3.4,by+1],[bx+3.4,by-7],[bx-3.4,by-7]],"#f4ecd8",1.4); // tower box
|
||
fillC(g,bx,by-3,1.9,"#6b6b72",1); // bell
|
||
fillP(g,[[bx-4,by-7],[bx+4,by-7],[bx,by-15]],"#b04a3a",1.4); // spire
|
||
g.strokeStyle="#7a5230";g.lineWidth=1.2;g.beginPath();g.moveTo(bx,by-15);g.lineTo(bx,by-19);g.stroke();
|
||
fillP(g,[[bx,by-19],[bx+7,by-17.6],[bx,by-16]],"#4b86b6",1.1); // flag
|
||
// gable clock
|
||
fillC(g,c.St[0],c.St[1]-7,2.4,"#f4ecd8",1.2);g.strokeStyle=INK;g.lineWidth=0.7;g.beginPath();g.moveTo(c.St[0],c.St[1]-7);g.lineTo(c.St[0],c.St[1]-8.6);g.moveTo(c.St[0],c.St[1]-7);g.lineTo(c.St[0]+1.2,c.St[1]-6.4);g.stroke();
|
||
// arched door + tall windows on the front
|
||
g.beginPath();g.moveTo(Pse(0.42,0.02)[0],Pse(0.42,0.02)[1]);g.lineTo(Pse(0.42,0.36)[0],Pse(0.42,0.36)[1]);
|
||
{const t=Pse(0.5,0.48),r=Pse(0.58,0.36);g.quadraticCurveTo(t[0],t[1],r[0],r[1]);}
|
||
g.lineTo(Pse(0.58,0.02)[0],Pse(0.58,0.02)[1]);g.closePath();g.fillStyle="#8a6b45";g.fill();inkStroke(g,1.3);
|
||
warmWin(g,Pse,0.16,0.24,0.32,0.6,"#bcd6e0");warmWin(g,Pse,0.68,0.24,0.84,0.6,"#bcd6e0");}
|
||
function artWolfDen(g,px,py,winter){shadow(g,px,py+5,22);
|
||
const wall="#9a938a",c=isoWalls(g,px,py,15,8,13,wall); // timber lodge
|
||
plankFace(g,c.W,c.S_,c.Wt,dark(wall,0.18),4);
|
||
const Pse=faceP(c.S_,c.E,c.St);
|
||
ridgeRoof(g,c,12,"#5f5f66",winter);
|
||
// round gable window
|
||
fillC(g,c.St[0],c.St[1]-7,2.4,"#3f2f22",1.2);
|
||
// big arched kennel mouth (signature) on the front
|
||
const dcx=(c.S_[0]+c.E[0])/2,dcy=(c.S_[1]+c.E[1])/2;
|
||
g.beginPath();g.moveTo(dcx-6,dcy+2);g.lineTo(dcx-6,dcy-7);g.arc(dcx,dcy-7,6,Math.PI,0);g.lineTo(dcx+6,dcy+2);g.closePath();g.fillStyle="#2f2722";g.fill();inkStroke(g,1.5);
|
||
g.strokeStyle="#6f4a2c";g.lineWidth=1.4;g.beginPath();g.arc(dcx,dcy-7,6.6,Math.PI,0);g.stroke(); // timber arch frame
|
||
// low fenced yard out front
|
||
g.strokeStyle="#8a6b45";g.lineWidth=1.5;g.lineCap="round";
|
||
for(const [fx,fy] of [[px-14,py+9],[px-8,py+12],[px-1,py+14],[px+7,py+13]]){g.beginPath();g.moveTo(fx,fy);g.lineTo(fx,fy-6);g.stroke();}
|
||
g.beginPath();g.moveTo(px-14,py+5.5);g.lineTo(px+7,py+8);g.stroke();
|
||
// bone sign on a post
|
||
shopSign(g,px+14,py+5,"#efe6d6",(cx,cy)=>{g.fillStyle="#f2ead6";g.strokeStyle=INK;g.lineWidth=1;
|
||
g.beginPath();g.arc(cx-3,cy-1.5,1.5,0,7);g.arc(cx-3,cy+1.5,1.5,0,7);g.arc(cx+3,cy-1.5,1.5,0,7);g.arc(cx+3,cy+1.5,1.5,0,7);g.fill();
|
||
g.fillRect(cx-3,cy-1.4,6,2.8);g.beginPath();g.moveTo(cx-2.5,cy-1.6);g.lineTo(cx+2.5,cy-1.6);g.moveTo(cx-2.5,cy+1.6);g.lineTo(cx+2.5,cy+1.6);g.stroke();});}
|
||
// ---- Decorative items (cosmetic; a prettier village lifts everyone's mood a little) ----
|
||
function artFlowerbed(g,px,py,winter){shadow(g,px,py+3,12);
|
||
fillP(g,[[px,py+5],[px+11,py],[px,py-5],[px-11,py]],"#8a6b45",1.4);
|
||
fillP(g,[[px,py+3.2],[px+8.5,py],[px,py-3.2],[px-8.5,py]],"#9c7a4e",1);
|
||
if(winter){fillP(g,[[px,py+3.2],[px+8.5,py],[px,py-3.2],[px-8.5,py]],"rgba(255,255,255,.6)",0);return;}
|
||
var cols=["#e0607a","#f2b84a","#c86ec8","#6aa9e0","#e57e8e"],spots=[[-6,0.5],[-2,-1.5],[2.5,1],[6,-0.5],[0,1.8],[-3.5,1.6]];
|
||
for(var i=0;i<spots.length;i++){var fx=px+spots[i][0],fy=py+spots[i][1];
|
||
g.strokeStyle="#5f8a3a";g.lineWidth=1;g.lineCap="round";g.beginPath();g.moveTo(fx,fy);g.lineTo(fx,fy-4.5);g.stroke();
|
||
g.fillStyle=cols[i%cols.length];for(var k=0;k<5;k++){var a=k/5*6.283;circ(g,fx+Math.cos(a)*1.5,fy-4.5+Math.sin(a)*1.5,1.15);g.fill();}
|
||
g.fillStyle="#fff3c0";circ(g,fx,fy-4.5,0.85);g.fill();}}
|
||
function artLantern(g,px,py,winter){shadow(g,px,py+3,6);
|
||
var ph=((S.tsec||0)%TOD_LEN)/TOD_LEN,night=ph>0.55&&ph<0.92;
|
||
g.strokeStyle="#5a4835";g.lineWidth=2.4;g.lineCap="round";
|
||
g.beginPath();g.moveTo(px,py+2);g.lineTo(px,py-15);g.lineTo(px+4,py-15);g.stroke();
|
||
var lx=px+4,ly=py-11.5;
|
||
if(night){var gr=g.createRadialGradient(lx,ly,0,lx,ly,11);gr.addColorStop(0,"rgba(255,224,130,.85)");gr.addColorStop(1,"rgba(255,224,130,0)");g.fillStyle=gr;circ(g,lx,ly,11);g.fill();}
|
||
fillP(g,[[lx-2.6,ly-3.2],[lx+2.6,ly-3.2],[lx+2.6,ly+3.2],[lx-2.6,ly+3.2]],night?"#ffe89a":"#cdbf9a",1.1);
|
||
g.fillStyle=night?"#fff4c2":"#e8dcc0";g.fillRect(lx-1.3,ly-2,2.6,4);
|
||
fillP(g,[[lx-3.2,ly-3.2],[lx+3.2,ly-3.2],[lx+2,ly-5.6],[lx-2,ly-5.6]],"#6b5640",1);}
|
||
function artWell(g,px,py,winter){shadow(g,px,py+3,12);
|
||
fillE(g,px,py,10,5,"#b7bdc2",1.5);fillE(g,px,py-1.5,8,3.8,"#9aa0a6",1.2);
|
||
g.fillStyle="#3a4550";g.beginPath();g.ellipse(px,py-1.5,6,2.9,0,0,7);g.fill();
|
||
g.strokeStyle="#6b5640";g.lineWidth=2.2;g.lineCap="round";
|
||
g.beginPath();g.moveTo(px-7,py-1);g.lineTo(px-6,py-15);g.moveTo(px+7,py-1);g.lineTo(px+6,py-15);g.stroke();
|
||
fillP(g,[[px-9,py-14],[px+9,py-14],[px+5,py-20],[px-5,py-20]],"#a35a4a",1.2);
|
||
g.strokeStyle="#5a4835";g.lineWidth=1.2;g.beginPath();g.moveTo(px,py-13.5);g.lineTo(px,py-6);g.stroke();
|
||
fillP(g,[[px-1.6,py-6],[px+1.6,py-6],[px+1.2,py-3],[px-1.2,py-3]],"#7a5230",1);}
|
||
function artBench(g,px,py,winter){shadow(g,px,py+2,11);var seatC="#b0895a";
|
||
g.strokeStyle="#5a4835";g.lineWidth=2;g.lineCap="round";
|
||
g.beginPath();g.moveTo(px-7,py-1);g.lineTo(px-7,py+3);g.moveTo(px+7,py-2);g.lineTo(px+7,py+2);g.stroke();
|
||
fillP(g,[[px-9,py-1],[px+9,py-3],[px+9,py-1.5],[px-9,py+0.5]],seatC,1.2);
|
||
g.strokeStyle="#5a4835";g.lineWidth=1.7;g.beginPath();g.moveTo(px-7,py-1.5);g.lineTo(px-7,py-8);g.moveTo(px+7,py-2.5);g.lineTo(px+7,py-9);g.stroke();
|
||
fillP(g,[[px-8,py-7],[px+8,py-9],[px+8,py-7.6],[px-8,py-5.6]],seatC,1.1);}
|
||
function artStatue(g,px,py,winter){shadow(g,px,py+3,9);
|
||
fillP(g,[[px-6,py+3],[px+6,py+3],[px+6,py-1],[px-6,py-1]],"#aab0b6",1.3); // pedestal base
|
||
fillP(g,[[px-5,py-1],[px+5,py-1],[px+4,py-4],[px-4,py-4]],"#c6ccd1",1.1); // pedestal top
|
||
var cx=px,cy=py-4,stone="#c4c9ce";
|
||
g.strokeStyle="#aab0b6";g.lineWidth=1.9;g.lineCap="round"; // little stone arms
|
||
g.beginPath();g.moveTo(cx-3.6,cy-8);g.lineTo(cx-5.2,cy-5);g.moveTo(cx+3.6,cy-8);g.lineTo(cx+5.2,cy-5);g.stroke();
|
||
g.fillStyle=stone;g.beginPath();g.moveTo(cx-4,cy-2);g.quadraticCurveTo(cx-5,cy-16,cx,cy-16);g.quadraticCurveTo(cx+5,cy-16,cx+4,cy-2);g.closePath();g.fill();inkStroke(g,1.3); // carved body
|
||
g.fillStyle="#dadfe3";g.beginPath();g.ellipse(cx-1.4,cy-11,1.5,4.2,0.18,0,7);g.fill(); // stone highlight
|
||
g.fillStyle="#868c92";circ(g,cx-1.6,cy-11,0.7);g.fill();circ(g,cx+1.6,cy-11,0.7);g.fill(); // carved eyes
|
||
g.strokeStyle="#868c92";g.lineWidth=0.7;g.beginPath();g.arc(cx,cy-9.2,1.4,0.25,Math.PI-0.25);g.stroke();} // carved smile
|
||
const BLDART={craft:artCraftShop,sawmill:artSawmill,kitchen:artKitchen,herbalist:artApothecary,
|
||
brewery:artBrewery,ranch:artRanch,butcher:artButcher,nursery:artNursery,school:artSchool,wolfcentre:artWolfDen,
|
||
flowerbed:artFlowerbed,lantern:artLantern,well:artWell,bench:artBench,statue:artStatue};
|
||
// themed falling particles for the seasonal festivals (confetti=Summer, leaf=Fall Harvest, petal=Spring Bloom)
|
||
let festP=[];
|
||
const FEST_SET={confetti:{cols:["#e06e78","#f2c14a","#6ab04a","#4b86b6","#b1567f","#e0913f"],fall:1.3,sway:0.6,shape:"sq"},
|
||
leaf:{cols:["#d98a3a","#c85a2a","#e0b24a","#a8642e","#c89a3a"],fall:0.85,sway:1.15,shape:"leaf"},
|
||
petal:{cols:["#f4c2d0","#f8dbe6","#ffffff","#f0b8cf","#e79ec0"],fall:0.65,sway:1.2,shape:"petal"}};
|
||
function drawFestival(kind){const set=FEST_SET[kind]||FEST_SET.confetti;
|
||
if(festP.length===0)for(let i=0;i<44;i++)festP.push({x:Math.random()*VW,y:Math.random()*VH,s:0.6+Math.random(),ci:i,r:Math.random()*6});
|
||
for(const f of festP){f.y+=f.s*set.fall;f.x+=Math.sin(f.y*0.06)*set.sway;f.r+=0.18;if(f.y>VH){f.y=-8;f.x=Math.random()*VW;}
|
||
ctx.save();ctx.translate(f.x,f.y);ctx.rotate(f.r);ctx.fillStyle=set.cols[f.ci%set.cols.length];
|
||
if(set.shape==="sq")ctx.fillRect(-1.7,-1.7,3.4,3.4);
|
||
else if(set.shape==="leaf"){ctx.beginPath();ctx.ellipse(0,0,1.4,2.9,0,0,7);ctx.fill();ctx.strokeStyle="rgba(90,72,53,.45)";ctx.lineWidth=0.5;ctx.beginPath();ctx.moveTo(0,-2.6);ctx.lineTo(0,2.6);ctx.stroke();}
|
||
else {ctx.beginPath();ctx.ellipse(0,0,2.5,1.5,0,0,7);ctx.fill();}
|
||
ctx.restore();}}
|
||
|
||
let snow=[];
|
||
function drawSnow(){if(snow.length===0)for(let i=0;i<45;i++)snow.push({x:Math.random()*VW,y:Math.random()*VH,s:0.4+Math.random()});
|
||
ctx.fillStyle="rgba(255,255,255,0.85)";
|
||
for(const f of snow){f.y+=f.s*0.7;f.x+=Math.sin(f.y*0.05)*0.3;if(f.y>VH){f.y=-5;f.x=Math.random()*VW;}circ(ctx,f.x,f.y,f.s*1.6);ctx.fill();}}
|
||
let rain=[];
|
||
function drawRain(){if(rain.length===0)for(let i=0;i<95;i++)rain.push({x:Math.random()*VW,y:Math.random()*VH,s:6+Math.random()*5});
|
||
ctx.fillStyle="rgba(120,140,170,0.08)";ctx.fillRect(0,0,VW,VH); // light overcast wash
|
||
ctx.strokeStyle="rgba(150,175,205,0.5)";ctx.lineWidth=1.1;ctx.lineCap="round";
|
||
for(const d of rain){d.y+=d.s*2.4;d.x+=d.s*0.35;if(d.y>VH){d.y=-10;d.x=Math.random()*VW-20;}ctx.beginPath();ctx.moveTo(d.x,d.y);ctx.lineTo(d.x-2.6,d.y-8.5);ctx.stroke();}}
|
||
// ambient world life: butterflies flutter by day in warm seasons, fireflies glow at night — makes the island breathe
|
||
let bflies=[], flies=[];
|
||
function drawAmbient(){if(!ui.anim||!S)return;
|
||
const t=S.tsec||0, ph=(t%TOD_LEN)/TOD_LEN, night=ph>0.6&&ph<0.9, warm=(season()==="Spring"||season()==="Summer");
|
||
if(isWinter())return;
|
||
if(night){
|
||
if(flies.length===0)for(let i=0;i<16;i++)flies.push({x:Math.random()*VW,y:VH*0.3+Math.random()*VH*0.55,ph:Math.random()*6.28});
|
||
for(const fl of flies){const glow=(Math.sin(t*2.2+fl.ph)+1)/2;
|
||
fl.x+=Math.sin(t*0.5+fl.ph)*0.35; fl.y+=Math.cos(t*0.42+fl.ph*1.3)*0.22;
|
||
if(fl.x<0)fl.x=VW; if(fl.x>VW)fl.x=0;
|
||
ctx.fillStyle="rgba(200,230,120,"+(0.22+glow*0.45).toFixed(3)+")";circ(ctx,fl.x,fl.y,2.6+glow*1.6);ctx.fill(); // soft green halo
|
||
ctx.fillStyle="rgba(255,246,170,"+(0.35+glow*0.55).toFixed(3)+")";circ(ctx,fl.x,fl.y,1.1+glow*0.9);ctx.fill(); // warm core
|
||
ctx.fillStyle="rgba(255,255,235,"+(glow*0.7).toFixed(3)+")";circ(ctx,fl.x,fl.y,0.6);ctx.fill();}
|
||
} else if(warm){
|
||
if(bflies.length===0)for(let i=0;i<5;i++)bflies.push({x:Math.random()*VW,y:VH*0.2+Math.random()*VH*0.55,ph:Math.random()*6.28,c:["#e57e8e","#e0a0d0","#efd24a","#a9c8ef","#efb0c0"][i%5],dir:Math.random()<0.5?1:-1});
|
||
for(const bf of bflies){bf.x+=bf.dir*0.5+Math.sin(t*1.3+bf.ph)*0.4; bf.y+=Math.cos(t*2.1+bf.ph)*0.55;
|
||
if(bf.x>VW+12)bf.x=-12; if(bf.x<-12)bf.x=VW+12; if(bf.y<VH*0.12)bf.y=VH*0.12; if(bf.y>VH*0.8)bf.y=VH*0.8;
|
||
const flap=Math.abs(Math.sin(t*11+bf.ph))*2.6+0.6;
|
||
ctx.save();ctx.translate(bf.x,bf.y);ctx.fillStyle=bf.c;
|
||
ctx.beginPath();ctx.ellipse(-flap,0,2.3,3.3,-0.5,0,7);ctx.fill();
|
||
ctx.beginPath();ctx.ellipse(flap,0,2.3,3.3,0.5,0,7);ctx.fill();
|
||
ctx.strokeStyle=INK;ctx.lineWidth=0.9;ctx.lineCap="round";ctx.beginPath();ctx.moveTo(0,-2.4);ctx.lineTo(0,2.4);ctx.stroke();
|
||
ctx.restore();}}}
|
||
|
||
/* ================= INPUT ================= */
|
||
let drag={on:false,moved:false,x:0,y:0,cx:0,cy:0};
|
||
function setupInput(){
|
||
const down=(x,y)=>{drag.on=true;drag.moved=false;drag.x=x;drag.y=y;drag.cx=cam.x;drag.cy=cam.y;ui.hoverX=x;ui.hoverY=y;};
|
||
const move=(x,y)=>{ui.hoverX=x;ui.hoverY=y;if(!drag.on)return;
|
||
if(Math.abs(x-drag.x)+Math.abs(y-drag.y)>6)drag.moved=true;if(!ui.placing){cam.x=drag.cx+(x-drag.x);cam.y=drag.cy+(y-drag.y);}};
|
||
const up=(x,y)=>{if(!drag.on)return;drag.on=false;if(!drag.moved)tap(x,y);};
|
||
cv.addEventListener("mousedown",e=>down(e.clientX,e.clientY));
|
||
window.addEventListener("mousemove",e=>move(e.clientX,e.clientY));
|
||
window.addEventListener("mouseup",e=>up(e.clientX,e.clientY));
|
||
// mouse wheel zoom (desktop) — zoom toward the cursor
|
||
cv.addEventListener("wheel",e=>{e.preventDefault();zoomAt(e.clientX,e.clientY,cam.z*(e.deltaY<0?1.12:0.89));},{passive:false});
|
||
// touch: 1 finger pans/taps, 2 fingers pinch-zoom
|
||
let pinch=null;const tdist=t=>Math.hypot(t[0].clientX-t[1].clientX,t[0].clientY-t[1].clientY);
|
||
const tmid=t=>[(t[0].clientX+t[1].clientX)/2,(t[0].clientY+t[1].clientY)/2];
|
||
cv.addEventListener("touchstart",e=>{if(e.touches.length>=2){pinch={d:tdist(e.touches),z:cam.z};drag.on=false;}
|
||
else{const t=e.touches[0];down(t.clientX,t.clientY);}},{passive:true});
|
||
cv.addEventListener("touchmove",e=>{if(e.touches.length>=2&&pinch){const m=tmid(e.touches);zoomAt(m[0],m[1],pinch.z*tdist(e.touches)/pinch.d);}
|
||
else{const t=e.touches[0];move(t.clientX,t.clientY);}},{passive:true});
|
||
cv.addEventListener("touchend",e=>{if(e.touches.length<2)pinch=null;
|
||
if(e.touches.length===0){const t=e.changedTouches[0];up(t.clientX,t.clientY);}},{passive:true});
|
||
}
|
||
function tap(sx,sy){if(ui.placing){tryPlace(sx,sy);return;}
|
||
// Pick by screen-space proximity to each sprite's DRAWN anchor (not ground-tile distance) so a tap
|
||
// selects whatever you actually pressed on — even when other objects are close by. Everything is scored
|
||
// in pure-iso space (worldToScreen space); radii are in iso px, so picking feels the same at any zoom.
|
||
const z=cam.z||1, px=(sx-cam.x)/z, py=(sy-cam.y)/z, cand=[];
|
||
const add=(gx,gy,ay,rad,kind,obj)=>{const a=worldToScreen(gx,gy);
|
||
const s=Math.hypot(px-a.x,py-(a.y+ay))/rad; if(s<1)cand.push({s,front:gx+gy,kind,obj});};
|
||
for(const m of S.mellows) add(m.gx,m.gy,-14,14,"mel",m); // little bodies drawn at p.y-6, ~24 tall
|
||
for(const w of S.wolves) add(w.gx,w.gy,-7,15,"wolf",w);
|
||
for(const b of S.buildings){const info=BUILD[b.type],sz=info.size||1;
|
||
if(info.dock){for(const t of bldTiles(b.type,b.gx,b.gy,b.dir))add(t[0],t[1],-10,22,"bld",b);continue;}
|
||
if(sz>1){for(const t of bldTiles(b.type,b.gx,b.gy))add(t[0],t[1],-8,26,"bld",b);continue;} // cover a 2×2 plot tile-by-tile
|
||
add(b.gx,b.gy, info.small?-8:-16, info.small?16:23, "bld",b);}
|
||
if(!cand.length)return;
|
||
cand.sort((A,B)=> Math.abs(A.s-B.s)<0.15 ? B.front-A.front : A.s-B.s); // nearest sprite; near-ties → front-most (visually on top)
|
||
const p=cand[0];
|
||
if(p.kind==="mel")openProfile(p.obj); else if(p.kind==="bld")openBuilding(p.obj); else openWolf(p.obj);}
|
||
function openWolf(w){ui.selBld=null;ui.selMel=null;const c=$("bldCard");
|
||
const sex=w.sex==="male"?"♂":"♀",age=wolfBaby(w)?"pup":"adult";
|
||
if(w.state==="family"){const head=S.mellows.find(m=>m.home===w.homeId&&!isChild(m));
|
||
c.innerHTML=`<h2>Family Wolf ${sex}</h2><div class="mini">A loyal ${age} — part of ${head?head.name+"'s family":"a family"}. It guards them (especially the little ones), lifts their spirits, and helps a Shepherd in the family work faster.</div>`;}
|
||
else if(w.state==="trained"||w.state==="training"){
|
||
c.innerHTML=`<h2>${w.state==="trained"?"Trained":"Training"} Wolf ${sex}</h2><div class="mini">A ${age} at the Wolf Den. ${w.state==="training"?"Still learning — ready to adopt "+(wolfAdoptable(w)?"now":"soon")+".":"Ready for a family to adopt."} Use the Wolf Den's <b>Adopt</b> button.</div>`;}
|
||
else{c.innerHTML=`<h2>Wild Wolf ${sex}</h2><div class="mini">A wild ${age} that roams its pack's territory and can harm Mellows — hunters, miners and lumberjacks most of all. Build a <b>Wolf Den</b> and a Wolf Trainer will tame the pack (8 meat each).</div>`;}
|
||
$("bldOverlay").style.display="flex";}
|
||
function tryPlace(sx,sy){const g=screenToGrid(sx,sy),gx=Math.round(g.gx),gy=Math.round(g.gy);
|
||
if(gx<0||gy<0||gx>=GRID||gy>=GRID)return;const type=ui.placing;
|
||
if(BUILD[type].needsCraft&&!S.buildings.some(b=>b.built&&b.type==="craft")){toast("Build a Craft Shop first — boats are made there");return;}
|
||
if(BUILD[type].dock){const dp=dockPlace(gx,gy);
|
||
if(!dp){toast("Tap a water tile next to reachable land");return;}
|
||
const dc=scaledCost(type);for(const k in dc)if((S.res[k]||0)<dc[k]){toast("Not enough "+k);return;}
|
||
for(const k in dc)S.res[k]-=dc[k];const nb=addBuilding(type,dp.gx,dp.gy,false);nb.dir=dp.dir;
|
||
toast("Placed Dock — a Builder will construct it");if(!affordable(type))endPlacing();refreshBuildBar();return;}
|
||
const needWater=BUILD[type].bridge||BUILD[type].boat;
|
||
const tiles=bldTiles(type,gx,gy);
|
||
if(tiles.some(t=>t[0]<0||t[1]<0||t[0]>=GRID||t[1]>=GRID)){toast("It doesn't fit there");return;}
|
||
if(needWater){ // single-tile water structures
|
||
if(S.tiles[gy][gx]!=="water"){toast("Place on water");return;}
|
||
if(tileTaken(gx,gy)){toast("Tile is taken");return;}
|
||
if((BUILD[type].bridge||BUILD[type].boat)&&![[1,0],[-1,0],[0,1],[0,-1]].some(d=>reachable(gx+d[0],gy+d[1]))){toast("Place it beside reachable land");return;}
|
||
} else { // land building — every tile of its footprint must be clear, land & reachable
|
||
for(const [tx,ty] of tiles){
|
||
if(S.tiles[ty][tx]==="water"){toast("Can't build on water");return;}
|
||
if(!reachable(tx,ty)){toast("Your Mellows can't reach there — bridge the water first");return;}
|
||
if(tileTaken(tx,ty)){toast("Not enough room — needs a clear "+(BUILD[type].size||1)+"×"+(BUILD[type].size||1)+" space");return;}
|
||
if(S.nodes.some(n=>n.gx===tx&&n.gy===ty&&!n.dormant&&n.amount>0)){toast("Clear that spot first");return;}
|
||
}
|
||
}
|
||
const cost=scaledCost(type);for(const k in cost)if((S.res[k]||0)<cost[k]){toast("Not enough "+k);return;}
|
||
const deco=!!BUILD[type].decor;
|
||
for(const k in cost)S.res[k]-=cost[k];addBuilding(type,gx,gy,deco); // decor is placed finished (cosmetic, no builder needed)
|
||
if(deco){computeReach();toast("Placed "+BUILD[type].name);} else toast("Placed "+BUILD[type].name+" — a Builder will construct it");
|
||
if(!affordable(type))endPlacing();refreshBuildBar();}
|
||
function scaledCost(type){const base=BUILD[type].cost,m=difficulty().cost,out={};for(const k in base)out[k]=Math.round(base[k]*m);return out;}
|
||
// Building upgrades: production/farm/pasture buildings can be upgraded once for +40% output, marked by a banner.
|
||
const UPGRADE_MULT=1.4;
|
||
function canUpgrade(b){const info=BUILD[b.type];return b.built&&!b.upg&&!!(info.recipe||info.craftTools||info.farm||info.pasture);}
|
||
function upgradeCost(type){const sc=scaledCost(type),out={};for(const k in sc)out[k]=Math.max(1,Math.ceil(sc[k]*0.6));return out;}
|
||
function upgMult(b){return b&&b.upg?UPGRADE_MULT:1;}
|
||
function tryUpgrade(b){const cost=upgradeCost(b.type);
|
||
for(const k in cost)if((S.res[k]||0)<cost[k]){toast("Not enough "+(RES_NAME[k]||k)+" to upgrade");return false;}
|
||
for(const k in cost)S.res[k]-=cost[k];b.upg=true;
|
||
toast("⭐ "+BUILD[b.type].name+" upgraded — +40% output!");logEvent("Upgraded the "+BUILD[b.type].name+" (+40% output).");
|
||
if(ui.sound)Sound.sfx('build');return true;}
|
||
function affordable(type){const c=scaledCost(type);for(const k in c)if((S.res[k]||0)<c[k])return false;return true;}
|
||
|
||
/* ================= UI ================= */
|
||
function $(id){return document.getElementById(id);}
|
||
function buildStartScreen(){
|
||
const grid=$("isleGrid");grid.innerHTML="";let selIsle="meadow",selDiff="Normal";
|
||
for(const k in ISLANDS){const is=ISLANDS[k];const d=document.createElement("div");d.className="isle"+(k===selIsle?" sel":"");
|
||
d.innerHTML=`<canvas width="180" height="116"></canvas><b>${is.name}</b><small>${is.blurb}</small>`;
|
||
d.onclick=()=>{selIsle=k;[...grid.children].forEach(c=>c.classList.remove("sel"));d.classList.add("sel");};
|
||
grid.appendChild(d);drawIslePreview(d.querySelector("canvas"),k);}
|
||
const dr=$("diffRow");dr.innerHTML="";
|
||
for(const k in DIFFS){const d=document.createElement("div");d.className="diff"+(k===selDiff?" sel":"");
|
||
d.innerHTML=`${k}<small>${DIFFS[k].label}</small>`;
|
||
d.onclick=()=>{selDiff=k;[...dr.children].forEach(c=>c.classList.remove("sel"));d.classList.add("sel");};dr.appendChild(d);}
|
||
$("btnStart").onclick=()=>{genWorld(selIsle,selDiff);ui.anim=true;S.anim=true;$("startOverlay").style.display="none";refreshBuildBar();save();
|
||
try{if(!localStorage.getItem("mellow_tut"))showTutorial();}catch(e){}};
|
||
if(localStorage.getItem("mellow_save")){$("btnContinue").style.display="";
|
||
$("btnContinue").onclick=()=>{if(load()){$("startOverlay").style.display="none";refreshBuildBar();}};}
|
||
}
|
||
function drawIslePreview(c,k){const g=c.getContext("2d"),is=ISLANDS[k];
|
||
g.fillStyle=is.accent;g.fillRect(0,0,c.width,c.height);
|
||
const ox=c.width/2,oy=20,tw=26,th=13;
|
||
for(let gy=0;gy<5;gy++)for(let gx=0;gx<5;gx++){const x=ox+(gx-gy)*tw/2,y=oy+(gx+gy)*th/2;
|
||
const top=((gx+gy)&1)?is.grass:is.grass2;
|
||
poly(g,[[x-tw/2,y],[x,y+th/2],[x,y+th/2+4],[x-tw/2,y+4]],dark(is.side,0.05));
|
||
poly(g,[[x+tw/2,y],[x,y+th/2],[x,y+th/2+4],[x+tw/2,y+4]],dark(is.side,0.18));
|
||
poly(g,[[x,y-th/2],[x+tw/2,y],[x,y+th/2],[x-tw/2,y]],top);}
|
||
artTree(g,ox-tw,oy+th*1.6,0.7,false,0.3);artTree(g,ox+tw*1.2,oy+th*2.2,0.6,false,0.7);
|
||
artMellow(g,ox,oy+th*2.4,is===ISLANDS.tundra?"#7d8a92":"#6ab04a","none",{s:0.85});}
|
||
function refreshBuildBar(){ if($("buildOverlay").style.display==="flex") openBuildPanel(); }
|
||
function openBuildPanel(){
|
||
const c=$("buildCard"),vl=villageLevel();
|
||
let html=`<div class="bldTitle">Buildings <span class="vlvl">Lv ${vl} · ${villageTitle()}</span></div>`;
|
||
for(const cat of BUILD_CATS){const list=BUILD_ORDER.filter(t=>BUILD[t].cat===cat);if(!list.length)continue;
|
||
html+=`<div class="catHead">${cat}</div>`;
|
||
for(const type of list){const info=BUILD[type];
|
||
const cost=scaledCost(type),ok=affordable(type);
|
||
const costStr=Object.entries(cost).map(([k,v])=>`<b>${img("res",k,"sm")}${v}</b>`).join("");
|
||
html+=`<div class="bcard"><div class="prev">${img("bld",type,"lg")}</div>
|
||
<div class="binfo"><b>${info.name}</b><div class="bdesc">${info.desc}</div>
|
||
<div class="bcost">Cost ${costStr}</div></div>
|
||
<button class="buildbtn ${ok?'':'no'}" data-type="${type}">Build</button></div>`;}}
|
||
c.innerHTML=html;
|
||
c.querySelectorAll(".buildbtn").forEach(b=>b.onclick=()=>{const t=b.dataset.type;
|
||
if(!affordable(t)){toast("Not enough resources");return;}
|
||
$("buildOverlay").style.display="none";startPlacing(t);});
|
||
$("buildOverlay").style.display="flex";
|
||
}
|
||
function startPlacing(type){if(!affordable(type)){toast("Not enough resources");return;}
|
||
ui.placing=type;$("placeBanner").style.display="flex";$("placeTxt").textContent="Tap a tile to place "+BUILD[type].name;}
|
||
function endPlacing(){ui.placing=null;$("placeBanner").style.display="none";}
|
||
function melPortrait(m){const c=document.createElement("canvas");c.width=74;c.height=82;const g=c.getContext("2d");
|
||
const j=JOBS[m.job];artMellow(g,37,70,j?j.cloak:"#9fb0a0",isChild(m)?"none":(j?j.tool:"none"),{s:isChild(m)?1.4:1.8,face:1});return c.toDataURL();}
|
||
function mellowNeeds(m){const out=[];
|
||
if(!homeValid(m)&&!isChild(m))out.push(["home","Needs a home"]);
|
||
if(isWinter()&&m.warmth<40)out.push(["cold","Is cold"]);
|
||
if(m.hunger<30)out.push(["food","Needs food"]);
|
||
if(m.sick)out.push(["sick","Feeling unwell"]);
|
||
if(!isChild(m)&&m.job==="idle")out.push(["idle","Looking for a job"]);
|
||
if(m.pregnant)out.push(["preg","Expecting a baby"]);
|
||
return out;}
|
||
function villageNeeds(){const ad=S.mellows.filter(m=>!isChild(m)),out=[];
|
||
const homeless=ad.filter(m=>!homeValid(m)).length;if(homeless)out.push(["home",homeless,"need a home"]);
|
||
const idle=ad.filter(m=>m.job==="idle").length;if(idle)out.push(["idle",idle,"are looking for something to do"]);
|
||
const preg=S.mellows.filter(m=>m.pregnant).length;if(preg)out.push(["preg",preg,"are expecting a baby"]);
|
||
const cold=isWinter()?S.mellows.filter(m=>m.warmth<40).length:0;if(cold)out.push(["cold",cold,"are hurt by the cold"]);
|
||
const hungry=S.mellows.filter(m=>m.hunger<30).length;if(hungry)out.push(["food",hungry,"are hungry"]);
|
||
const sick=S.mellows.filter(m=>m.sick).length;if(sick)out.push(["sick",sick,"are unwell"]);
|
||
if(S.res.tools<=0){const nt=ad.filter(m=>["axe","pick","bow","saw","scythe"].includes(JOBS[m.job].tool)).length;if(nt)out.push(["tool",nt,"need new tools"]);}
|
||
return out;}
|
||
function drawPyramid(c2){const g=c2.getContext("2d"),W=c2.width,H=c2.height;g.clearRect(0,0,W,H);
|
||
const buckets=[];for(let a=0;a<65;a+=5)buckets.push([a,a+4]);
|
||
const mC=buckets.map(()=>0),fC=buckets.map(()=>0);
|
||
for(const m of S.mellows){let bi=Math.min(buckets.length-1,Math.floor(m.age/5));if(bi<0)bi=0;if(m.sex==="male")mC[bi]++;else fC[bi]++;}
|
||
const maxC=Math.max(1,...mC,...fC),cx=W/2,labelW=46,barMax=cx-labelW/2-10,rowH=(H-24)/buckets.length;
|
||
g.font="9px sans-serif";g.textBaseline="middle";g.fillStyle="#4a3316";
|
||
g.textAlign="left";g.fillText("Male",6,10);g.textAlign="right";g.fillText("Female",W-6,10);
|
||
for(let i=0;i<buckets.length;i++){const y=H-12-(i+0.5)*rowH,bh=rowH*0.66;
|
||
const mL=mC[i]/maxC*barMax,fL=fC[i]/maxC*barMax;
|
||
g.fillStyle="#5f86bf";g.fillRect(cx-labelW/2-mL,y-bh/2,mL,bh);
|
||
g.fillStyle="#cf7d7d";g.fillRect(cx+labelW/2,y-bh/2,fL,bh);
|
||
g.strokeStyle="#c69a5a";g.lineWidth=1;g.strokeRect(cx-labelW/2,y-rowH*0.42,labelW,rowH*0.84);
|
||
g.fillStyle="#4a3316";g.textAlign="center";g.fillText(buckets[i][0]+"-"+buckets[i][1],cx,y);}}
|
||
function openProfile(m){ui.selMel=m;ui.selBld=null;const c=$("profCard"),j=JOBS[m.job];
|
||
const homeB=S.buildings.find(b=>b.id===m.home),child=isChild(m);
|
||
const bars=[["Health",m.health,"#cf5a5a"],["Happiness",m.happy,"#6ab04a"],["Hunger",m.hunger,"#e0913f"]];
|
||
if(isWinter())bars.push(["Warmth",m.warmth,"#4bb4d6"]);
|
||
bars.push(["Intelligence "+Math.round(m.intel||3)+"/10",(m.intel||3)*10,"#7a8ec9"]);
|
||
const barsHtml=bars.map(([n,v,cc])=>`<div class="mini" style="margin-bottom:1px">${n}</div><div class="statbar"><i style="width:${Math.max(2,v)}%;background:${cc}"></i></div>`).join("");
|
||
const jobs=Object.keys(JOBS).map(jk=>`<div class="jobopt ${jk===m.job?'sel':''}" data-job="${jk}">${img("job",jk)}${JOBS[jk].name}</div>`).join("");
|
||
const needs=mellowNeeds(m);
|
||
const needsHtml=needs.length?needs.map(([k,t])=>`<div class="noticeRow"><img class="ic nb" src="${icon('need',k,40)}"><span>${t}</span></div>`).join(""):`<div class="noticeRow"><span>Content and cared for.</span></div>`;
|
||
let contraHtml="";
|
||
if(!child&&m.sex==="female"){
|
||
if(m.contraT>0){const sl=Math.ceil(m.contraT/difficulty().season);
|
||
contraHtml=`<div class="noticeRow">${img("res","contra","nb")}<span>On contraceptive herb — ${sl} season${sl!==1?"s":""} left</span><button class="btn sec small" id="pContraStop" style="margin-left:auto">Stop</button></div>`;
|
||
} else { const have=(S.res.contra||0);
|
||
contraHtml=`<div class="noticeRow">${img("res","contra","nb")}<span>Contraceptive herb — ${have} in storage (from the Apothecary)</span></div>
|
||
<div class="row" style="margin-top:6px"><button class="btn sec small" id="pContra"${have<=0?" disabled":""}>Give contraceptive herb (lasts 1 yr)</button></div>`;
|
||
}
|
||
}
|
||
const eld=isElder(m),tr=TRAITS[m.trait],mood=moodOf(m),hob=HOBBIES[m.hobby];
|
||
const lineage=m.parents?`Child of ${m.parents.mom} & ${m.parents.dad}${m.gparents&&m.gparents.length?` · grandchild of ${m.gparents.filter(Boolean).join(", ")}`:""}`:"A village founder";
|
||
c.innerHTML=`<h2>${m.name} ${m.surname||""} <span style="font-size:13px;color:var(--ink2);font-weight:normal">${child?"a child":eld?"an elder":(j?j.name:"Nothing")}</span></h2>
|
||
<div class="pinfo"><img class="portrait" src="${melPortrait(m)}">
|
||
<div class="pmeta"><div><b>Age</b> : ${Math.floor(m.age)}${eld?" (elder)":""}</div><div><b>Sex</b> : ${m.sex==="male"?"Male":"Female"}</div>
|
||
<div><b>Job</b> : ${child?"—":(j?j.name:"Unassigned")}</div><div><b>Home</b> : ${homeB?BUILD[homeB.type].name:"none"}</div>
|
||
<div><b>Intelligence</b> : ${Math.round(m.intel||3)} / 10</div>
|
||
<div><b>Trait</b> : ${tr?tr.name:"—"}</div>
|
||
<div><b>Hobby</b> : ${hob?hob.emoji+" "+hob.label:"—"}</div>
|
||
${child?`<div><b>School</b> : ${m.age<6?"too young (starts at 6)":!schooling()?"no schoolhouse":schoolInSession()?"in session":"off until fall"}</div>`:""}</div></div>
|
||
<div class="mini" style="text-align:center">Mood: ${mood.emoji} ${mood.label}${hob&&hob.job&&hob.job===m.job?` · doing what they love ${hob.emoji}`:""}</div>
|
||
${tr?`<div class="mini" style="text-align:center">${tr.desc}${eld?" · An elder — assign as Teacher to mentor children faster.":""}</div>`:""}
|
||
<div class="mini" style="text-align:center">${lineage}</div>
|
||
${child?`<div class="mini" style="text-align:center">Lives with ${S.mellows.filter(x=>x.home===m.home&&!isChild(x)).map(a=>a.name).join(" & ")||"their family"}</div>`
|
||
:`<div class="mini" style="text-align:center">Partner: <b>${partnerOf(m)?partnerOf(m).name:"—"}</b>${childrenOf(m).length?" · Children: "+childrenOf(m).map(k=>k.name).join(", "):""}</div>
|
||
<div class="mini" style="text-align:center">Friends: <b>${friendNames(m)||"—"}</b></div>`}
|
||
<h3 style="text-align:center">Status</h3>${barsHtml}
|
||
${child?'<div class="mini" style="text-align:center">Still growing — will take on a job at age 12.</div>':`<h3>Assign job</h3><div class="jobgrid">${jobs}</div>`}
|
||
<h3 style="text-align:center">Notice</h3>${needsHtml}
|
||
${contraHtml}
|
||
<div class="row" style="margin-top:12px"><button class="btn sec small" id="pRename">Rename</button><button class="btn sec small" id="pReset">Send home</button>
|
||
${child||m.job==="idle"?"":'<button class="btn sec small" id="pIdle">Take off job</button>'}</div>`;
|
||
$("pRename").onclick=()=>{const nm=(prompt("Rename this Mellow:",m.name||"")||"").replace(/[<>]/g,"").trim();if(nm){m.name=nm.slice(0,18);openProfile(m);}};
|
||
c.querySelectorAll(".jobopt").forEach(el=>el.onclick=()=>{m.job=el.dataset.job;m.state="idle";m.target=null;m.timer=0;openProfile(m);refreshBuildBar();});
|
||
$("pReset").onclick=()=>{const h=S.buildings.find(b=>b.id===m.home)||S.buildings[0];m.gx=h.gx;m.gy=h.gy;m.tx=h.gx;m.ty=h.gy;m.state="idle";m.target=null;toast(m.name+" sent home");};
|
||
const idl=$("pIdle");if(idl)idl.onclick=()=>{m.job="idle";m.state="idle";m.target=null;toast(m.name+" is now free");openProfile(m);};
|
||
const pc=$("pContra");if(pc)pc.onclick=()=>{if((S.res.contra||0)<=0){toast("No contraceptive herbs — staff an Apothecary");return;}S.res.contra--;m.contraT=4*difficulty().season;m.pregnant=false;m.pregT=0;toast(m.name+" took a contraceptive herb");openProfile(m);};
|
||
const pcs=$("pContraStop");if(pcs)pcs.onclick=()=>{m.contraT=0;toast(m.name+" stopped the contraceptive");openProfile(m);};
|
||
$("profOverlay").style.display="flex";}
|
||
function closeProfile(){$("profOverlay").style.display="none";ui.selMel=null;}
|
||
function openBuilding(b){ui.selBld=b;ui.selMel=null;const info=BUILD[b.type],c=$("bldCard");
|
||
let body=`<h2>${img("bld",b.type,'lg')} ${info.name}</h2>`;
|
||
if(!b.built){body+=`<div class="mini">Under construction — ${Math.round(b.prog*100)}%</div>
|
||
<div class="statbar"><i style="width:${Math.max(2,b.prog*100)}%;background:#6ab04a"></i></div>
|
||
<div class="hint">Assign a Mellow to <b>Builder</b> to finish it.</div>`;}
|
||
else{if(info.job){const workers=S.mellows.filter(m=>m.job===info.job&&!isChild(m));
|
||
body+=`<div class="mini">Needs a <b>${JOBS[info.job].name}</b> to run.</div>
|
||
<div class="mini">Working here: ${workers.length?workers.map(w=>w.name).join(", "):"nobody"}</div>`;
|
||
if(info.craftTools){const sel=b.makeTool||"axe";
|
||
body+=`<div class="mini">Choose what to craft — make more of whatever you're low on:</div><div class="toolPick">`;
|
||
for(const tk of TOOL_ORDER){const t=TOOLS[tk];const costS=Object.entries(t.cost).map(([k,v])=>img("res",k,"sm")+v).join(" ");
|
||
body+=`<div class="tpick ${tk===sel?'sel':''}" data-tool="${tk}">${img("res",tk,"")}<b>${t.name}</b><span class="tcost">${costS}</span><span class="tcost">have ${S.res[tk]||0}</span></div>`;}
|
||
body+=`</div>`;}
|
||
else if(info.recipe){const r=info.recipe;const inS=Object.entries(r.in).map(([k,v])=>img("res",k,"sm")+v).join(" ");
|
||
const outS=Object.entries(r.out).map(([k,v])=>img("res",k,"sm")+v).join(" ");body+=`<div class="mini">Recipe: ${inS} → ${outS}</div>`;}
|
||
if(info.wolfcentre){const list=facilityWolves(b.id),mode=b.mode||"train";
|
||
const training=list.filter(w=>w.state==="training"),babies=list.filter(wolfBaby),males=list.filter(w=>w.sex==="male");
|
||
const ready=list.filter(wolfAdoptable);
|
||
body+=`<div class="mini" style="text-align:center">Housed <b>${list.length}</b>/${FACILITY_CAP} · Training <b>${training.length}</b> · Ready <b>${ready.length}</b></div>`;
|
||
body+=`<div class="mini" style="text-align:center">♂ ${males.length} · ♀ ${list.length-males.length} · ${babies.length} pup${babies.length===1?"":"s"} · ${list.length-babies.length} adult${list.length-babies.length===1?"":"s"}</div>`;
|
||
body+=`<div class="toolPick"><div class="tpick ${mode==="train"?'sel':''}" data-mode="train"><b>Train + House</b><span class="tcost">tame & raise</span></div>
|
||
<div class="tpick ${mode==="house"?'sel':''}" data-mode="house"><b>House only</b><span class="tcost">no new taming</span></div></div>`;
|
||
body+=`<button class="btn small ${ready.length?'':'no'}" id="adoptWolf" style="margin-top:6px">Adopt a wolf → a family</button>`;}
|
||
body+=`<button class="btn small" id="assignJob" style="margin-top:8px">Assign an idle Mellow here</button>`;}
|
||
else if(info.house){body+=`<div class="mini">Shelters ${info.house} Mellows${info.warm?" · extra warm in winter":""}.</div>`;
|
||
const occ=S.mellows.filter(m=>m.home===b.id),ad=occ.filter(m=>!isChild(m)),kd=occ.filter(m=>isChild(m));
|
||
if(occ.length){body+=`<h3 style="text-align:center">Who lives here</h3>`;
|
||
body+=ad.map(m=>`<div class="noticeRow resRow" data-id="${m.id}" style="cursor:pointer"><img class="ic nb" src="${melPortrait(m)}"><span><b>${m.name}</b> — ${JOBS[m.job]?JOBS[m.job].name:"Unassigned"}${m.pregnant?" · expecting":""}${m.contraT>0?" · on contraceptive":""}</span></div>`).join("");
|
||
body+=kd.map(m=>`<div class="noticeRow resRow" data-id="${m.id}" style="cursor:pointer"><img class="ic nb" src="${melPortrait(m)}"><span><b>${m.name}</b> — child, age ${Math.floor(m.age)}</span></div>`).join("");
|
||
} else body+=`<div class="mini" style="text-align:center">Empty — nobody has moved in yet.</div>`;}
|
||
else if(info.warmth){body+=`<div class="mini">Keeps Mellows warm in winter (burns firewood).</div>`;}
|
||
else if(info.store){body+=`<div class="mini" style="text-align:center">What the village has stored${totalFood()?` · food ${totalFood()}`:""}</div><div class="resgrid">`;
|
||
for(const r of RES_ORDER){const v=S.res[r]||0;const nm=RES_NAME[r]||(r[0].toUpperCase()+r.slice(1));
|
||
body+=`<div class="ritem">${img("res",r,"")}<span>${nm}</span><b>${v}</b></div>`;}
|
||
body+=seedRow()+`</div>`;}
|
||
else if(info.farm){const cur=b.crop||"crops",stage=b.stage||"empty",isWin=season()==="Winter",canChoose=isWin&&stage!=="growing";S.seeds=S.seeds||startSeeds();
|
||
const statusTxt=stage==="growing"
|
||
?`🌱 Growing <b>${CROPS[b.growing||cur].name}</b> — ripens over Summer, harvested in Fall`
|
||
:isWin?`❄ Winter — choose the crop to sow next Spring`
|
||
:`Will sow <b>${CROPS[cur].name}</b> in Spring (${PLANT_COST} seeds → ${PLANT_YIELD})`;
|
||
body+=`<div class="mini" style="text-align:center">${statusTxt}</div>`;
|
||
body+=`<div class="mini">${canChoose?`Pick what to plant — ${PLANT_COST} seeds of one kind yields ${PLANT_YIELD}:`:`Crop: <b>${CROPS[cur].name}</b> — the crop can only be changed in <b>Winter</b>, before spring planting.`}</div><div class="toolPick">`;
|
||
for(const c of CROP_ORDER){const have=S.seeds[c]||0,enough=have>=PLANT_COST;
|
||
body+=`<div class="tpick ${c===cur?'sel':''} ${canChoose&&enough?'':'no'}" ${canChoose?`data-crop="${c}"`:''}>${img("res",c,"")}<b>${CROPS[c].name}</b><span class="tcost">${have} seeds</span></div>`;}
|
||
body+=`</div><div class="mini" style="text-align:center">Sown in <b>Spring</b>, grows through <b>Summer</b>, harvested in <b>Fall</b>. Needs a <b>Farmer</b>.</div>`;}
|
||
else if(info.pasture){const animal=b.animal||"sheep",herd=b.herd||2;
|
||
body+=`<div class="mini" style="text-align:center"><b>${animal==="sheep"?"Sheep":"Deer"} Pasture</b> — herd <b>${herd}</b>/${HERD_CAP}</div>`;
|
||
body+=`<div class="mini">Choose what to raise (they don't mix):</div><div class="toolPick">`;
|
||
for(const a of ["sheep","deer"]){body+=`<div class="tpick ${a===animal?'sel':''}" data-animal="${a}">${img("res",a==="sheep"?"wool":"meat","")}<b>${a==="sheep"?"Sheep":"Deer"}</b><span class="tcost">${a==="sheep"?"wool + meat":"meat + leather"}</span></div>`;}
|
||
body+=`</div><div class="mini" style="text-align:center">Assign a <b>Shepherd</b>; the herd grows each season.</div>`;}
|
||
else if(info.cemetery){const mem=S.memorial||[];
|
||
body+=`<div class="mini">A quiet resting place. ${mem.length?mem.length+" laid to rest.":"No one has passed yet."}</div>`;
|
||
if(mem.length){body+=`<h3 style="text-align:center">In Memoriam</h3>`;
|
||
body+=mem.slice(0,40).map(e=>`<div class="noticeRow"><img class="ic nb" src="${icon('bld','cemetery',40)}"><span><b>${e.name} ${e.surname||""}</b> — ${e.why}, age ${e.age}</span></div>`).join("");}}
|
||
else if(info.decor){body+=`<div class="mini" style="text-align:center">${info.desc}</div>
|
||
<div class="mini" style="text-align:center">✨ Decorations make ${S.villageName||"the village"} lovelier — a little happiness for everyone.</div>`;}}
|
||
if(b.built&&(info.recipe||info.craftTools||info.farm||info.pasture)){
|
||
if(b.upg)body+=`<div class="mini" style="text-align:center;color:#c98a1a">⭐ Upgraded — producing +40%.</div>`;
|
||
else{const uc=upgradeCost(b.type),ucS=Object.entries(uc).map(([k,v])=>img("res",k,"sm")+v).join(" ");
|
||
body+=`<button class="btn small" id="upgradeBld" style="margin-top:8px">⭐ Upgrade · +40% output ${ucS}</button>`;}}
|
||
body+=`<button class="btn sec small" id="demolish" style="margin-top:10px">Demolish</button>`;c.innerHTML=body;
|
||
c.querySelectorAll(".resRow").forEach(el=>el.onclick=()=>{const mm=S.mellows.find(x=>x.id==el.dataset.id);if(mm){closeBuilding();openProfile(mm);}});
|
||
c.querySelectorAll(".tpick[data-tool]").forEach(el=>el.onclick=()=>{b.makeTool=el.dataset.tool;openBuilding(b);});
|
||
c.querySelectorAll(".tpick[data-crop]").forEach(el=>el.onclick=()=>{b.crop=el.dataset.crop;openBuilding(b);});
|
||
c.querySelectorAll(".tpick[data-animal]").forEach(el=>el.onclick=()=>{b.animal=el.dataset.animal;openBuilding(b);});
|
||
c.querySelectorAll(".tpick[data-mode]").forEach(el=>el.onclick=()=>{b.mode=el.dataset.mode;openBuilding(b);});
|
||
{const ab=$("adoptWolf");if(ab)ab.onclick=()=>{adoptFromCentre(b);openBuilding(b);};}
|
||
{const ub=$("upgradeBld");if(ub)ub.onclick=()=>{if(tryUpgrade(b)){openBuilding(b);refreshBuildBar();}};}
|
||
const aj=$("assignJob");if(aj)aj.onclick=()=>{const idle=S.mellows.find(m=>m.job==="idle"&&!isChild(m))||S.mellows.find(m=>JOBS[m.job].kind==="gather"&&!isChild(m));
|
||
if(idle){idle.job=info.job;idle.state="idle";idle.target=null;toast(idle.name+" now works the "+info.name);openBuilding(b);refreshBuildBar();}else toast("No spare Mellow — free one up first");};
|
||
let confirmDemo=false;
|
||
$("demolish").onclick=()=>{if(S.buildings.length<=1){toast("Keep at least one building");return;}
|
||
if(!confirmDemo){confirmDemo=true;$("demolish").textContent="Really demolish? (salvages ~half)";
|
||
setTimeout(()=>{confirmDemo=false;const d=$("demolish");if(d)d.textContent="Demolish";},3000);return;}
|
||
const cost=scaledCost(b.type);for(const k in cost)S.res[k]=(S.res[k]||0)+Math.floor(cost[k]*0.5);
|
||
S.buildings=S.buildings.filter(x=>x!==b);computeReach();toast("Demolished "+info.name+" — materials salvaged");closeBuilding();refreshBuildBar();};
|
||
$("bldOverlay").style.display="flex";}
|
||
function closeBuilding(){$("bldOverlay").style.display="none";ui.selBld=null;}
|
||
const TUTORIAL=[
|
||
{t:"Welcome!",b:"This is your village. Guide your Mellows to gather food, build homes and grow a thriving town — through every season."},
|
||
{t:"Give them jobs",b:"Tap a Mellow, then choose a job. Gatherers collect food & materials and drop them at Storage. Start with food — a Fruit Picker or Hunter."},
|
||
{t:"Build things",b:"Tap the <b>Build</b> button, pick a building, then tap a tile. Keep at least one Mellow on <b>Builder</b> so construction finishes."},
|
||
{t:"Keep them fed",b:"Hunger falls over time — always keep food in stock. A <b>Kitchen</b> turns raw food into filling meals."},
|
||
{t:"Survive winter",b:"In winter, plants stop growing and it gets cold. Stock <b>firewood</b> (from a Sawmill) and build a <b>Campfire</b>, or Mellows lose warmth."},
|
||
{t:"Grow your village",b:"Couples in houses have babies. Every building is available from the start — but the fancier ones need better materials like <b>planks</b> (from a Sawmill) and <b>iron</b> (mined from ore). Have fun!"}];
|
||
function showTutorial(){let i=0;const ov=document.createElement("div");ov.className="overlay";ov.id="tutOverlay";ov.style.display="flex";
|
||
function close(){try{localStorage.setItem("mellow_tut","1");}catch(e){}ov.remove();}
|
||
function render(){const s=TUTORIAL[i];
|
||
ov.innerHTML=`<div class="card parch" style="max-width:340px;text-align:center">
|
||
<div style="font-size:12px;color:var(--ink2)">Step ${i+1} of ${TUTORIAL.length}</div>
|
||
<h2>${s.t}</h2><div class="hint"><p>${s.b}</p></div>
|
||
<div class="row" style="justify-content:space-between;align-items:center;margin-top:10px">
|
||
<button class="btn sec small" id="tutSkip">Skip</button>
|
||
<div class="row" style="gap:6px">${i>0?'<button class="btn sec small" id="tutBack">Back</button>':''}<button class="btn" id="tutNext">${i<TUTORIAL.length-1?"Next":"Start playing"}</button></div>
|
||
</div></div>`;
|
||
$("tutNext").onclick=()=>{if(i<TUTORIAL.length-1){i++;render();}else close();};
|
||
const bk=$("tutBack");if(bk)bk.onclick=()=>{i--;render();};
|
||
$("tutSkip").onclick=close;}
|
||
document.body.appendChild(ov);render();}
|
||
function openJobs(){const c=$("jobsCard");
|
||
c.innerHTML=`<div class="vhead"><div><b id="vnameTxt">${S.villageName||"Our Village"}</b><div class="vsub">a ${villageTitle()} · Level ${villageLevel()} · ${S.mellows.length} Mellows</div></div><button class="btn sec small" id="vnameEdit">Rename</button></div>
|
||
<div class="vtabs"><div class="vtab on" id="tabNotice">Notice</div><div class="vtab" id="tabGoals">Goals</div><div class="vtab" id="tabPop">Population</div><div class="vtab" id="tabMile">Milestones</div></div>
|
||
<div id="vNotice"></div><div id="vGoals" style="display:none"></div><div id="vPop" style="display:none"></div><div id="vMile" style="display:none"></div>`;
|
||
$("vnameEdit").onclick=()=>{const nm=(prompt("Name your village:",S.villageName||"")||"").replace(/[<>]/g,"").trim();if(nm){S.villageName=nm.slice(0,24);openJobs();}};
|
||
function renderMile(){S.achieved=S.achieved||[];
|
||
$("vMile").innerHTML=MILESTONES.map(ms=>{const got=S.achieved.includes(ms.id);
|
||
return `<div class="noticeRow" style="opacity:${got?1:0.5}"><span style="font-size:16px">${got?"🏆":"○"}</span><span><b>${ms.name}</b><br><small style="color:var(--ink2)">${ms.desc}</small></span></div>`;}).join("");}
|
||
function renderGoals(){S.quests=S.quests||[];let nextShown=false;
|
||
const done=S.quests.length,tot=QUESTS.length;
|
||
const rows=QUESTS.map(q=>{const got=S.quests.includes(q.id);const isNext=!got&&!nextShown;if(isNext)nextShown=true;
|
||
return `<div class="noticeRow" style="opacity:${got?1:isNext?1:0.55};${isNext?'background:#fff3d6;border-radius:8px':''}">
|
||
<span style="font-size:16px">${got?"✅":isNext?"➡️":"○"}</span>
|
||
<span><b>${q.name}</b>${got?"":`<br><small style="color:var(--ink2)">${q.hint}</small>`}</span></div>`;}).join("");
|
||
$("vGoals").innerHTML=`<div class="mini" style="text-align:center;margin-bottom:6px">${done} of ${tot} goals complete · each gives +8 happiness</div>${rows}`;}
|
||
function renderNotice(){const nd=villageNeeds();
|
||
$("vNotice").innerHTML=nd.length?nd.map(([k,n,t])=>`<div class="noticeRow"><img class="ic nb" src="${icon('need',k,40)}"><span><b class="num">${n}</b> ${n===1?'Mellow':'Mellows'} ${t}</span></div>`).join("")
|
||
:`<div class="noticeRow"><span>All is well in the village.</span></div>`;}
|
||
function renderPop(){const males=S.mellows.filter(m=>m.sex==="male").length,females=S.mellows.length-males,kids=S.mellows.filter(isChild).length,preg=S.mellows.filter(m=>m.pregnant).length;
|
||
$("vPop").innerHTML=`<div class="pmeta" style="text-align:center;margin-bottom:8px;font-size:13px">${S.mellows.length} Mellows · ${males} male · ${females} female<br>${kids} ${kids===1?'child':'children'} · ${preg} expecting</div>
|
||
<canvas id="pyr" width="360" height="300" style="width:100%;background:#fff8ea;border:2px solid #c69a5a;border-radius:10px"></canvas>`;
|
||
drawPyramid($("pyr"));}
|
||
const tabs={tabNotice:"vNotice",tabGoals:"vGoals",tabPop:"vPop",tabMile:"vMile"};
|
||
const show=(on,fn)=>{for(const t in tabs){$(t).classList.toggle("on",t===on);$(tabs[t]).style.display=t===on?"":"none";}fn();};
|
||
$("tabNotice").onclick=()=>show("tabNotice",renderNotice);
|
||
$("tabGoals").onclick=()=>show("tabGoals",renderGoals);
|
||
$("tabPop").onclick=()=>show("tabPop",renderPop);
|
||
$("tabMile").onclick=()=>show("tabMile",renderMile);
|
||
renderNotice();$("jobsOverlay").style.display="flex";}
|
||
function ordinal(n){const s=["th","st","nd","rd"],v=n%100;return n+(s[(v-20)%10]||s[v]||s[0]);}
|
||
function openResources(){const c=$("resCard");
|
||
const males=S.mellows.filter(m=>m.sex==="male").length,females=S.mellows.length-males;
|
||
let html=`<h2>Storage</h2><div class="mini">${S.mellows.length} Mellows · ${males} male · ${females} female · food ${totalFood()}</div><div class="resgrid">`;
|
||
for(const r of RES_ORDER){const v=S.res[r]||0;const nm=RES_NAME[r]||(r[0].toUpperCase()+r.slice(1));
|
||
html+=`<div class="ritem">${img("res",r,"")}<span>${nm}</span><b>${v}</b></div>`;}
|
||
c.innerHTML=html+seedRow()+`</div>`;$("resOverlay").style.display="flex";}
|
||
// one aggregate Seeds row for the Items/Storage views (per-type breakdown lives in the Farm panel)
|
||
function seedRow(){const t=S.seeds?Object.values(S.seeds).reduce((a,b)=>a+b,0):0;
|
||
return `<div class="ritem">${img("res","seed","")}<span>Seeds</span><b>${t}</b></div>`;}
|
||
let _hudT=0,_resIcoSet=false;
|
||
function updateHUD(dt){_hudT-=dt||0;if(_hudT>0)return;_hudT=0.25;
|
||
$("seasonIco").src=icon("season",season(),32);$("seasonName").textContent=season();
|
||
$("dayNum").textContent=ordinal(S.seasonCount)+" Season";
|
||
$("tempTxt").textContent=(SEASON_TEMP[season()]+(ISLANDS[S.island].coldland?-6:0))+"°";
|
||
if(!_resIcoSet){$("resIco").src=icon("res","wood",40);_resIcoSet=true;}
|
||
if($("resOverlay").style.display==="flex")openResources();}
|
||
|
||
/* ================= SOUND (synth, no assets) ================= */
|
||
const Sound=(function(){let ctx=null,master=null,amb=null,on=false;
|
||
function ensure(){if(ctx)return;const AC=window.AudioContext||window.webkitAudioContext;if(!AC)return;
|
||
ctx=new AC();master=ctx.createGain();master.gain.value=0.5;master.connect(ctx.destination);}
|
||
function blip(freq,dur,type,vol,slideTo){if(!ctx)return;try{const t0=ctx.currentTime,o=ctx.createOscillator(),g=ctx.createGain();
|
||
o.type=type||"sine";o.frequency.setValueAtTime(freq,t0);if(slideTo)o.frequency.exponentialRampToValueAtTime(slideTo,t0+dur);
|
||
g.gain.setValueAtTime(0.0001,t0);g.gain.exponentialRampToValueAtTime(vol||0.2,t0+0.02);
|
||
g.gain.exponentialRampToValueAtTime(0.0001,t0+dur);o.connect(g);g.connect(master);o.start(t0);o.stop(t0+dur+0.03);}catch(e){}}
|
||
const SFX={
|
||
build:()=>{blip(180,0.13,"triangle",0.24);setTimeout(()=>blip(135,0.15,"triangle",0.2),95);},
|
||
birth:()=>{blip(523,0.12,"sine",0.22);setTimeout(()=>blip(784,0.18,"sine",0.2),110);},
|
||
festival:()=>[523,659,784,1047].forEach((f,i)=>setTimeout(()=>blip(f,0.2,"triangle",0.18),i*95)),
|
||
milestone:()=>[659,988].forEach((f,i)=>setTimeout(()=>blip(f,0.22,"sine",0.2),i*130)),
|
||
arrive:()=>blip(420,0.12,"sine",0.18,640),
|
||
death:()=>blip(300,0.5,"sine",0.16,150),
|
||
birds:()=>[880,1175,988,1318].forEach((f,i)=>setTimeout(()=>blip(f,0.08,"sine",0.05),i*95)), // a little daytime birdsong
|
||
cricket:()=>{for(let i=0;i<3;i++)setTimeout(()=>blip(2550,0.016,"square",0.024),i*75);}}; // a nighttime cricket chirp
|
||
function startAmb(){if(!ctx||amb)return;try{const o=ctx.createOscillator(),o2=ctx.createOscillator(),g=ctx.createGain();
|
||
o.type="sine";o2.type="sine";o.frequency.value=98;o2.frequency.value=147;g.gain.value=0.0;
|
||
o.connect(g);o2.connect(g);g.connect(master);o.start();o2.start();
|
||
g.gain.linearRampToValueAtTime(0.03,ctx.currentTime+3);amb={o,o2,g};}catch(e){}}
|
||
function stopAmb(){if(!amb)return;const a=amb;amb=null;try{a.g.gain.linearRampToValueAtTime(0,ctx.currentTime+0.5);
|
||
setTimeout(()=>{try{a.o.stop();a.o2.stop();}catch(e){}},600);}catch(e){}}
|
||
return {set(v){on=v;if(on){ensure();if(ctx&&ctx.state==="suspended")ctx.resume();startAmb();}else stopAmb();},
|
||
sfx(n){if(!on)return;ensure();if(ctx&&ctx.state==="suspended")ctx.resume();if(SFX[n])SFX[n]();}};
|
||
})();
|
||
|
||
/* ================= SAVE ================= */
|
||
function save(){if(!S)return;try{S.anim=ui.anim;S.sound=ui.sound;localStorage.setItem("mellow_save",JSON.stringify(S));}catch(e){}}
|
||
function load(){try{const raw=localStorage.getItem("mellow_save");if(!raw)return false;S=JSON.parse(raw);
|
||
if(S.seasonCount==null)S.seasonCount=1;if(S.res.contra==null)S.res.contra=0;
|
||
if(S.graves==null)S.graves=[];if(S.memorial==null)S.memorial=[];if(S.festT==null)S.festT=0;if(S.festKind==null)S.festKind="confetti";if(S.weather==null)S.weather="clear";if(S.vlevel==null)S.vlevel=villageLevel();if(S.villageName==null)S.villageName=randomVillageName();S._weddingsInit=false;
|
||
if(S.achieved==null)S.achieved=[];if(S.quests==null)S.quests=[];
|
||
if(S.newcomers==null)S.newcomers=[];if(S._nextNewcomer==null)S._nextNewcomer=S.seasonCount+2;
|
||
for(const m of S.newcomers){m.newcomer=true;m._pg=undefined;m._path=null;}
|
||
overShown=false;cam.z=1;
|
||
if(S.seeds==null)S.seeds=startSeeds();for(const c of CROP_ORDER){if(S.seeds[c]==null)S.seeds[c]=0;if(S.res[c]==null)S.res[c]=0;}
|
||
if(S.treeRegrow==null)S.treeRegrow=0;
|
||
for(const b of S.buildings)if(BUILD[b.type]&&BUILD[b.type].dock&&b.dir==null)b.dir=0;
|
||
S.dens=S.dens||[];S.wolves=S.wolves||[]; // wolf model migration (old saves had {tamed})
|
||
for(const w of S.wolves){if(w.sex==null)w.sex=Math.random()<0.5?"male":"female";
|
||
if(w.state==null)w.state="wild";if(w.bornAt==null)w.bornAt=(S.tsec||0)-difficulty().season*2;
|
||
if(w.packId==null)w.packId=0;if(w.homeId===undefined)w.homeId=null;if(w.facId===undefined)w.facId=null;
|
||
if(w.trainT==null)w.trainT=0;if(w.atkCd==null)w.atkCd=0;if(w.bornTame==null)w.bornTame=false;if(w.face==null)w.face=1;delete w.tamed;}
|
||
if(S.dens.length<1&&S.wolves.length)S.dens.push({gx:S.wolves[0].gx|0,gy:S.wolves[0].gy|0});
|
||
while(S.dens.length<2)S.dens.push({gx:(GRID*0.7)|0,gy:(GRID*0.3)|0});
|
||
for(const m of S.mellows){m.target=null;
|
||
if(m.intel==null)m.intel=m.edu!=null?Math.max(1,Math.min(10,Math.round(1+m.edu*9))):(m.age<12?2:(3+(Math.random()*3|0)));
|
||
if(m.contraT==null)m.contraT=0;
|
||
if(m.friends==null)m.friends=[];
|
||
if(m.surname==null)m.surname=SURNAMES[(Math.random()*SURNAMES.length)|0];
|
||
if(m.trait==null)m.trait=pickTrait();
|
||
if(m.hobby==null)m.hobby=pickHobby();
|
||
if(m.parents===undefined)m.parents=null;if(m.gparents==null)m.gparents=[];
|
||
if(m.momId===undefined)m.momId=null;if(m.dadId===undefined)m.dadId=null;}
|
||
ui.anim=S.anim!==false;ui.sound=!!S.sound;$("swAnim").classList.toggle("on",ui.anim);$("swSound").classList.toggle("on",ui.sound);
|
||
if(ui.sound)Sound.set(true);
|
||
computeReach();centerCamera(GRID/2,GRID/2);refreshBuildBar();if((S.newcomers||[]).length)showNewcomerPopup();return true;}catch(e){return false;}}
|
||
|
||
/* ================= BOOT ================= */
|
||
function loop(t){requestAnimationFrame(loop);if(!lastT)lastT=t;let dt=(t-lastT)/1000;lastT=t;if(dt>0.1)dt=0.1;
|
||
if(S&&$("startOverlay").style.display==="none"){step(dt*(ui.speed==null?1:ui.speed));render();updateHUD(dt);
|
||
S._saveT=(S._saveT||0)+dt;if(S._saveT>5){S._saveT=0;save();}}}
|
||
function boot(){cv=$("cv");ctx=cv.getContext("2d");resize();
|
||
window.addEventListener("resize",()=>{resize();if(S)centerCamera(GRID/2,GRID/2);});
|
||
setupInput();buildStartScreen();
|
||
$("buildIco").src=icon("bld","craft",40);
|
||
$("btnBuild").onclick=()=>{if(S)openBuildPanel();};
|
||
$("buildClose").onclick=()=>$("buildOverlay").style.display="none";
|
||
$("btnRes").onclick=()=>{if(S)openResources();};
|
||
$("resClose").onclick=()=>$("resOverlay").style.display="none";
|
||
$("btnJobs").onclick=()=>{if(S)openJobs();};
|
||
$("btnHelp").onclick=()=>$("helpOverlay").style.display="flex";
|
||
$("btnSettings").onclick=()=>$("setOverlay").style.display="flex";
|
||
{const SPD=[1,2,3,0],SPDL=["1×","2×","3×","❚❚"];$("speedBtn").onclick=()=>{let i=(SPD.indexOf(ui.speed)+1)%SPD.length;ui.speed=SPD[i];$("speedBtn").textContent=SPDL[i];};}
|
||
$("placeCancel").onclick=endPlacing;$("profClose").onclick=closeProfile;$("bldClose").onclick=closeBuilding;
|
||
$("jobsClose").onclick=()=>$("jobsOverlay").style.display="none";$("setClose").onclick=()=>$("setOverlay").style.display="none";
|
||
$("helpClose").onclick=()=>$("helpOverlay").style.display="none";$("helpOk").onclick=()=>$("helpOverlay").style.display="none";
|
||
$("helpTut").onclick=()=>{$("helpOverlay").style.display="none";showTutorial();};
|
||
["buildOverlay","resOverlay","profOverlay","bldOverlay","jobsOverlay","setOverlay","helpOverlay"].forEach(id=>{
|
||
const o=$(id);o.addEventListener("click",e=>{if(e.target===o){o.style.display="none";
|
||
if(id==="profOverlay")ui.selMel=null;if(id==="bldOverlay")ui.selBld=null;}});});
|
||
$("swAnim").onclick=()=>{ui.anim=!ui.anim;$("swAnim").classList.toggle("on",ui.anim);};
|
||
$("swSound").onclick=()=>{ui.sound=!ui.sound;$("swSound").classList.toggle("on",ui.sound);Sound.set(ui.sound);};
|
||
$("btnSave").onclick=()=>{save();toast("Saved");};
|
||
$("btnQuit").onclick=()=>{save();$("setOverlay").style.display="none";$("startOverlay").style.display="flex";buildStartScreen();};
|
||
$("overRestart").onclick=()=>{$("overOverlay").style.display="none";overShown=false;ui.speed=1;$("speedBtn").textContent="1×";genWorld(S.island,S.diff);refreshBuildBar();save();};
|
||
$("overMenu").onclick=()=>{$("overOverlay").style.display="none";overShown=false;ui.speed=1;$("speedBtn").textContent="1×";localStorage.removeItem("mellow_save");$("startOverlay").style.display="flex";buildStartScreen();};
|
||
requestAnimationFrame(loop);}
|
||
boot();
|
||
</script>
|
||
<script>
|
||
/* register the service worker for offline / installable app */
|
||
if('serviceWorker' in navigator){window.addEventListener('load',function(){navigator.serviceWorker.register('sw.js').catch(function(){});});}
|
||
</script>
|
||
</body>
|
||
</html>
|