Games / world-builder.html
Azivandor's picture
Update world-builder.html
888ed2a verified
Raw History Blame Contribute Delete
42.6 kB
<!DOCTYPE html>
<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>