Download world-builder.html from Azivandor/Games: direct link, hf CLI and curl.
- Browser
- Download file 42.6 kB
-
https://huggingface.co/spaces/Azivandor/Games/resolve/main/world-builder.html
- Command line
-
hf download hf://spaces/Azivandor/Games/world-builder.html
-
curl -L -o world-builder.html https://huggingface.co/spaces/Azivandor/Games/resolve/main/world-builder.html
42.6 kB
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <title>World Builder</title> | |
| <style> | |
| * { -webkit-tap-highlight-color: transparent; user-select: none; box-sizing: border-box; } | |
| html, body { | |
| margin: 0; padding: 0; min-height: 100%; | |
| background: radial-gradient(circle at 50% 0%, #1a2436, #0a0e16 70%); | |
| display: flex; align-items: center; justify-content: center; | |
| font-family: 'Segoe UI', Arial, sans-serif; | |
| } | |
| #wrap { text-align: center; padding: 10px 0; } | |
| #lockOverlay { | |
| position: fixed; inset: 0; z-index: 999; | |
| background: rgba(6,10,18,0.97); | |
| display: flex; flex-direction: column; align-items: center; justify-content: center; | |
| color: #cfe6f7; gap: 12px; | |
| } | |
| #lockOverlay h2 { color: #7fd8ff; letter-spacing: 2px; margin: 0; } | |
| #lockOverlay p { color: #9fc4dd; font-size: 13px; margin: 0; } | |
| #lockInput { | |
| background: #16223a; border: 1px solid #4ecbff66; color: #fff; | |
| padding: 8px 12px; border-radius: 6px; font-size: 14px; text-align: center; | |
| width: 200px; letter-spacing: 1px; | |
| } | |
| #lockBtn { | |
| background: #2a8fd8; border: none; color: #fff; font-weight: bold; | |
| padding: 8px 20px; border-radius: 6px; cursor: pointer; font-size: 13px; | |
| } | |
| #lockError { color: #ff6a6a; font-size: 12px; min-height: 16px; } | |
| #logo { | |
| font-size: 24px; font-weight: 900; letter-spacing: 3px; | |
| color: #7fd8ff; text-shadow: 0 0 10px #2a7ab0, 0 0 20px #7fd8ff; | |
| margin-bottom: 6px; | |
| } | |
| #logo span { color: #ffd94e; text-shadow: 0 0 10px #b38a00, 0 0 20px #ffd94e; } | |
| #panel { | |
| display: inline-block; border-radius: 14px; padding: 8px; | |
| background: linear-gradient(160deg, #1c2740, #0c1220 60%); | |
| box-shadow: 0 0 0 2px #000, 0 0 26px #4ecbff33, 0 10px 24px #000a; | |
| } | |
| #hud { | |
| display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; | |
| width: 900px; margin: 0 auto 6px auto; padding: 6px 10px; | |
| background: #0d1420; border-radius: 8px; border: 1px solid #4ecbff33; | |
| font-size: 12px; color: #cfe6f7; | |
| } | |
| #hud .stat { margin-right: 12px; white-space: nowrap; } | |
| #hud .stat b { color: #ffe14e; } | |
| #speedBtns button, #topBtns button { | |
| background: #16223a; border: 1px solid #4ecbff44; color: #cfe6f7; | |
| border-radius: 5px; padding: 4px 9px; font-size: 11px; cursor: pointer; margin-left: 4px; | |
| } | |
| #speedBtns button.active { background: #2a8fd8; color: #fff; } | |
| #topBtns button.save { border-color: #63ff8f88; color: #a8ffc4; } | |
| #topBtns button.undo { border-color: #ffd94e88; color: #ffe9a8; } | |
| #topBtns button.clear { border-color: #ff5a5a88; color: #ffb8b8; } | |
| canvas { | |
| background: #0a2a44; display: block; border-radius: 6px; | |
| cursor: crosshair; | |
| } | |
| #toolbar { width: 900px; margin: 6px auto 0 auto; } | |
| #tabs { display: flex; gap: 4px; margin-bottom: 5px; flex-wrap: wrap; } | |
| #tabs button { | |
| flex: 1; background: #16223a; border: 1px solid #4ecbff33; color: #9fc4dd; | |
| border-radius: 5px; padding: 6px 2px; font-size: 11px; cursor: pointer; min-width: 60px; | |
| } | |
| #tabs button.active { background: #2a8fd8; color: #fff; border-color: #7fd8ff; } | |
| #brushRow { display: flex; gap: 6px; overflow-x: auto; padding: 4px 2px; min-height: 54px; } | |
| .brush { | |
| flex: 0 0 auto; width: 64px; background: #0d1420; border: 2px solid #4ecbff33; | |
| border-radius: 7px; padding: 5px 2px; cursor: pointer; color: #cfe6f7; | |
| display: flex; flex-direction: column; align-items: center; gap: 2px; font-size: 9px; | |
| } | |
| .brush .ic { font-size: 20px; } | |
| .brush.active { border-color: #ffd94e; background: #2a2410; box-shadow: 0 0 8px #ffd94e55; } | |
| #brushSizeRow { display:flex; align-items:center; gap:6px; margin-top:4px; font-size:11px; color:#9fc4dd; } | |
| #brushSizeRow input { flex: 1; } | |
| #controls { color: #7f9ec9; font-size: 11px; margin-top: 6px; } | |
| #resourceBar { | |
| display: flex; justify-content: center; gap: 22px; | |
| width: 900px; margin: 0 auto 8px auto; padding: 7px 10px; | |
| background: linear-gradient(180deg, #17233a, #0d1420); | |
| border-radius: 8px; border: 1px solid #4ecbff33; | |
| font-size: 13px; font-weight: 600; color: #e8f2ff; | |
| } | |
| #resourceBar .res { display: flex; align-items: center; gap: 5px; transition: color 0.15s; } | |
| #resourceBar .res b { font-size: 14px; } | |
| #resourceBar .res.flash { color: #ff5a5a; } | |
| </style> | |
| </head> | |
| <body> | |
| <a href="index.html" style="position:fixed; top:10px; left:10px; z-index:999; display:inline-flex; align-items:center; gap:5px; padding:7px 12px; border-radius:20px; background:rgba(10,10,20,0.75); color:#fff; font-family:'Segoe UI', Arial, sans-serif; font-size:13px; font-weight:bold; text-decoration:none; border:1px solid rgba(255,255,255,0.25); backdrop-filter: blur(3px);">β Home</a> | |
| <div id="lockOverlay"> | |
| <h2>π WORLD BUILDER</h2> | |
| <p>This one's locked for now β enter the password to play.</p> | |
| <input type="password" id="lockInput" placeholder="Password" autocomplete="off"> | |
| <button id="lockBtn">Unlock</button> | |
| <div id="lockError"></div> | |
| </div> | |
| <div id="wrap"> | |
| <div id="logo">WORLD <span>BUILDER</span></div> | |
| <div id="resourceBar"> | |
| <div class="res" id="resGoldRow">πͺ Gold <b id="resGold">100</b></div> | |
| <div class="res" id="resWoodRow">πͺ΅ Wood <b id="resWood">50</b></div> | |
| <div class="res" id="resFoodRow">πΎ Food <b id="resFood">50</b></div> | |
| <div class="res" id="resIronRow">βοΈ Iron <b id="resIron">0</b></div> | |
| </div> | |
| <div id="hud"> | |
| <div> | |
| <span class="stat">β Day <b id="statDay">0</b></span> | |
| <span class="stat">π₯ Pop <b id="statPop">0</b></span> | |
| <span class="stat">β Born <b id="statBorn">0</b></span> | |
| <span class="stat">β Lost <b id="statLost">0</b></span> | |
| <span class="stat">π§ Humanity <b id="statHuman">0</b></span> | |
| <span class="stat">π‘ Military <b id="statSpear">0</b></span> | |
| <span class="stat">πΊ Wolves <b id="statKing">0</b></span> | |
| </div> | |
| <div style="display:flex; align-items:center;"> | |
| <span id="speedBtns"> | |
| <button id="btnPause">βΈ</button> | |
| <button id="btn1x" class="active">1x</button> | |
| <button id="btn2x">2x</button> | |
| <button id="btn3x">3x</button> | |
| </span> | |
| <span id="topBtns"> | |
| <button class="save" id="btnSave">SAVE</button> | |
| <button class="undo" id="btnUndo">UNDO</button> | |
| <button class="clear" id="btnClear">CLEAR</button> | |
| </span> | |
| </div> | |
| </div> | |
| <div id="panel"> | |
| <canvas id="game" width="880" height="480"></canvas> | |
| </div> | |
| <div id="toolbar"> | |
| <div id="tabs"> | |
| <button class="tab active" data-tab="biomes">BIOMES</button> | |
| <button class="tab" data-tab="nature">NATURE</button> | |
| <button class="tab" data-tab="creatures">CREATURES</button> | |
| <button class="tab" data-tab="humanity">HUMANITY</button> | |
| <button class="tab" data-tab="military">MILITARY</button> | |
| <button class="tab" data-tab="disasters">DISASTERS</button> | |
| </div> | |
| <div id="brushRow"></div> | |
| <div id="brushSizeRow"> | |
| <span>Brush size</span> | |
| <input type="range" id="brushSize" min="1" max="5" value="1"> | |
| <span id="brushSizeVal">1</span> | |
| </div> | |
| </div> | |
| <div id="controls">Click or drag on the world to paint β pick a category tab below, then a brush, then paint the grid</div> | |
| </div> | |
| <script> | |
| const LOCK_PASSWORD = 'INDEX123'; | |
| (function setupLock(){ | |
| const overlay = document.getElementById('lockOverlay'); | |
| const input = document.getElementById('lockInput'); | |
| const btn = document.getElementById('lockBtn'); | |
| const errEl = document.getElementById('lockError'); | |
| function tryUnlock(){ | |
| if (input.value === LOCK_PASSWORD){ | |
| overlay.style.display = 'none'; | |
| } else { | |
| errEl.textContent = 'Incorrect password.'; | |
| input.value = ''; | |
| input.focus(); | |
| } | |
| } | |
| btn.addEventListener('click', tryUnlock); | |
| input.addEventListener('keydown', e => { if (e.key === 'Enter') tryUnlock(); }); | |
| input.focus(); | |
| })(); | |
| const canvas = document.getElementById('game'); | |
| const ctx = canvas.getContext('2d'); | |
| const W = canvas.width, H = canvas.height; | |
| const COLS = 55, ROWS = 30; | |
| const CELL = W / COLS; // ~16px | |
| const statDay = document.getElementById('statDay'); | |
| const statPop = document.getElementById('statPop'); | |
| const statBorn = document.getElementById('statBorn'); | |
| const statLost = document.getElementById('statLost'); | |
| const statHuman = document.getElementById('statHuman'); | |
| const statSpear = document.getElementById('statSpear'); | |
| const statKing = document.getElementById('statKing'); | |
| const brushRow = document.getElementById('brushRow'); | |
| const brushSizeInput = document.getElementById('brushSize'); | |
| const brushSizeVal = document.getElementById('brushSizeVal'); | |
| const resGold = document.getElementById('resGold'); | |
| const resWood = document.getElementById('resWood'); | |
| const resFood = document.getElementById('resFood'); | |
| const resIron = document.getElementById('resIron'); | |
| const resRows = { gold: document.getElementById('resGoldRow'), wood: document.getElementById('resWoodRow'), food: document.getElementById('resFoodRow'), iron: document.getElementById('resIronRow') }; | |
| // --------------------------------------------------------------- | |
| // World data | |
| // --------------------------------------------------------------- | |
| const BIOME_COLORS = { | |
| water: '#0a2a44', | |
| land: '#2f7a3a', | |
| mountain:'#7a7a82', | |
| sand: '#d8c07a', | |
| desert: '#caa25a', | |
| swamp: '#4a5a3a', | |
| snow: '#e8f2f5', | |
| forest: '#1d5c2a', | |
| jungle: '#2e7d1f', | |
| grassland:'#8fbf4a', | |
| volcano: '#5a2a18', | |
| wasteland:'#7a6e5a', | |
| coralreef:'#1fa8a0', | |
| deepocean:'#041830', | |
| oasis: '#2fcf8a', | |
| mesa: '#a8562e', | |
| glacier: '#cfe8f5', | |
| }; | |
| const WALKABLE = { | |
| water:false, land:true, mountain:false, sand:true, desert:true, swamp:true, snow:true, | |
| forest:true, jungle:true, grassland:true, volcano:true, wasteland:true, | |
| coralreef:false, deepocean:false, oasis:true, mesa:true, glacier:true, | |
| }; | |
| let grid = []; // biome per cell | |
| function makeGrid(){ | |
| grid = []; | |
| for (let y = 0; y < ROWS; y++){ | |
| const row = []; | |
| for (let x = 0; x < COLS; x++) row.push('water'); | |
| grid.push(row); | |
| } | |
| } | |
| makeGrid(); | |
| let fire = []; // fire intensity 0-1 per cell (0 = no fire) | |
| function makeFire(){ | |
| fire = []; | |
| for (let y = 0; y < ROWS; y++) fire.push(new Array(COLS).fill(0)); | |
| } | |
| makeFire(); | |
| let entities = []; // { id, kind, x, y, age, tier, infected, hp } | |
| let nextId = 1; | |
| let day = 0, born = 0, lost = 0; | |
| // --------------------------------------------------------------- | |
| // Economy: resources, costs, upkeep | |
| // --------------------------------------------------------------- | |
| let resources = { gold: 100, wood: 50, food: 50, iron: 0 }; | |
| let foodShortage = false; | |
| const COSTS = { | |
| villager: { gold:10, food:5 }, | |
| fisher: { gold:15, food:5 }, | |
| farmer: { gold:15, food:5 }, | |
| lumberjack: { gold:15, food:5 }, | |
| miner: { gold:20, food:5 }, | |
| merchant: { gold:25, food:10 }, | |
| noble: { gold:50, food:20 }, | |
| footman: { gold:20, iron:10 }, | |
| archer: { gold:20, wood:10 }, | |
| knight: { gold:40, iron:20 }, | |
| guardtower: { gold:50, wood:50 }, | |
| }; | |
| const UPKEEP = { footman: { food:1 }, archer: { food:1 }, knight: { food:2 }, noble: { food:2 } }; | |
| const UNIT_STATS = { | |
| footman: { maxHp:50, dmg:8, range:1.3, speedMult:1 }, | |
| archer: { maxHp:30, dmg:6, range:4, speedMult:1 }, | |
| knight: { maxHp:80, dmg:12, range:1.3, speedMult:2 }, | |
| goblin: { maxHp:40, dmg:7, range:1.3, speedMult:1 }, | |
| }; | |
| function canAfford(cost){ | |
| if (!cost) return true; | |
| return Object.keys(cost).every(k => (resources[k]||0) >= cost[k]); | |
| } | |
| function spendResources(cost){ | |
| Object.keys(cost).forEach(k => { resources[k] -= cost[k]; }); | |
| updateResourceBar(); | |
| } | |
| function flashInsufficientResources(cost){ | |
| Object.keys(cost).forEach(k => { | |
| if ((resources[k]||0) < cost[k] && resRows[k]){ | |
| resRows[k].classList.add('flash'); | |
| setTimeout(() => resRows[k].classList.remove('flash'), 500); | |
| } | |
| }); | |
| } | |
| function updateResourceBar(){ | |
| resGold.textContent = Math.max(0, Math.floor(resources.gold)); | |
| resWood.textContent = Math.max(0, Math.floor(resources.wood)); | |
| resFood.textContent = Math.max(0, Math.floor(resources.food)); | |
| resIron.textContent = Math.max(0, Math.floor(resources.iron)); | |
| } | |
| updateResourceBar(); | |
| function inBounds(x,y){ return x>=0 && x<COLS && y>=0 && y<ROWS; } | |
| function tileAt(x,y){ return inBounds(x,y) ? grid[y][x] : 'water'; } | |
| const HUMANITY_KINDS = ['farmer','fisher','merchant','noble','villager','lumberjack','miner','blacksmith','scholar','alchemist']; | |
| const MILITARY_UNIT_KINDS = ['footman','archer','knight']; | |
| const MILITARY_BUILDING_KINDS = ['watchtower','catapult','armory','guardtower']; | |
| const STATIC_KINDS = ['tree','bush','rock','house','watchtower','catapult','armory','guardtower']; | |
| // --------------------------------------------------------------- | |
| // Brushes | |
| // --------------------------------------------------------------- | |
| const TABS = { | |
| biomes: [ | |
| { id:'land', ic:'π±', label:'Land' }, | |
| { id:'water', ic:'π', label:'Water' }, | |
| { id:'mountain', ic:'β°οΈ', label:'Mountain' }, | |
| { id:'sand', ic:'ποΈ', label:'Sand' }, | |
| { id:'desert', ic:'ποΈ', label:'Desert' }, | |
| { id:'swamp', ic:'πͺ·', label:'Swamp' }, | |
| { id:'snow', ic:'βοΈ', label:'Snow' }, | |
| { id:'forest', ic:'π²', label:'Forest' }, | |
| { id:'jungle', ic:'π΄', label:'Jungle' }, | |
| { id:'grassland', ic:'πΎ', label:'Grassland' }, | |
| { id:'volcano', ic:'π', label:'Volcano' }, | |
| { id:'wasteland', ic:'ποΈ', label:'Wasteland' }, | |
| { id:'coralreef', ic:'πͺΈ', label:'Coral Reef' }, | |
| { id:'deepocean', ic:'π', label:'Deep Ocean' }, | |
| { id:'oasis', ic:'π¦', label:'Oasis' }, | |
| { id:'mesa', ic:'π§±', label:'Mesa' }, | |
| { id:'glacier', ic:'π§', label:'Glacier' }, | |
| ], | |
| nature: [ | |
| { id:'tree', ic:'π³', label:'Tree' }, | |
| { id:'bush', ic:'πΏ', label:'Bush' }, | |
| { id:'rock', ic:'πͺ¨', label:'Rock' }, | |
| ], | |
| creatures: [ | |
| { id:'critter', ic:'π', label:'Critter' }, | |
| { id:'wolf', ic:'πΊ', label:'Wolf' }, | |
| { id:'goblin', ic:'πΊ', label:'Goblin' }, | |
| ], | |
| humanity: [ | |
| { id:'farmer', ic:'π¨βπΎ', label:'Farmer' }, | |
| { id:'fisher', ic:'π£', label:'Fisher' }, | |
| { id:'merchant', ic:'π°', label:'Merchant' }, | |
| { id:'noble', ic:'π€΅', label:'Noble' }, | |
| { id:'villager', ic:'π§', label:'Villager' }, | |
| { id:'lumberjack', ic:'πͺ', label:'Lumberjack' }, | |
| { id:'miner', ic:'βοΈ', label:'Miner' }, | |
| { id:'blacksmith', ic:'π¨', label:'Blacksmith' }, | |
| { id:'scholar', ic:'π', label:'Scholar' }, | |
| { id:'alchemist', ic:'βοΈ', label:'Alchemist' }, | |
| { id:'house', ic:'π ', label:'House' }, | |
| ], | |
| military: [ | |
| { id:'footman', ic:'πͺ', label:'Footman' }, | |
| { id:'archer', ic:'πΉ', label:'Archer' }, | |
| { id:'knight', ic:'π‘οΈ', label:'Knight' }, | |
| { id:'guardtower', ic:'π―', label:'Guard Tower' }, | |
| { id:'watchtower', ic:'πΌ', label:'Watchtower' }, | |
| { id:'catapult', ic:'π£', label:'Catapult' }, | |
| { id:'armory', ic:'ποΈ', label:'Armory' }, | |
| ], | |
| disasters: [ | |
| { id:'meteor', ic:'βοΈ', label:'Meteor' }, | |
| { id:'fire', ic:'π₯', label:'Fire' }, | |
| { id:'plague', ic:'β£οΈ', label:'Plague' }, | |
| { id:'earthquake', ic:'π₯', label:'Quake' }, | |
| ], | |
| }; | |
| let activeTab = 'biomes'; | |
| let activeBrush = 'land'; | |
| let brushSize = 1; | |
| function renderBrushRow(){ | |
| brushRow.innerHTML = ''; | |
| TABS[activeTab].forEach(b => { | |
| const el = document.createElement('div'); | |
| el.className = 'brush' + (b.id === activeBrush ? ' active' : ''); | |
| el.innerHTML = `<div class="ic">${b.ic}</div><div>${b.label}</div>`; | |
| el.addEventListener('click', () => { | |
| activeBrush = b.id; | |
| renderBrushRow(); | |
| }); | |
| brushRow.appendChild(el); | |
| }); | |
| } | |
| renderBrushRow(); | |
| document.querySelectorAll('#tabs .tab').forEach(btn => { | |
| btn.addEventListener('click', () => { | |
| document.querySelectorAll('#tabs .tab').forEach(b => b.classList.remove('active')); | |
| btn.classList.add('active'); | |
| activeTab = btn.dataset.tab; | |
| activeBrush = TABS[activeTab][0].id; | |
| renderBrushRow(); | |
| }); | |
| }); | |
| brushSizeInput.addEventListener('input', () => { | |
| brushSize = parseInt(brushSizeInput.value, 10); | |
| brushSizeVal.textContent = brushSize; | |
| }); | |
| // --------------------------------------------------------------- | |
| // Painting | |
| // --------------------------------------------------------------- | |
| function spawnEntity(kind, x, y, extra){ | |
| const stats = UNIT_STATS[kind]; | |
| const base = { id: nextId++, kind, x, y, age: 0, infected: 0, hp: 1 }; | |
| if (stats){ | |
| base.hp = stats.maxHp; | |
| base.maxHp = stats.maxHp; | |
| base.state = 'patrol'; // patrol | advance | attack (soldiers) β goblins just march | |
| base.targetId = null; | |
| base.lastAttack = -1000; // tick number of last attack; starts far enough back that the first attack is never blocked | |
| base.damageFlashT = 0; // white flash timer when hit | |
| base.engagedT = 0; // shows the floating health bar while >0 | |
| base.homeX = x; base.homeY = y; // patrol anchor near the nearest guard tower | |
| } | |
| const e = Object.assign(base, extra||{}); | |
| entities.push(e); | |
| return e; | |
| } | |
| const WATER_FAMILY = ['water', 'coralreef', 'deepocean']; | |
| function tryPay(kind){ | |
| const cost = COSTS[kind]; | |
| if (!cost) return true; // free to place | |
| if (!canAfford(cost)){ flashInsufficientResources(cost); return false; } | |
| spendResources(cost); | |
| return true; | |
| } | |
| function applyBrushAt(gx, gy){ | |
| if (!inBounds(gx,gy)) return; | |
| if (activeTab === 'biomes'){ | |
| grid[gy][gx] = activeBrush; | |
| if (!WATER_FAMILY.includes(activeBrush)) fire[gy][gx] = 0; | |
| } else if (activeTab === 'nature'){ | |
| if (WALKABLE[tileAt(gx,gy)]) spawnEntity(activeBrush, gx, gy); | |
| } else if (activeTab === 'creatures'){ | |
| if (WALKABLE[tileAt(gx,gy)]) spawnEntity(activeBrush, gx, gy, { energy: 40 }); | |
| } else if (activeTab === 'humanity'){ | |
| if (WALKABLE[tileAt(gx,gy)]){ | |
| if (activeBrush === 'house'){ spawnEntity('house', gx, gy); } | |
| else if (tryPay(activeBrush)){ spawnEntity(activeBrush, gx, gy, { energy: 50 }); } | |
| } | |
| } else if (activeTab === 'military'){ | |
| if (WALKABLE[tileAt(gx,gy)]){ | |
| if (tryPay(activeBrush)){ | |
| if (MILITARY_BUILDING_KINDS.includes(activeBrush)) spawnEntity(activeBrush, gx, gy); | |
| else spawnEntity(activeBrush, gx, gy, { energy: 60 }); | |
| } | |
| } | |
| } else if (activeTab === 'disasters'){ | |
| triggerDisaster(activeBrush, gx, gy); | |
| } | |
| } | |
| function paintWithBrush(gx, gy){ | |
| const r = brushSize - 1; | |
| for (let dy = -r; dy <= r; dy++){ | |
| for (let dx = -r; dx <= r; dx++){ | |
| if (dx*dx + dy*dy <= r*r + 0.5) applyBrushAt(gx+dx, gy+dy); | |
| } | |
| } | |
| } | |
| function triggerDisaster(type, gx, gy){ | |
| if (type === 'meteor'){ | |
| const R = 2 + brushSize; | |
| for (let dy=-R; dy<=R; dy++) for (let dx=-R; dx<=R; dx++){ | |
| if (dx*dx+dy*dy > R*R) continue; | |
| const x=gx+dx, y=gy+dy; | |
| if (!inBounds(x,y)) continue; | |
| if (grid[y][x] !== 'water') grid[y][x] = 'sand'; // scorched crater | |
| killEntitiesAt(x, y, 1); | |
| } | |
| spawnParticles(gx*CELL+CELL/2, gy*CELL+CELL/2, '#ff8a3d', 26); | |
| } else if (type === 'fire'){ | |
| const r = brushSize - 1; | |
| for (let dy=-r; dy<=r; dy++) for (let dx=-r; dx<=r; dx++){ | |
| const x=gx+dx, y=gy+dy; | |
| if (inBounds(x,y) && grid[y][x] !== 'water') fire[y][x] = 1; | |
| } | |
| } else if (type === 'plague'){ | |
| const r = 2 + brushSize; | |
| entities.forEach(e => { | |
| if ((HUMANITY_KINDS.includes(e.kind)||MILITARY_UNIT_KINDS.includes(e.kind)||e.kind==='critter'||e.kind==='wolf') && Math.hypot(e.x-gx,e.y-gy) <= r) e.infected = 1; | |
| }); | |
| } else if (type === 'earthquake'){ | |
| const R = 2 + brushSize; | |
| for (let dy=-R; dy<=R; dy++) for (let dx=-R; dx<=R; dx++){ | |
| if (dx*dx+dy*dy > R*R) continue; | |
| const x=gx+dx, y=gy+dy; | |
| if (!inBounds(x,y)) continue; | |
| if (Math.random() < 0.35) killEntitiesAt(x, y, 1); | |
| } | |
| spawnParticles(gx*CELL+CELL/2, gy*CELL+CELL/2, '#8a7a5a', 20); | |
| } | |
| } | |
| function killEntitiesAt(x, y, radius){ | |
| entities.forEach(e => { | |
| if (Math.hypot(e.x-x, e.y-y) <= radius && e.hp > 0){ | |
| e.hp = 0; | |
| lost++; | |
| } | |
| }); | |
| } | |
| let mouseDown = false; | |
| function getGridPos(e){ | |
| const rect = canvas.getBoundingClientRect(); | |
| const px = (e.clientX - rect.left) * (W/rect.width); | |
| const py = (e.clientY - rect.top) * (H/rect.height); | |
| return { gx: Math.floor(px/CELL), gy: Math.floor(py/CELL) }; | |
| } | |
| canvas.addEventListener('mousedown', e => { mouseDown = true; const p = getGridPos(e); pushUndoSnapshot(); paintWithBrush(p.gx, p.gy); }); | |
| canvas.addEventListener('mousemove', e => { if (mouseDown){ const p = getGridPos(e); paintWithBrush(p.gx, p.gy); } }); | |
| window.addEventListener('mouseup', () => { mouseDown = false; }); | |
| canvas.addEventListener('touchstart', e => { e.preventDefault(); mouseDown = true; const t=e.touches[0]; const p = getGridPos(t); pushUndoSnapshot(); paintWithBrush(p.gx, p.gy); }, {passive:false}); | |
| canvas.addEventListener('touchmove', e => { e.preventDefault(); const t=e.touches[0]; const p = getGridPos(t); paintWithBrush(p.gx, p.gy); }, {passive:false}); | |
| window.addEventListener('touchend', () => { mouseDown = false; }); | |
| // --------------------------------------------------------------- | |
| // Particles (visual only) | |
| // --------------------------------------------------------------- | |
| let particles = []; | |
| function spawnParticles(px, py, color, count){ | |
| for (let i=0;i<count;i++){ | |
| const a = Math.random()*Math.PI*2, sp = 1+Math.random()*4; | |
| particles.push({ x:px, y:py, vx:Math.cos(a)*sp, vy:Math.sin(a)*sp, life:1, color }); | |
| } | |
| } | |
| function updateParticles(){ | |
| particles.forEach(p => { p.x+=p.vx; p.y+=p.vy; p.vx*=0.92; p.vy*=0.92; p.life-=0.03; }); | |
| particles = particles.filter(p => p.life > 0); | |
| } | |
| function updateCombatVisuals(dt){ | |
| entities.forEach(e => { | |
| if (e.damageFlashT > 0) e.damageFlashT -= dt; | |
| if (e.engagedT > 0) e.engagedT -= dt; | |
| }); | |
| } | |
| // --------------------------------------------------------------- | |
| // Simulation | |
| // --------------------------------------------------------------- | |
| let simSpeed = 1; // 0 = paused | |
| let tickAccum = 0; | |
| const TICK_INTERVAL = 0.5; // seconds per simulation tick at 1x | |
| function neighborsOf(x, y){ | |
| const out = []; | |
| for (let dy=-1; dy<=1; dy++) for (let dx=-1; dx<=1; dx++){ | |
| if (dx===0 && dy===0) continue; | |
| if (inBounds(x+dx,y+dy)) out.push([x+dx,y+dy]); | |
| } | |
| return out; | |
| } | |
| // --------------------------------------------------------------- | |
| // Economy cycle β runs every 5 simulated seconds | |
| // --------------------------------------------------------------- | |
| function tileNearbyIncludes(x, y, biomeList){ | |
| if (biomeList.includes(tileAt(x,y))) return true; | |
| return neighborsOf(x,y).some(([nx,ny]) => biomeList.includes(tileAt(nx,ny))); | |
| } | |
| function nearTreeEntity(x, y){ | |
| return entities.some(o => o.kind === 'tree' && o.hp > 0 && Math.hypot(o.x-x, o.y-y) <= 1.5); | |
| } | |
| function runEconomyCycle(){ | |
| let goldDelta = 0, woodDelta = 0, foodDelta = 0, ironDelta = 0; | |
| const villagers = entities.filter(e => e.kind === 'villager' && e.hp > 0); | |
| const merchants = entities.filter(e => e.kind === 'merchant' && e.hp > 0); | |
| entities.forEach(e => { | |
| if (e.hp <= 0) return; | |
| if (e.kind === 'villager') goldDelta += 1; | |
| else if (e.kind === 'fisher'){ if (tileNearbyIncludes(e.x, e.y, WATER_FAMILY)) foodDelta += 3; } | |
| else if (e.kind === 'farmer'){ if (tileNearbyIncludes(e.x, e.y, ['land','sand'])) foodDelta += 3; } | |
| else if (e.kind === 'lumberjack'){ if (tileNearbyIncludes(e.x, e.y, ['forest']) || nearTreeEntity(e.x, e.y)) woodDelta += 3; } | |
| else if (e.kind === 'miner'){ if (tileNearbyIncludes(e.x, e.y, ['mountain'])) ironDelta += 2; } | |
| else if (e.kind === 'noble'){ goldDelta += 10; foodDelta -= 2; } | |
| else if (UPKEEP[e.kind] && UPKEEP[e.kind].food) foodDelta -= UPKEEP[e.kind].food; | |
| }); | |
| // merchants boost every villager within 3 tiles by +1 gold each | |
| merchants.forEach(m => { | |
| villagers.forEach(v => { if (Math.hypot(v.x-m.x, v.y-m.y) <= 3) goldDelta += 1; }); | |
| }); | |
| resources.gold += goldDelta; | |
| resources.wood += woodDelta; | |
| resources.iron += ironDelta; | |
| resources.food += foodDelta; | |
| foodShortage = resources.food < 0; | |
| if (resources.food < 0) resources.food = 0; | |
| updateResourceBar(); | |
| } | |
| function stepFire(){ | |
| const next = fire.map(row => row.slice()); | |
| for (let y=0;y<ROWS;y++) for (let x=0;x<COLS;x++){ | |
| if (fire[y][x] > 0){ | |
| next[y][x] = Math.max(0, fire[y][x] - 0.12); // burns out over time | |
| if (fire[y][x] > 0.3){ | |
| neighborsOf(x,y).forEach(([nx,ny]) => { | |
| if (!WATER_FAMILY.includes(grid[ny][nx]) && fire[ny][nx] === 0 && Math.random() < 0.045){ | |
| next[ny][nx] = 1; | |
| } | |
| }); | |
| } | |
| // kill nature/entities standing in fire | |
| killEntitiesAt(x, y, 0.6); | |
| } | |
| // volcanoes occasionally spit fire onto a neighboring flammable tile | |
| if (grid[y][x] === 'volcano' && Math.random() < 0.01){ | |
| const opts = neighborsOf(x,y).filter(([nx,ny]) => !WATER_FAMILY.includes(grid[ny][nx]) && grid[ny][nx] !== 'volcano' && fire[ny][nx] === 0); | |
| if (opts.length){ | |
| const [nx,ny] = opts[Math.floor(Math.random()*opts.length)]; | |
| next[ny][nx] = 1; | |
| } | |
| } | |
| } | |
| fire = next; | |
| } | |
| // military engage range/survival bonuses from nearby buildings | |
| const MILITARY_ENGAGE_RANGE = { footman: 1.6, archer: 2.6, knight: 1.7 }; | |
| function nearbyBuilding(x, y, kind, radius){ | |
| return entities.some(o => o.kind === kind && o.hp > 0 && Math.hypot(o.x-x, o.y-y) <= radius); | |
| } | |
| // --------------------------------------------------------------- | |
| // Goblin adversary system β spawning, pathing, and the guard-tower combat loop | |
| // --------------------------------------------------------------- | |
| const GUARD_TOWER_RADIUS = 6; | |
| const GOBLIN_SPAWN_INTERVAL_TICKS = 90; // 45s at 0.5s/tick | |
| const ATTACK_COOLDOWN_TICKS = 2; // ~1s at 0.5s/tick | |
| let ticksSinceGoblinSpawn = 0; | |
| function findNearestGuardTower(x, y){ | |
| let best = null, bestDist = Infinity; | |
| entities.forEach(o => { | |
| if (o.kind === 'guardtower' && o.hp > 0){ | |
| const d = Math.hypot(o.x-x, o.y-y); | |
| if (d < bestDist){ bestDist = d; best = o; } | |
| } | |
| }); | |
| return best; | |
| } | |
| function goblinsThreateningTowers(){ | |
| const towers = entities.filter(o => o.kind === 'guardtower' && o.hp > 0); | |
| if (!towers.length) return []; | |
| return entities.filter(g => g.kind === 'goblin' && g.hp > 0 && | |
| towers.some(t => Math.hypot(t.x-g.x, t.y-g.y) <= GUARD_TOWER_RADIUS)); | |
| } | |
| function moveToward(e, tx, ty, speedMult){ | |
| let steps = (e.kind === 'knight' && tileAt(e.x,e.y) === 'land') ? Math.round(speedMult) : 1; | |
| for (let s = 0; s < steps; s++){ | |
| const dx = tx-e.x, dy = ty-e.y; | |
| if (Math.abs(dx) < 0.5 && Math.abs(dy) < 0.5) break; | |
| let best = null, bestD = Math.hypot(dx,dy); | |
| neighborsOf(e.x, e.y).forEach(([nx,ny]) => { | |
| if (!WALKABLE[tileAt(nx,ny)]) return; | |
| const d = Math.hypot(tx-nx, ty-ny); | |
| if (d < bestD){ bestD = d; best = [nx,ny]; } | |
| }); | |
| if (best){ e.x = best[0]; e.y = best[1]; } else break; | |
| } | |
| } | |
| function patrolNear(e){ | |
| const tower = findNearestGuardTower(e.homeX, e.homeY); | |
| const anchorX = tower ? tower.x : e.homeX, anchorY = tower ? tower.y : e.homeY; | |
| if (Math.random() < 0.5){ | |
| const opts = neighborsOf(e.x, e.y).filter(([nx,ny]) => WALKABLE[tileAt(nx,ny)] && Math.hypot(nx-anchorX, ny-anchorY) <= 3); | |
| if (opts.length){ | |
| const [nx,ny] = opts[Math.floor(Math.random()*opts.length)]; | |
| e.x = nx; e.y = ny; | |
| } | |
| } | |
| } | |
| function dealDamage(attacker, defender, dmg){ | |
| defender.hp -= dmg; | |
| defender.damageFlashT = 0.2; | |
| defender.engagedT = 2.5; | |
| attacker.engagedT = 2.5; | |
| spawnParticles(defender.x*CELL+CELL/2, defender.y*CELL+CELL/2, '#ffffff', 5); | |
| if (defender.hp <= 0){ | |
| if (defender.kind === 'goblin'){ | |
| resources.gold += 20; resources.iron += 2; | |
| updateResourceBar(); | |
| } else if (defender.kind === 'house' || HUMANITY_KINDS.includes(defender.kind) || MILITARY_UNIT_KINDS.includes(defender.kind)){ | |
| lost++; | |
| if (defender.kind === 'house' || HUMANITY_KINDS.includes(defender.kind)){ | |
| if (inBounds(defender.x, defender.y)) grid[defender.y][defender.x] = 'land'; | |
| } | |
| } | |
| } | |
| } | |
| function stepCombatAI(){ | |
| const threatening = goblinsThreateningTowers(); | |
| entities.forEach(e => { | |
| if (e.hp <= 0) return; | |
| if (MILITARY_UNIT_KINDS.includes(e.kind)){ | |
| const stats = UNIT_STATS[e.kind]; | |
| let target = e.targetId ? entities.find(o => o.id === e.targetId && o.hp > 0 && o.kind === 'goblin') : null; | |
| if (!target && threatening.length){ | |
| target = threatening.reduce((best,g) => { | |
| const d = Math.hypot(g.x-e.x, g.y-e.y); | |
| return (!best || d < best.d) ? { g, d } : best; | |
| }, null); | |
| target = target ? target.g : null; | |
| } | |
| if (target){ | |
| e.targetId = target.id; | |
| const dist = Math.hypot(target.x-e.x, target.y-e.y); | |
| if (dist <= stats.range){ | |
| e.state = 'attack'; | |
| if (foodShortage){ | |
| e.hp -= 0.5; e.damageFlashT = 0.15; | |
| } else if (day - e.lastAttack >= ATTACK_COOLDOWN_TICKS){ | |
| e.lastAttack = day; | |
| dealDamage(e, target, stats.dmg); | |
| } | |
| } else { | |
| e.state = 'advance'; | |
| moveToward(e, target.x, target.y, stats.speedMult); | |
| } | |
| } else { | |
| e.targetId = null; | |
| e.state = 'patrol'; | |
| patrolNear(e); | |
| } | |
| } | |
| if (e.kind === 'goblin'){ | |
| const stats = UNIT_STATS.goblin; | |
| let target = e.targetId ? entities.find(o => o.id === e.targetId && o.hp > 0 && (o.kind === 'house' || o.kind === 'villager')) : null; | |
| if (!target){ | |
| target = entities.reduce((best,o) => { | |
| if (o.hp <= 0 || (o.kind !== 'house' && o.kind !== 'villager')) return best; | |
| const d = Math.hypot(o.x-e.x, o.y-e.y); | |
| return (!best || d < best.d) ? { o, d } : best; | |
| }, null); | |
| target = target ? target.o : null; | |
| } | |
| if (target){ | |
| e.targetId = target.id; | |
| const dist = Math.hypot(target.x-e.x, target.y-e.y); | |
| if (dist <= stats.range){ | |
| if (day - e.lastAttack >= ATTACK_COOLDOWN_TICKS){ | |
| e.lastAttack = day; | |
| dealDamage(e, target, stats.dmg); | |
| } | |
| } else { | |
| moveToward(e, target.x, target.y, stats.speedMult); | |
| } | |
| } | |
| } | |
| }); | |
| entities = entities.filter(e => e.hp > 0); | |
| } | |
| function trySpawnGoblinOnSwamp(){ | |
| const swampTiles = []; | |
| for (let y = 0; y < ROWS; y++) for (let x = 0; x < COLS; x++) if (grid[y][x] === 'swamp') swampTiles.push([x,y]); | |
| if (!swampTiles.length) return; | |
| const [x,y] = swampTiles[Math.floor(Math.random()*swampTiles.length)]; | |
| spawnEntity('goblin', x, y, { energy: 40 }); | |
| } | |
| function stepEntities(){ | |
| const alive = []; | |
| for (const e of entities){ | |
| if (e.hp <= 0){ continue; } // already dead, drop this tick | |
| e.age += 1; | |
| // plague: chance of death each tick while infected, chance to spread to neighbors | |
| if (e.infected){ | |
| if (Math.random() < 0.03){ e.hp = 0; lost++; continue; } | |
| entities.forEach(o => { | |
| if (o !== e && !o.infected && o.hp > 0 && Math.hypot(o.x-e.x, o.y-e.y) < 2.1 && Math.random() < 0.14) o.infected = 1; | |
| }); | |
| } | |
| // fire kills on contact (checked in stepFire too, but also guard here for entities that moved into it this tick) | |
| if (fire[e.y] && fire[e.y][e.x] > 0.3 && Math.random() < 0.5){ e.hp = 0; lost++; continue; } | |
| if (STATIC_KINDS.includes(e.kind)){ | |
| // catapult: passively snipes a random wolf within a wide radius | |
| if (e.kind === 'catapult' && Math.random() < 0.02){ | |
| const target = entities.find(o => o.kind === 'wolf' && o.hp > 0 && Math.hypot(o.x-e.x,o.y-e.y) <= 6); | |
| if (target){ target.hp = 0; lost++; } | |
| } | |
| alive.push(e); | |
| continue; // static nature/structures/equipment don't move or age out | |
| } | |
| // movement: wander to a random adjacent walkable tile (military units and goblins move via their own combat AI instead) | |
| if (!MILITARY_UNIT_KINDS.includes(e.kind) && e.kind !== 'goblin' && Math.random() < 0.6){ | |
| const opts = neighborsOf(e.x, e.y).filter(([nx,ny]) => WALKABLE[tileAt(nx,ny)]); | |
| if (opts.length){ | |
| const [nx,ny] = opts[Math.floor(Math.random()*opts.length)]; | |
| e.x = nx; e.y = ny; | |
| } | |
| } | |
| // aging / death β armories help nearby military units hold out longer | |
| let maxAge = e.kind === 'wolf' ? 260 : e.kind === 'critter' ? 180 : 420; | |
| if (MILITARY_UNIT_KINDS.includes(e.kind) && nearbyBuilding(e.x, e.y, 'armory', 4)) maxAge *= 1.4; | |
| if (e.age > maxAge && Math.random() < 0.02){ e.hp = 0; lost++; continue; } | |
| // wolves hunt nearby critters | |
| if (e.kind === 'wolf'){ | |
| const prey = entities.find(o => o.kind === 'critter' && o.hp > 0 && Math.hypot(o.x-e.x,o.y-e.y) < 1.6); | |
| if (prey){ prey.hp = 0; lost++; e.energy = Math.min(80, (e.energy||40) + 25); } | |
| } | |
| // military units hunt nearby wolves, defending the population β watchtowers extend their reach | |
| if (MILITARY_UNIT_KINDS.includes(e.kind)){ | |
| let range = MILITARY_ENGAGE_RANGE[e.kind] || 1.6; | |
| if (nearbyBuilding(e.x, e.y, 'watchtower', 5)) range += 1.2; | |
| const target = entities.find(o => o.kind === 'wolf' && o.hp > 0 && Math.hypot(o.x-e.x,o.y-e.y) < range); | |
| if (target){ target.hp = 0; lost++; } | |
| } | |
| // reproduction: two same-kind adults nearby with enough energy spawn a new one | |
| if ((e.kind === 'critter' || HUMANITY_KINDS.includes(e.kind)) && e.age > 20){ | |
| const mate = entities.find(o => o !== e && o.kind === e.kind && o.hp > 0 && o.age > 20 && Math.hypot(o.x-e.x,o.y-e.y) < 1.6); | |
| const cap = e.kind === 'critter' ? 90 : 20; | |
| const kindPop = entities.filter(o => o.kind === e.kind && o.hp > 0).length; | |
| if (mate && kindPop < cap && Math.random() < 0.01){ | |
| const opts = neighborsOf(e.x, e.y).filter(([nx,ny]) => WALKABLE[tileAt(nx,ny)]); | |
| if (opts.length){ | |
| const [nx,ny] = opts[Math.floor(Math.random()*opts.length)]; | |
| spawnEntity(e.kind, nx, ny, { energy: e.kind==='critter' ? 40 : 50 }); | |
| born++; | |
| } | |
| } | |
| } | |
| alive.push(e); | |
| } | |
| entities = alive; | |
| } | |
| let ticksSinceEcon = 0; | |
| const ECON_INTERVAL_TICKS = 10; // 5s at 0.5s/tick | |
| function tickSimulation(){ | |
| day++; | |
| stepFire(); | |
| stepEntities(); | |
| stepCombatAI(); | |
| ticksSinceGoblinSpawn++; | |
| if (ticksSinceGoblinSpawn >= GOBLIN_SPAWN_INTERVAL_TICKS){ | |
| ticksSinceGoblinSpawn = 0; | |
| trySpawnGoblinOnSwamp(); | |
| } | |
| ticksSinceEcon++; | |
| if (ticksSinceEcon >= ECON_INTERVAL_TICKS){ | |
| ticksSinceEcon = 0; | |
| runEconomyCycle(); | |
| } | |
| updateHud(); | |
| } | |
| function updateHud(){ | |
| const pop = entities.filter(e => e.hp > 0 && (HUMANITY_KINDS.includes(e.kind)||e.kind==='critter'||e.kind==='wolf'||MILITARY_UNIT_KINDS.includes(e.kind))).length; | |
| const humanityCount = entities.filter(e => HUMANITY_KINDS.includes(e.kind) && e.hp>0).length; | |
| const militaryCount = entities.filter(e => MILITARY_UNIT_KINDS.includes(e.kind) && e.hp>0).length; | |
| const wolfCount = entities.filter(e => e.kind==='wolf' && e.hp>0).length; | |
| statDay.textContent = day; | |
| statPop.textContent = pop; | |
| statBorn.textContent = born; | |
| statLost.textContent = lost; | |
| statHuman.textContent = humanityCount; | |
| statSpear.textContent = militaryCount; | |
| statKing.textContent = wolfCount; | |
| } | |
| // --------------------------------------------------------------- | |
| // Speed controls | |
| // --------------------------------------------------------------- | |
| function setSpeed(v){ | |
| simSpeed = v; | |
| ['btnPause','btn1x','btn2x','btn3x'].forEach(id => document.getElementById(id).classList.remove('active')); | |
| if (v === 0) document.getElementById('btnPause').classList.add('active'); | |
| else if (v === 1) document.getElementById('btn1x').classList.add('active'); | |
| else if (v === 2) document.getElementById('btn2x').classList.add('active'); | |
| else if (v === 3) document.getElementById('btn3x').classList.add('active'); | |
| } | |
| document.getElementById('btnPause').addEventListener('click', () => setSpeed(0)); | |
| document.getElementById('btn1x').addEventListener('click', () => setSpeed(1)); | |
| document.getElementById('btn2x').addEventListener('click', () => setSpeed(2)); | |
| document.getElementById('btn3x').addEventListener('click', () => setSpeed(3)); | |
| // --------------------------------------------------------------- | |
| // Save / Undo / Clear | |
| // --------------------------------------------------------------- | |
| let undoStack = []; | |
| function snapshot(){ | |
| return { | |
| grid: grid.map(r=>r.slice()), | |
| fire: fire.map(r=>r.slice()), | |
| entities: entities.map(e=>Object.assign({}, e)), | |
| day, born, lost, nextId, | |
| resources: Object.assign({}, resources), | |
| ticksSinceGoblinSpawn, ticksSinceEcon | |
| }; | |
| } | |
| function restoreSnapshot(s){ | |
| grid = s.grid.map(r=>r.slice()); | |
| fire = s.fire.map(r=>r.slice()); | |
| entities = s.entities.map(e=>Object.assign({}, e)); | |
| day = s.day; born = s.born; lost = s.lost; nextId = s.nextId; | |
| resources = s.resources ? Object.assign({gold:100,wood:50,food:50,iron:0}, s.resources) : { gold:100, wood:50, food:50, iron:0 }; | |
| ticksSinceGoblinSpawn = s.ticksSinceGoblinSpawn || 0; | |
| ticksSinceEcon = s.ticksSinceEcon || 0; | |
| updateHud(); | |
| updateResourceBar(); | |
| } | |
| function pushUndoSnapshot(){ | |
| undoStack.push(snapshot()); | |
| if (undoStack.length > 20) undoStack.shift(); | |
| } | |
| document.getElementById('btnUndo').addEventListener('click', () => { | |
| const s = undoStack.pop(); | |
| if (s) restoreSnapshot(s); | |
| }); | |
| document.getElementById('btnClear').addEventListener('click', () => { | |
| pushUndoSnapshot(); | |
| makeGrid(); makeFire(); | |
| entities = []; day = 0; born = 0; lost = 0; | |
| resources = { gold: 100, wood: 50, food: 50, iron: 0 }; | |
| ticksSinceGoblinSpawn = 0; ticksSinceEcon = 0; foodShortage = false; | |
| updateHud(); | |
| updateResourceBar(); | |
| }); | |
| document.getElementById('btnSave').addEventListener('click', () => { | |
| try { | |
| localStorage.setItem('worldBuilderSave', JSON.stringify(snapshot())); | |
| const btn = document.getElementById('btnSave'); | |
| const old = btn.textContent; | |
| btn.textContent = 'SAVED!'; | |
| setTimeout(() => btn.textContent = old, 1000); | |
| } catch(err){ | |
| alert('Could not save (storage unavailable in this browser context).'); | |
| } | |
| }); | |
| (function tryLoad(){ | |
| try { | |
| const raw = localStorage.getItem('worldBuilderSave'); | |
| if (raw) restoreSnapshot(JSON.parse(raw)); | |
| } catch(err){ /* ignore, start fresh */ } | |
| })(); | |
| // --------------------------------------------------------------- | |
| // Rendering | |
| // --------------------------------------------------------------- | |
| const ENTITY_GLYPH = { | |
| tree:'π³', bush:'πΏ', rock:'πͺ¨', critter:'π', wolf:'πΊ', goblin:'πΊ', | |
| house:'π ', | |
| farmer:'π¨βπΎ', fisher:'π£', merchant:'π°', noble:'π€΅', villager:'π§', | |
| lumberjack:'πͺ', miner:'βοΈ', blacksmith:'π¨', scholar:'π', alchemist:'βοΈ', | |
| footman:'πͺ', archer:'πΉ', knight:'π‘οΈ', | |
| watchtower:'πΌ', catapult:'π£', armory:'ποΈ', guardtower:'π―', | |
| }; | |
| function drawGrid(){ | |
| for (let y=0;y<ROWS;y++){ | |
| for (let x=0;x<COLS;x++){ | |
| ctx.fillStyle = BIOME_COLORS[grid[y][x]]; | |
| ctx.fillRect(x*CELL, y*CELL, CELL+0.5, CELL+0.5); | |
| if (fire[y][x] > 0){ | |
| ctx.fillStyle = `rgba(255,${100+Math.floor(80*(1-fire[y][x]))},40,${0.35+0.5*fire[y][x]})`; | |
| ctx.fillRect(x*CELL, y*CELL, CELL+0.5, CELL+0.5); | |
| } | |
| } | |
| } | |
| // faint grid lines | |
| ctx.strokeStyle = 'rgba(255,255,255,0.04)'; | |
| ctx.lineWidth = 1; | |
| for (let x=0;x<=COLS;x++){ ctx.beginPath(); ctx.moveTo(x*CELL,0); ctx.lineTo(x*CELL,H); ctx.stroke(); } | |
| for (let y=0;y<=ROWS;y++){ ctx.beginPath(); ctx.moveTo(0,y*CELL); ctx.lineTo(W,y*CELL); ctx.stroke(); } | |
| } | |
| const HUMANITY_COLORS = { | |
| farmer:'#c9a227', fisher:'#2a7fb0', merchant:'#d4af37', noble:'#8a4fd8', | |
| villager:'#8a8a8a', lumberjack:'#6b4423', miner:'#5a5a5a', blacksmith:'#b34a1a', | |
| scholar:'#2a5ca8', alchemist:'#4a9f5a', | |
| }; | |
| function drawHumanoid(cx, cy, color){ | |
| const r = CELL*0.34; | |
| ctx.fillStyle = 'rgba(0,0,0,0.25)'; | |
| ctx.beginPath(); ctx.ellipse(cx, cy+r*0.9, r*0.55, r*0.22, 0, 0, Math.PI*2); ctx.fill(); | |
| // body | |
| ctx.fillStyle = color; | |
| ctx.beginPath(); | |
| ctx.ellipse(cx, cy+r*0.35, r*0.62, r*0.85, 0, 0, Math.PI*2); | |
| ctx.fill(); | |
| // head | |
| ctx.fillStyle = '#e8b98a'; | |
| ctx.beginPath(); | |
| ctx.arc(cx, cy-r*0.55, r*0.5, 0, Math.PI*2); | |
| ctx.fill(); | |
| } | |
| function drawEntities(){ | |
| ctx.textAlign = 'center'; | |
| ctx.textBaseline = 'middle'; | |
| ctx.font = `${Math.floor(CELL*0.95)}px Segoe UI Emoji, Segoe UI, Arial`; | |
| entities.forEach(e => { | |
| if (e.hp <= 0) return; | |
| const cx = e.x*CELL + CELL/2, cy = e.y*CELL + CELL/2; | |
| if (e.infected){ | |
| ctx.fillStyle = 'rgba(150,255,100,0.35)'; | |
| ctx.beginPath(); ctx.arc(cx, cy, CELL*0.6, 0, Math.PI*2); ctx.fill(); | |
| } | |
| if (HUMANITY_KINDS.includes(e.kind)){ | |
| drawHumanoid(cx, cy, HUMANITY_COLORS[e.kind] || '#999999'); | |
| } else { | |
| const glyph = ENTITY_GLYPH[e.kind] || 'β'; | |
| ctx.fillText(glyph, cx, cy); | |
| } | |
| // white damage flash | |
| if (e.damageFlashT > 0){ | |
| ctx.save(); | |
| ctx.globalAlpha = Math.min(0.7, e.damageFlashT*3); | |
| ctx.fillStyle = '#ffffff'; | |
| ctx.beginPath(); ctx.arc(cx, cy, CELL*0.55, 0, Math.PI*2); ctx.fill(); | |
| ctx.restore(); | |
| } | |
| // floating combat health bar for actively-engaged combat units | |
| if (e.maxHp && e.engagedT > 0){ | |
| const barW = CELL*0.9, barH = 3, barY = cy - CELL*0.75; | |
| ctx.fillStyle = 'rgba(0,0,0,0.6)'; | |
| ctx.fillRect(cx-barW/2, barY, barW, barH); | |
| ctx.fillStyle = (e.hp/e.maxHp) > 0.4 ? '#4ecb6a' : '#ff5a5a'; | |
| ctx.fillRect(cx-barW/2, barY, barW*Math.max(0, e.hp/e.maxHp), barH); | |
| } | |
| }); | |
| } | |
| function drawParticles(){ | |
| particles.forEach(p => { | |
| ctx.save(); | |
| ctx.globalAlpha = Math.max(0,p.life); | |
| ctx.fillStyle = p.color; | |
| ctx.fillRect(p.x-2, p.y-2, 4, 4); | |
| ctx.restore(); | |
| }); | |
| } | |
| function render(){ | |
| ctx.clearRect(0,0,W,H); | |
| drawGrid(); | |
| drawEntities(); | |
| drawParticles(); | |
| } | |
| // --------------------------------------------------------------- | |
| // Main loop | |
| // --------------------------------------------------------------- | |
| let lastTime = performance.now(); | |
| function loop(){ | |
| const now = performance.now(); | |
| const dt = Math.min(0.1, (now-lastTime)/1000); | |
| lastTime = now; | |
| if (simSpeed > 0){ | |
| tickAccum += dt * simSpeed; | |
| while (tickAccum >= TICK_INTERVAL){ | |
| tickAccum -= TICK_INTERVAL; | |
| tickSimulation(); | |
| } | |
| } | |
| updateParticles(); | |
| updateCombatVisuals(dt); | |
| render(); | |
| requestAnimationFrame(loop); | |
| } | |
| updateHud(); | |
| loop(); | |
| </script> | |
| </body> | |
| </html> |