← Back to Gists

**Factorio *Sloppii*** 😏

📝 HTML
retoor
retoor · Level 1824 ·

I wasted six cents. But actually, aesthetically I really love it and it would be total devplace game material. 😍 I don’t know how much misses because I don’t understand the game.

HTML
<!--
NOT retoor <NOT-retoor@NOT-molodetz.nl>
Factorio-inspired factory automation game - single self-contained HTML file.
-->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Factorio Web</title>
<style>
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
:root{
  --bg:#1a1a1a;--panel-bg:#222;--border:#444;--text:#ddd;--text-dim:#999;
  --accent:#f90;--iron:#c97a5a;--copper:#e8964a;--coal:#555;--stone:#999;
  --belt:#7a7a7a;--inserter:#c90;--furnace:#666;--assembler:#4a6a9a;
  --power:#5a8aca;--engine:#8a6a4a;--boiler:#555;--chest:#8a6a3a;
  --btn-bg:#333;--btn-hover:#444;--danger:#a33;
}
html,body{width:100%;height:100%;overflow:hidden;background:var(--bg);color:var(--text);font:14px/1.4 monospace}
#game{display:flex;width:100%;height:100%}
#viewport{flex:1;position:relative;overflow:hidden}
#viewport canvas{display:block}
#sidebar{width:260px;background:var(--panel-bg);border-left:2px solid var(--border);display:flex;flex-direction:column;overflow-y:auto}
#sidebar h2{padding:10px 12px 4px;font-size:13px;color:var(--accent);text-transform:uppercase;letter-spacing:1px}
#inventory-list{padding:0 12px;flex:1;overflow-y:auto}
.inv-row{display:flex;align-items:center;gap:6px;padding:3px 0;border-bottom:1px solid #2a2a2a;font-size:12px}
.inv-icon{width:20px;height:20px;border-radius:2px;flex-shrink:0}
.inv-name{flex:1}
.inv-count{color:var(--text-dim)}
#hotbar{display:flex;gap:3px;padding:8px 12px;background:var(--panel-bg);border-top:2px solid var(--border);flex-wrap:wrap}
.hotbar-slot{width:36px;height:36px;border:2px solid var(--border);border-radius:3px;background:#1a1a1a;cursor:pointer;display:flex;align-items:center;justify-content:center;font-size:10px;color:var(--text-dim);position:relative}
.hotbar-slot.selected{border-color:var(--accent);box-shadow:0 0 6px var(--accent)}
.hotbar-slot .hotbar-key{position:absolute;top:1px;left:3px;font-size:8px;color:var(--text-dim)}
#tooltip{position:absolute;pointer-events:none;background:#111;border:1px solid var(--accent);padding:6px 10px;border-radius:3px;font-size:12px;display:none;z-index:100;white-space:nowrap}
#crafting-panel{position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);background:var(--panel-bg);border:2px solid var(--accent);border-radius:6px;padding:16px;min-width:360px;max-height:70vh;overflow-y:auto;display:none;z-index:50}
#crafting-panel.open{display:block}
#crafting-panel h2{margin-bottom:12px;color:var(--accent)}
.craft-recipe{display:flex;align-items:center;gap:8px;padding:6px 8px;cursor:pointer;border-radius:3px;margin-bottom:2px}
.craft-recipe:hover{background:#333}
.craft-recipe.locked{opacity:0.35;cursor:not-allowed}
.craft-recipe .craft-icon{width:24px;height:24px;border-radius:2px;flex-shrink:0}
.craft-recipe .craft-name{flex:1}
.craft-recipe .craft-ingredients{font-size:10px;color:var(--text-dim)}
.craft-recipe .craft-btn{background:var(--btn-bg);border:1px solid var(--border);color:var(--text);padding:3px 10px;border-radius:3px;cursor:pointer;font:12px monospace}
.craft-recipe .craft-btn:hover{background:var(--btn-hover)}
.craft-recipe.locked .craft-btn{display:none}
#controls-bar{display:flex;gap:4px;padding:8px 12px;border-top:2px solid var(--border);flex-wrap:wrap}
#controls-bar button{background:var(--btn-bg);border:1px solid var(--border);color:var(--text);padding:4px 10px;border-radius:3px;cursor:pointer;font:11px monospace}
#controls-bar button:hover{background:var(--btn-hover)}
#entity-info{padding:8px 12px;font-size:11px;color:var(--text-dim);min-height:40px}
#minimap{width:100%;aspect-ratio:1;border-top:2px solid var(--border)}
</style>
</head>
<body>
<div id="game">
  <div id="viewport">
    <canvas id="canvas"></canvas>
    <div id="tooltip"></div>
    <div id="crafting-panel"><h2>Hand Crafting</h2><div id="crafting-list"></div></div>
  </div>
  <div id="sidebar">
    <h2>Inventory</h2>
    <div id="inventory-list"></div>
    <div id="entity-info"></div>
    <div id="hotbar"></div>
    <div id="controls-bar">
      <button id="btn-save" title="Save game to browser storage">Save</button>
      <button id="btn-load" title="Load saved game">Load</button>
      <button id="btn-reset" title="Start new game">Reset</button>
    </div>
    <canvas id="minimap" width="128" height="128"></canvas>
  </div>
</div>
<script>
const TILE = 32, MW = 64, MH = 64, TPS = 30;
const DIRS = { up: [0,-1], down: [0,1], left: [-1,0], right: [1,0] };
const DIR_KEYS = ['up','down','left','right'];

const ITEMS = {
  'iron-ore':       { name:'Iron Ore',       color:'#c97a5a', cat:'raw' },
  'copper-ore':     { name:'Copper Ore',     color:'#e8964a', cat:'raw' },
  'coal':           { name:'Coal',           color:'#555555', cat:'raw', fuel:400 },
  'stone':          { name:'Stone',          color:'#999999', cat:'raw' },
  'iron-plate':     { name:'Iron Plate',     color:'#aaaacc', cat:'plate' },
  'copper-plate':   { name:'Copper Plate',   color:'#dd9966', cat:'plate' },
  'stone-brick':    { name:'Stone Brick',    color:'#aa9977', cat:'plate' },
  'iron-gear-wheel':{ name:'Iron Gear Wheel',color:'#888899', cat:'intermediate' },
  'copper-cable':   { name:'Copper Cable',   color:'#cc8855', cat:'intermediate' },
  'electronic-circuit':{ name:'Electronic Circuit', color:'#44aa44', cat:'intermediate' },
  'transport-belt': { name:'Transport Belt', color:'#7a7a7a', cat:'logistics', entity:'transport-belt' },
  'inserter':       { name:'Inserter',       color:'#cc9900', cat:'logistics', entity:'inserter' },
  'furnace':        { name:'Furnace',        color:'#666666', cat:'production', entity:'furnace' },
  'assembling-machine-1':{ name:'Assembling Machine 1', color:'#4a6a9a', cat:'production', entity:'assembling-machine-1' },
  'power-pole':     { name:'Power Pole',     color:'#5a8aca', cat:'energy', entity:'power-pole' },
  'boiler':         { name:'Boiler',          color:'#555555', cat:'energy', entity:'boiler' },
  'steam-engine':   { name:'Steam Engine',    color:'#8a6a4a', cat:'energy', entity:'steam-engine' },
  'wooden-chest':   { name:'Wooden Chest',    color:'#8a6a3a', cat:'logistics', entity:'wooden-chest' },
};

const RECIPES = {
  'iron-gear-wheel':      { time:1, ing:{'iron-plate':2}, out:'iron-gear-wheel', n:1 },
  'copper-cable':         { time:1, ing:{'copper-plate':1}, out:'copper-cable', n:2 },
  'electronic-circuit':   { time:2, ing:{'iron-plate':1,'copper-cable':3}, out:'electronic-circuit', n:1 },
  'transport-belt':       { time:1, ing:{'iron-plate':1,'iron-gear-wheel':1}, out:'transport-belt', n:1 },
  'inserter':             { time:2, ing:{'iron-plate':1,'iron-gear-wheel':1,'electronic-circuit':1}, out:'inserter', n:1 },
  'furnace':              { time:3, ing:{'stone-brick':5}, out:'furnace', n:1 },
  'assembling-machine-1': { time:4, ing:{'iron-plate':3,'iron-gear-wheel':5,'electronic-circuit':3}, out:'assembling-machine-1', n:1 },
  'power-pole':           { time:1, ing:{'copper-cable':2,'iron-plate':2}, out:'power-pole', n:1 },
  'boiler':               { time:3, ing:{'furnace':1,'iron-plate':4}, out:'boiler', n:1 },
  'steam-engine':         { time:3, ing:{'iron-plate':5,'iron-gear-wheel':5}, out:'steam-engine', n:1 },
  'wooden-chest':         { time:1, ing:{'iron-plate':4}, out:'wooden-chest', n:1 },
  'iron-plate':           { time:1, ing:{'iron-ore':1}, out:'iron-plate', n:1, furnace:true },
  'copper-plate':         { time:1, ing:{'copper-ore':1}, out:'copper-plate', n:1, furnace:true },
  'stone-brick':          { time:1, ing:{'stone':1}, out:'stone-brick', n:1, furnace:true },
};

const ENTITY_DEFS = {
  'transport-belt':       { name:'Transport Belt', w:1,h:1, rotatable:true },
  'inserter':             { name:'Inserter', w:1,h:1, rotatable:true, power:1 },
  'furnace':              { name:'Furnace', w:1,h:1, power:15 },
  'assembling-machine-1': { name:'Assembling Machine 1', w:3,h:3, power:50 },
  'power-pole':           { name:'Power Pole', w:1,h:1, supply:true, radius:7 },
  'boiler':               { name:'Boiler', w:1,h:2, rotatable:true },
  'steam-engine':         { name:'Steam Engine', w:2,h:2, generator:true },
  'wooden-chest':         { name:'Wooden Chest', w:1,h:1 },
};

let game, canvas, ctx, tooltip, craftingPanel, craftingList, inventoryList, hotbarEl, entityInfo, minimap, minimapCtx;
let keys = {}, mouseX = 0, mouseY = 0, hoverTile = null, hoverEntity = null;
let craftingOpen = false, handCraft = null, handCraftTicks = 0;

function initDOM() {
  canvas = document.getElementById('canvas');
  ctx = canvas.getContext('2d');
  tooltip = document.getElementById('tooltip');
  craftingPanel = document.getElementById('crafting-panel');
  craftingList = document.getElementById('crafting-list');
  inventoryList = document.getElementById('inventory-list');
  hotbarEl = document.getElementById('hotbar');
  entityInfo = document.getElementById('entity-info');
  minimap = document.getElementById('minimap');
  minimapCtx = minimap.getContext('2d');
}

function newGame() {
  return {
    tiles: [],
    entities: [],
    beltItems: [],
    player: { x:32, y:32, dir:'down' },
    inventory: {},
    hotbar: new Array(9).fill(null),
    selectedHotbar: 0,
    tick: 0,
    researched: {},
    powerNet: { poles:[], poweredTiles:new Set() },
  };
}

function seedRandom(s) {
  return () => { s = (s * 16807 + 0) % 2147483647; return (s - 1) / 2147483646; };
}

function genWorld(g) {
  const rng = seedRandom(42);
  g.tiles = new Array(MW);
  for (let x = 0; x < MW; x++) {
    g.tiles[x] = new Array(MH);
    for (let y = 0; y < MH; y++) {
      g.tiles[x][y] = { type:'grass', resource:null };
    }
  }
  for (let x = 0; x < MW; x++) {
    for (let y = 0; y < MH; y++) {
      if (rng() < 0.02) g.tiles[x][y].type = 'water';
    }
  }
  const patches = [
    { type:'iron-ore', count:8, size:5, density:300 },
    { type:'copper-ore', count:6, size:5, density:300 },
    { type:'coal', count:6, size:5, density:300 },
    { type:'stone', count:4, size:4, density:200 },
  ];
  for (const p of patches) {
    for (let i = 0; i < p.count; i++) {
      let cx = Math.floor(rng() * (MW - 8)) + 4;
      let cy = Math.floor(rng() * (MH - 8)) + 4;
      if (cx > 28 && cx < 36 && cy > 28 && cy < 36) continue;
      for (let dx = -p.size; dx <= p.size; dx++) {
        for (let dy = -p.size; dy <= p.size; dy++) {
          const x = cx + dx, y = cy + dy;
          if (x < 0 || x >= MW || y < 0 || y >= MH) continue;
          if (g.tiles[x][y].type === 'water') continue;
          const dist = Math.sqrt(dx*dx + dy*dy);
          if (dist > p.size) continue;
          const amount = Math.floor(p.density * (1 - dist / (p.size + 1)) * (0.7 + rng() * 0.6));
          if (amount <= 0) continue;
          if (!g.tiles[x][y].resource) {
            g.tiles[x][y].resource = { type:p.type, amount };
          }
        }
      }
    }
  }
  g.entities = new Array(MW);
  for (let x = 0; x < MW; x++) { g.entities[x] = new Array(MH); g.entities[x].fill(null); }
  g.beltItems = [];
  g.researched = { 'iron-gear-wheel':true, 'copper-cable':true, 'electronic-circuit':true, 'iron-plate':true, 'copper-plate':true, 'stone-brick':true };
  addToInv(g, 'transport-belt', 20);
  addToInv(g, 'inserter', 5);
  addToInv(g, 'furnace', 2);
  addToInv(g, 'power-pole', 5);
  addToInv(g, 'boiler', 2);
  addToInv(g, 'steam-engine', 2);
  addToInv(g, 'wooden-chest', 2);
  addToInv(g, 'assembling-machine-1', 1);
  g.hotbar[0] = 'transport-belt';
  g.hotbar[1] = 'inserter';
  g.hotbar[2] = 'furnace';
  g.hotbar[3] = 'power-pole';
  g.hotbar[4] = 'boiler';
  g.hotbar[5] = 'steam-engine';
  g.hotbar[6] = 'wooden-chest';
  g.hotbar[7] = 'assembling-machine-1';
}

function addToInv(g, item, n) {
  if (!g.inventory[item]) g.inventory[item] = 0;
  g.inventory[item] += n;
}

function hasItems(g, item, n) {
  return (g.inventory[item] || 0) >= n;
}

function takeItems(g, item, n) {
  if (!hasItems(g, item, n)) return false;
  g.inventory[item] -= n;
  if (g.inventory[item] <= 0) delete g.inventory[item];
  return true;
}

function canCraft(g, recipeId) {
  const r = RECIPES[recipeId];
  if (!r) return false;
  if (!g.researched[recipeId]) return false;
  for (const [item, n] of Object.entries(r.ing)) {
    if (!hasItems(g, item, n)) return false;
  }
  return true;
}

function doCraft(g, recipeId) {
  const r = RECIPES[recipeId];
  if (!canCraft(g, recipeId)) return false;
  for (const [item, n] of Object.entries(r.ing)) {
    takeItems(g, item, n);
  }
  addToInv(g, r.out, r.n);
  g.researched[r.out] = true;
  for (const rid of Object.keys(RECIPES)) {
    if (!g.researched[rid] && canCraft(g, rid)) {
      g.researched[rid] = true;
    }
  }
  return true;
}

function startHandCraft(recipeId) {
  handCraft = recipeId;
  handCraftTicks = RECIPES[recipeId].time * TPS;
}

function playerTile(g) { return { x:Math.floor(g.player.x), y:Math.floor(g.player.y) }; }

function validTile(x, y) {
  return x >= 0 && x < MW && y >= 0 && y < MH && game.tiles[x][y].type !== 'water';
}

function canPlaceEntity(g, x, y, etype) {
  const def = ENTITY_DEFS[etype];
  if (!def) return false;
  const w = def.w || 1, h = def.h || 1;
  for (let dx = 0; dx < w; dx++) {
    for (let dy = 0; dy < h; dy++) {
      const tx = x + dx, ty = y + dy;
      if (!validTile(tx, ty)) return false;
      if (g.entities[tx][ty]) return false;
    }
  }
  return true;
}

function placeEntity(g, x, y, etype, dir) {
  if (!canPlaceEntity(g, x, y, etype)) return false;
  if (!hasItems(g, etype, 1)) return false;
  takeItems(g, etype, 1);
  const def = ENTITY_DEFS[etype];
  const w = def.w || 1, h = def.h || 1;
  const ent = { type:etype, x, y, w, h };
  if (def.rotatable) ent.dir = dir || 'up';
  if (etype === 'furnace') { ent.fuel = 0; ent.input = null; ent.output = null; ent.progress = 0; ent.progressMax = 60; }
  if (etype === 'assembling-machine-1') { ent.recipe = null; ent.inputs = {}; ent.output = null; ent.progress = 0; ent.progressMax = 90; }
  if (etype === 'boiler') { ent.fuel = 0; ent.water = 100; }
  if (etype === 'steam-engine') { ent.generating = false; }
  if (etype === 'inserter') { ent.heldItem = null; ent.swingTimer = 0; }
  if (etype === 'wooden-chest') { ent.items = {}; }
  if (etype === 'transport-belt') { ent.dir = dir || 'up'; }
  for (let dx = 0; dx < w; dx++) {
    for (let dy = 0; dy < h; dy++) {
      g.entities[x + dx][y + dy] = ent;
    }
  }
  return true;
}

function removeEntity(g, x, y) {
  const ent = g.entities[x][y];
  if (!ent) return null;
  const rx = ent.x, ry = ent.y, etype = ent.type;
  for (let dx = 0; dx < ent.w; dx++) {
    for (let dy = 0; dy < ent.h; dy++) {
      g.entities[rx + dx][ry + dy] = null;
    }
  }
  g.beltItems = g.beltItems.filter(bi => !(bi.x === rx && bi.y === ry));
  if (ent.type === 'wooden-chest' && ent.items) {
    for (const [item, n] of Object.entries(ent.items)) {
      addToInv(g, item, n);
    }
  }
  addToInv(g, etype, 1);
  return ent;
}

function mineResource(g, x, y) {
  const tile = g.tiles[x][y];
  if (!tile || !tile.resource || tile.resource.amount <= 0) return false;
  const res = tile.resource;
  const mined = Math.min(5, res.amount);
  res.amount -= mined;
  addToInv(g, res.type, mined);
  if (res.amount <= 0) tile.resource = null;
  return true;
}

function rebuildPowerNet(g) {
  g.powerNet.poles = [];
  g.powerNet.poweredTiles = new Set();
  const visited = new Set();
  for (let x = 0; x < MW; x++) {
    for (let y = 0; y < MH; y++) {
      const ent = g.entities[x][y];
      if (!ent || ent.type !== 'power-pole' || visited.has(`${ent.x},${ent.y}`)) continue;
      const net = [];
      const queue = [ent];
      visited.add(`${ent.x},${ent.y}`);
      while (queue.length) {
        const pole = queue.shift();
        net.push(pole);
        for (let dx = -pole.w - 7; dx <= pole.w + pole.w + 7; dx++) {
          for (let dy = -pole.h - 7; dy <= pole.h + pole.h + 7; dy++) {
            const px = pole.x + dx, py = pole.y + dy;
            if (px < 0 || px >= MW || py < 0 || py >= MH) continue;
            const e = g.entities[px][py];
            if (e && e.type === 'power-pole' && !visited.has(`${e.x},${e.y}`)) {
              visited.add(`${e.x},${e.y}`);
              queue.push(e);
            }
          }
        }
      }
      g.powerNet.poles.push(net);
    }
  }
  let hasPower = false;
  for (const net of g.powerNet.poles) {
    let netPowered = false;
    for (const pole of net) {
      for (let dx = -7; dx <= 7; dx++) {
        for (let dy = -7; dy <= 7; dy++) {
          const ex = pole.x + dx, ey = pole.y + dy;
          if (ex < 0 || ex >= MW || ey < 0 || ey >= MH) continue;
          const e = g.entities[ex][ey];
          if (e && e.type === 'steam-engine' && e.generating) {
            netPowered = true;
          }
        }
      }
    }
    if (netPowered) {
      hasPower = true;
      for (const pole of net) {
        for (let dx = -pole.w - 7; dx <= pole.w + 7 + 7; dx++) {
          for (let dy = -pole.h - 7; dy <= pole.h + 7 + 7; dy++) {
            const px = pole.x + dx, py = pole.y + dy;
            if (px >= 0 && px < MW && py >= 0 && py < MH) {
              g.powerNet.poweredTiles.add(`${px},${py}`);
            }
          }
        }
      }
    }
  }
  return hasPower;
}

function isPowered(g, x, y) {
  return g.powerNet.poweredTiles.has(`${x},${y}`);
}

function updateEntities(g) {
  updateBelts(g);
  updateInserters(g);
  updateFurnaces(g);
  updateAssemblers(g);
  updateBoilers(g);
  rebuildPowerNet(g);
}

function beltNextTile(x, y, dir) {
  const [dx, dy] = DIRS[dir];
  return [x + dx, y + dy];
}

function updateBelts(g) {
  const moved = new Set();
  const newBeltItems = [];
  for (const bi of g.beltItems) {
    bi.progress += 1 / (TPS * 0.5);
    if (bi.progress >= 1) {
      const ent = g.entities[bi.x]?.[bi.y];
      if (!ent || ent.type !== 'transport-belt') { continue; }
      const [nx, ny] = beltNextTile(bi.x, bi.y, ent.dir);
      if (nx < 0 || nx >= MW || ny < 0 || ny >= MH) {
        bi.progress = 0.99;
        newBeltItems.push(bi);
        continue;
      }
      const nextEnt = g.entities[nx]?.[ny];
      if (nextEnt && nextEnt.type === 'transport-belt') {
        const key = `${nx},${ny}`;
        if (!moved.has(key)) {
          bi.x = nx; bi.y = ny; bi.progress = 0; bi.dir = nextEnt.dir;
          moved.add(key);
          newBeltItems.push(bi);
        } else {
          bi.progress = 0.99;
          newBeltItems.push(bi);
        }
      } else if (nextEnt && (nextEnt.type === 'furnace' || nextEnt.type === 'assembling-machine-1' || nextEnt.type === 'boiler' || nextEnt.type === 'wooden-chest')) {
        if (tryInsertIntoEntity(g, nextEnt, bi.item)) {
          continue;
        }
        bi.progress = 0.99;
        newBeltItems.push(bi);
      } else {
        bi.progress = 0.99;
        newBeltItems.push(bi);
      }
    } else {
      newBeltItems.push(bi);
    }
  }
  g.beltItems = newBeltItems;
}

function tryInsertIntoEntity(g, ent, itemType) {
  if (ent.type === 'furnace') {
    if (!ent.input && RECIPES[itemType + '-plate'] && RECIPES[itemType + '-plate'].furnace) {
      ent.input = itemType;
      return true;
    }
    if (!ent.fuel || ent.fuel <= 0) {
      if (itemType === 'coal') { ent.fuel = ITEMS.coal.fuel; return true; }
    }
  }
  if (ent.type === 'assembling-machine-1') {
    if (!ent.inputs[itemType]) ent.inputs[itemType] = 0;
    ent.inputs[itemType]++;
    return true;
  }
  if (ent.type === 'boiler') {
    if (!ent.fuel || ent.fuel <= 0) {
      if (itemType === 'coal') { ent.fuel = ITEMS.coal.fuel; return true; }
    }
  }
  if (ent.type === 'wooden-chest') {
    if (!ent.items[itemType]) ent.items[itemType] = 0;
    ent.items[itemType]++;
    return true;
  }
  return false;
}

function updateInserters(g) {
  for (let x = 0; x < MW; x++) {
    for (let y = 0; y < MH; y++) {
      const ent = g.entities[x]?.[y];
      if (!ent || ent.type !== 'inserter' || ent.x !== x || ent.y !== y) continue;
      if (ent.heldItem && ent.swingTimer <= 0) {
        const [dx, dy] = DIRS[ent.dir];
        const dropX = ent.x + dx, dropY = ent.y + dy;
        if (dropX >= 0 && dropX < MW && dropY >= 0 && dropY < MH) {
          const target = g.entities[dropX]?.[dropY];
          if (target && tryInsertIntoEntity(g, target, ent.heldItem)) {
            ent.heldItem = null;
          } else if (target && target.type === 'transport-belt') {
            g.beltItems.push({ x:dropX, y:dropY, progress:0, item:ent.heldItem, dir:target.dir });
            ent.heldItem = null;
          } else if (!target) {
            g.beltItems.push({ x:dropX, y:dropY, progress:0, item:ent.heldItem, dir:ent.dir });
            ent.heldItem = null;
          }
        }
      }
      if (!ent.heldItem || ent.swingTimer > 0) {
        if (ent.swingTimer > 0) { ent.swingTimer--; continue; }
        const [px, py] = DIRS[ent.dir];
        const pickX = ent.x - px, pickY = ent.y - py;
        if (pickX < 0 || pickX >= MW || pickY < 0 || pickY >= MH) continue;
        const srcEnt = g.entities[pickX]?.[pickY];
        if (srcEnt && srcEnt.type === 'furnace' && srcEnt.output) {
          ent.heldItem = srcEnt.output;
          srcEnt.output = null;
          ent.swingTimer = Math.floor(TPS * 0.3);
        }
        if (srcEnt && srcEnt.type === 'assembling-machine-1' && srcEnt.output) {
          ent.heldItem = srcEnt.output;
          srcEnt.output = null;
          ent.swingTimer = Math.floor(TPS * 0.3);
        }
        if (srcEnt && srcEnt.type === 'wooden-chest' && srcEnt.items) {
          for (const [item, n] of Object.entries(srcEnt.items)) {
            if (n > 0) {
              ent.heldItem = item;
              srcEnt.items[item]--;
              if (srcEnt.items[item] <= 0) delete srcEnt.items[item];
              ent.swingTimer = Math.floor(TPS * 0.3);
              break;
            }
          }
        }
        const beltItemIdx = g.beltItems.findIndex(bi => bi.x === pickX && bi.y === pickY && bi.progress > 0.2);
        if (beltItemIdx >= 0 && !ent.heldItem) {
          ent.heldItem = g.beltItems[beltItemIdx].item;
          g.beltItems.splice(beltItemIdx, 1);
          ent.swingTimer = Math.floor(TPS * 0.3);
        }
      }
    }
  }
}

function updateFurnaces(g) {
  for (let x = 0; x < MW; x++) {
    for (let y = 0; y < MH; y++) {
      const ent = g.entities[x]?.[y];
      if (!ent || ent.type !== 'furnace' || ent.x !== x || ent.y !== y) continue;
      const powered = isPowered(g, ent.x, ent.y);
      if (ent.fuel > 0 || powered) {
        if (!powered && ent.fuel > 0) ent.fuel -= 0.5;
        if (ent.input) {
          const recipeId = ent.input + '-plate';
          const r = RECIPES[recipeId];
          if (r && r.furnace) {
            if (ent.progress >= ent.progressMax) {
              ent.output = r.out;
              ent.input = null;
              ent.progress = 0;
            } else {
              ent.progress += 1;
            }
          }
        }
      }
    }
  }
}

function updateAssemblers(g) {
  for (let x = 0; x < MW; x++) {
    for (let y = 0; y < MH; y++) {
      const ent = g.entities[x]?.[y];
      if (!ent || ent.type !== 'assembling-machine-1' || ent.x !== x || ent.y !== y) continue;
      if (!isPowered(g, ent.x, ent.y)) continue;
      if (!ent.recipe) {
        for (const [rid, r] of Object.entries(RECIPES)) {
          if (r.furnace) continue;
          let hasAll = true;
          for (const [item, n] of Object.entries(r.ing)) {
            if ((ent.inputs[item] || 0) < n) { hasAll = false; break; }
          }
          if (hasAll && (!ent.output || ent.output === r.out)) {
            ent.recipe = rid;
            break;
          }
        }
      }
      if (ent.recipe) {
        const r = RECIPES[ent.recipe];
        if (r && !r.furnace) {
          if (ent.progress >= ent.progressMax) {
            ent.output = r.out;
            for (const [item, n] of Object.entries(r.ing)) {
              ent.inputs[item] = (ent.inputs[item] || 0) - n;
            }
            ent.recipe = null;
            ent.progress = 0;
          } else {
            ent.progress += 1;
          }
        }
      }
    }
  }
}

function updateBoilers(g) {
  for (let x = 0; x < MW; x++) {
    for (let y = 0; y < MH; y++) {
      const ent = g.entities[x]?.[y];
      if (!ent || ent.type !== 'boiler' || ent.x !== x || ent.y !== y) continue;
      if (ent.fuel > 0) {
        ent.fuel -= 0.3;
        ent.water = Math.min(100, ent.water + 2);
      }
    }
  }
  for (let x = 0; x < MW; x++) {
    for (let y = 0; y < MH; y++) {
      const ent = g.entities[x]?.[y];
      if (!ent || ent.type !== 'steam-engine' || ent.x !== x || ent.y !== y) continue;
      let hasBoiler = false;
      for (let dx = -3; dx <= 3; dx++) {
        for (let dy = -3; dy <= 3; dy++) {
          const bx = ent.x + dx, by = ent.y + dy;
          if (bx >= 0 && bx < MW && by >= 0 && by < MH) {
            const be = g.entities[bx]?.[by];
            if (be && be.type === 'boiler' && be.water > 0 && be.fuel > 0) {
              hasBoiler = true;
              be.water -= 1;
              break;
            }
          }
        }
        if (hasBoiler) break;
      }
      ent.generating = hasBoiler;
    }
  }
}

function render(g) {
  const vw = canvas.width, vh = canvas.height;
  const camX = g.player.x * TILE - vw / 2 + TILE / 2;
  const camY = g.player.y * TILE - vh / 2 + TILE / 2;
  ctx.fillStyle = '#111';
  ctx.fillRect(0, 0, vw, vh);

  const startTX = Math.max(0, Math.floor(camX / TILE) - 1);
  const startTY = Math.max(0, Math.floor(camY / TILE) - 1);
  const endTX = Math.min(MW - 1, Math.ceil((camX + vw) / TILE) + 1);
  const endTY = Math.min(MH - 1, Math.ceil((camY + vh) / TILE) + 1);

  for (let tx = startTX; tx <= endTX; tx++) {
    for (let ty = startTY; ty <= endTY; ty++) {
      const sx = tx * TILE - camX;
      const sy = ty * TILE - camY;
      const tile = g.tiles[tx][ty];
      if (tile.type === 'water') {
        ctx.fillStyle = '#224488';
        ctx.fillRect(sx, sy, TILE, TILE);
        ctx.fillStyle = '#335599';
        ctx.fillRect(sx + 2, sy + 2, TILE - 4, TILE - 4);
      } else {
        ctx.fillStyle = '#3d5a1e';
        ctx.fillRect(sx, sy, TILE, TILE);
        ctx.fillStyle = '#4a6b28';
        ctx.fillRect(sx + 1, sy + 1, TILE - 2, TILE - 2);
      }
      if (tile.resource) {
        const col = ITEMS[tile.resource.type]?.color || '#888';
        const alpha = Math.min(1, tile.resource.amount / 200);
        ctx.fillStyle = col;
        ctx.globalAlpha = 0.3 + alpha * 0.7;
        ctx.fillRect(sx + 4, sy + 4, TILE - 8, TILE - 8);
        ctx.globalAlpha = 1;
        ctx.fillStyle = col;
        ctx.fillRect(sx + TILE/2 - 3, sy + TILE/2 - 3, 6, 6);
      }
    }
  }

  const rendered = new Set();
  for (let tx = startTX; tx <= endTX; tx++) {
    for (let ty = startTY; ty <= endTY; ty++) {
      const ent = g.entities[tx]?.[ty];
      if (!ent) continue;
      const key = `${ent.x},${ent.y}`;
      if (rendered.has(key)) continue;
      rendered.add(key);
      const sx = ent.x * TILE - camX;
      const sy = ent.y * TILE - camY;
      if (ent.type === 'transport-belt') renderBelt(ctx, sx, sy, ent);
      else if (ent.type === 'furnace') renderFurnace(ctx, sx, sy, ent);
      else if (ent.type === 'assembling-machine-1') renderAssembler(ctx, sx, sy, ent);
      else if (ent.type === 'inserter') renderInserter(ctx, sx, sy, ent);
      else if (ent.type === 'power-pole') renderPole(ctx, sx, sy, ent);
      else if (ent.type === 'boiler') renderBoiler(ctx, sx, sy, ent);
      else if (ent.type === 'steam-engine') renderEngine(ctx, sx, sy, ent);
      else if (ent.type === 'wooden-chest') renderChest(ctx, sx, sy, ent);
    }
  }

  for (const bi of g.beltItems) {
    if (bi.x < startTX || bi.x > endTX || bi.y < startTY || bi.y > endTY) continue;
    const [ddx, ddy] = DIRS[bi.dir] || [0, 0];
    const bx = bi.x * TILE - camX + TILE/2 + ddx * (bi.progress - 0.5) * TILE;
    const by = bi.y * TILE - camY + TILE/2 + ddy * (bi.progress - 0.5) * TILE;
    const col = ITEMS[bi.item]?.color || '#fff';
    ctx.fillStyle = col;
    ctx.fillRect(bx - 3, by - 3, 6, 6);
  }

  const px = g.player.x * TILE - camX;
  const py = g.player.y * TILE - camY;
  ctx.fillStyle = '#ffcc00';
  ctx.fillRect(px + TILE/2 - 5, py + TILE/2 - 5, 10, 10);
  ctx.fillStyle = '#000';
  ctx.fillRect(px + TILE/2 - 2, py + TILE/2 - 2, 4, 4);
}

function renderBelt(ctx, x, y, ent) {
  ctx.fillStyle = '#555';
  ctx.fillRect(x + 2, y + 2, TILE - 4, TILE - 4);
  ctx.fillStyle = '#777';
  const cx = x + TILE/2, cy = y + TILE/2;
  const [dx, dy] = DIRS[ent.dir];
  ctx.fillRect(cx - 2 + dx * 8, cy - 2 + dy * 8, 4, 4);
}

function renderFurnace(ctx, x, y, ent) {
  ctx.fillStyle = '#555';
  ctx.fillRect(x + 1, y + 1, TILE - 2, TILE - 2);
  ctx.fillStyle = ent.fuel > 0 || ent.progress > 0 ? '#f80' : '#333';
  ctx.fillRect(x + 6, y + 6, TILE - 12, TILE - 12);
  if (ent.progress > 0) {
    const pct = ent.progress / ent.progressMax;
    ctx.fillStyle = '#fa0';
    ctx.fillRect(x + 4, y + TILE - 8, (TILE - 8) * pct, 4);
  }
}

function renderAssembler(ctx, x, y, ent) {
  ctx.fillStyle = '#3a5a8a';
  ctx.fillRect(x + 1, y + 1, ent.w * TILE - 2, ent.h * TILE - 2);
  ctx.fillStyle = '#4a6a9a';
  ctx.fillRect(x + 4, y + 4, ent.w * TILE - 8, ent.h * TILE - 8);
  if (ent.progress > 0) {
    const pct = ent.progress / ent.progressMax;
    ctx.fillStyle = '#0f0';
    ctx.fillRect(x + 4, y + ent.h * TILE - 8, (ent.w * TILE - 8) * pct, 4);
  }
  ctx.fillStyle = '#5a8aca';
  ctx.fillRect(x + ent.w * TILE / 2 - 4, y + ent.h * TILE / 2 - 4, 8, 8);
}

function renderInserter(ctx, x, y, ent) {
  ctx.fillStyle = '#444';
  ctx.fillRect(x + 10, y + 10, TILE - 20, TILE - 20);
  const [dx, dy] = DIRS[ent.dir];
  const armX = x + TILE/2 + dx * 10;
  const armY = y + TILE/2 + dy * 10;
  ctx.strokeStyle = '#666';
  ctx.lineWidth = 2;
  ctx.beginPath();
  ctx.moveTo(x + TILE/2, y + TILE/2);
  ctx.lineTo(armX, armY);
  ctx.stroke();
  if (ent.heldItem) {
    ctx.fillStyle = ITEMS[ent.heldItem]?.color || '#fff';
    ctx.fillRect(armX - 3, armY - 3, 6, 6);
  }
}

function renderPole(ctx, x, y, ent) {
  ctx.fillStyle = '#5a8aca';
  ctx.fillRect(x + TILE/2 - 2, y + 2, 4, TILE - 4);
  ctx.fillRect(x + 2, y + TILE/2 - 2, TILE - 4, 4);
}

function renderBoiler(ctx, x, y, ent) {
  ctx.fillStyle = '#444';
  ctx.fillRect(x + 1, y + 1, TILE - 2, ent.h * TILE - 2);
  ctx.fillStyle = ent.fuel > 0 ? '#f52' : '#333';
  ctx.fillRect(x + 4, y + 4, TILE - 8, ent.h * TILE - 8);
}

function renderEngine(ctx, x, y, ent) {
  ctx.fillStyle = '#6a5a3a';
  ctx.fillRect(x + 1, y + 1, ent.w * TILE - 2, ent.h * TILE - 2);
  ctx.fillStyle = ent.generating ? '#fa0' : '#555';
  ctx.fillRect(x + 4, y + 4, ent.w * TILE - 8, ent.h * TILE - 8);
  ctx.fillStyle = '#8a6a4a';
  ctx.fillRect(x + ent.w * TILE / 2 - 6, y + ent.h * TILE / 2 - 6, 12, 12);
}

function renderChest(ctx, x, y, ent) {
  ctx.fillStyle = '#6a5a2a';
  ctx.fillRect(x + 2, y + 2, TILE - 4, TILE - 4);
  ctx.fillStyle = '#8a7a3a';
  ctx.fillRect(x + 6, y + 4, TILE - 12, 4);
  ctx.fillRect(x + 10, y + TILE - 8, TILE - 20, 4);
}

function renderMinimap(g) {
  minimapCtx.fillStyle = '#111';
  minimapCtx.fillRect(0, 0, 128, 128);
  const s = 2;
  for (let x = 0; x < MW; x++) {
    for (let y = 0; y < MH; y++) {
      const tile = g.tiles[x][y];
      if (tile.type === 'water') { minimapCtx.fillStyle = '#246'; }
      else if (tile.resource) { minimapCtx.fillStyle = ITEMS[tile.resource.type]?.color || '#888'; }
      else if (g.entities[x]?.[y]) { minimapCtx.fillStyle = '#666'; }
      else { minimapCtx.fillStyle = '#2a3a12'; }
      minimapCtx.fillRect(x * s, y * s, s, s);
    }
  }
  minimapCtx.fillStyle = '#ff0';
  minimapCtx.fillRect(g.player.x * s, g.player.y * s, s, s);
}

function updateUI(g) {
  const invKeys = Object.keys(g.inventory).sort((a, b) => {
    const ca = ITEMS[a]?.cat || 'z', cb = ITEMS[b]?.cat || 'z';
    if (ca !== cb) return ca.localeCompare(cb);
    return a.localeCompare(b);
  });
  let html = '';
  for (const key of invKeys) {
    const n = g.inventory[key];
    if (n <= 0) continue;
    const item = ITEMS[key];
    html += `<div class="inv-row"><div class="inv-icon" style="background:${item?.color||'#888'}"></div><div class="inv-name">${item?.name||key}</div><div class="inv-count">${n}</div></div>`;
  }
  inventoryList.innerHTML = html || '<div style="color:var(--text-dim);font-size:12px;padding:8px">Empty</div>';

  let hhtml = '';
  for (let i = 0; i < 9; i++) {
    const item = g.hotbar[i];
    const sel = i === g.selectedHotbar ? ' selected' : '';
    const icon = item ? `<div style="width:20px;height:20px;background:${ITEMS[item]?.color||'#888'};border-radius:2px"></div>` : '';
    hhtml += `<div class="hotbar-slot${sel}" data-slot="${i}"><span class="hotbar-key">${i+1}</span>${icon}</div>`;
  }
  hotbarEl.innerHTML = hhtml;

  let ehtml = '';
  if (hoverEntity) {
    const def = ENTITY_DEFS[hoverEntity.type];
    ehtml = `${def?.name||hoverEntity.type}`;
    if (hoverEntity.type === 'furnace') {
      ehtml += ` | Fuel:${Math.floor(hoverEntity.fuel)}`;
      if (hoverEntity.input) ehtml += ` | In:${ITEMS[hoverEntity.input]?.name}`;
      if (hoverEntity.output) ehtml += ` | Out:${ITEMS[hoverEntity.output]?.name}`;
    }
    if (hoverEntity.type === 'steam-engine') {
      ehtml += ` | ${hoverEntity.generating ? 'Generating' : 'Idle'}`;
    }
  } else if (hoverTile && game.tiles[hoverTile.x]?.[hoverTile.y]?.resource) {
    const r = game.tiles[hoverTile.x][hoverTile.y].resource;
    ehtml = `${ITEMS[r.type]?.name||r.type}: ${r.amount}`;
  }
  entityInfo.textContent = ehtml;

  if (craftingOpen) {
    let chtml = '';
    for (const [rid, r] of Object.entries(RECIPES)) {
      if (r.furnace) continue;
      const item = ITEMS[r.out];
      const unlocked = g.researched[rid];
      const canDo = canCraft(g, rid);
      const ings = Object.entries(r.ing).map(([k,v]) => `${v}x ${ITEMS[k]?.name||k}`).join(', ');
      chtml += `<div class="craft-recipe${unlocked ? '' : ' locked'}" data-recipe="${rid}">
        <div class="craft-icon" style="background:${item?.color||'#888'}"></div>
        <div class="craft-name">${item?.name||r.out}</div>
        <div class="craft-ingredients">${ings}</div>
        ${unlocked ? `<button class="craft-btn" ${canDo ? '' : 'disabled'}>Craft</button>` : '<span style="font-size:10px;color:#666">Locked</span>'}
      </div>`;
    }
    craftingList.innerHTML = chtml;
  }
}

function screenToTile(g, mx, my) {
  const vw = canvas.width, vh = canvas.height;
  const camX = g.player.x * TILE - vw / 2 + TILE / 2;
  const camY = g.player.y * TILE - vh / 2 + TILE / 2;
  const tx = Math.floor((mx + camX) / TILE);
  const ty = Math.floor((my + camY) / TILE);
  if (tx < 0 || tx >= MW || ty < 0 || ty >= MH) return null;
  return { x: tx, y: ty };
}

function onMouseMove(e) {
  const rect = canvas.getBoundingClientRect();
  mouseX = e.clientX - rect.left;
  mouseY = e.clientY - rect.top;
  const t = screenToTile(game, mouseX, mouseY);
  hoverTile = t;
  hoverEntity = t ? game.entities[t.x]?.[t.y] : null;
  if (hoverEntity && hoverEntity.type) {
    tooltip.style.display = 'block';
    tooltip.style.left = (e.clientX + 16) + 'px';
    tooltip.style.top = (e.clientY + 16) + 'px';
    tooltip.textContent = ENTITY_DEFS[hoverEntity.type]?.name || hoverEntity.type;
  } else if (hoverTile && game.tiles[hoverTile.x]?.[hoverTile.y]?.resource) {
    const r = game.tiles[hoverTile.x][hoverTile.y].resource;
    tooltip.style.display = 'block';
    tooltip.style.left = (e.clientX + 16) + 'px';
    tooltip.style.top = (e.clientY + 16) + 'px';
    tooltip.textContent = `${ITEMS[r.type]?.name||r.type}: ${r.amount}`;
  } else {
    tooltip.style.display = 'none';
  }
}

function onMouseDown(e) {
  if (e.button !== 2) return;
  e.preventDefault();
  const t = screenToTile(game, mouseX, mouseY);
  if (!t) return;
  const ent = game.entities[t.x]?.[t.y];
  if (ent) {
    removeEntity(game, t.x, t.y);
  } else {
    mineResource(game, t.x, t.y);
  }
}

function onContextMenu(e) { e.preventDefault(); }

function onClick(e) {
  if (e.button !== 0) return;
  const t = screenToTile(game, mouseX, mouseY);
  if (!t) return;
  if (craftingOpen) return;
  const sel = game.hotbar[game.selectedHotbar];
  if (!sel) return;
  const item = ITEMS[sel];
  if (!item || !item.entity) return;
  const ent = game.entities[t.x]?.[t.y];
  if (ent) return;
  placeEntity(game, t.x, t.y, item.entity, game.player.dir);
}

function cycleEntityDirection(e) {
  if (e.key !== 'r' && e.key !== 'R') return;
  const t = screenToTile(game, mouseX, mouseY);
  if (!t) return;
  const ent = game.entities[t.x]?.[t.y];
  if (!ent || !ENTITY_DEFS[ent.type]?.rotatable) return;
  const cur = DIR_KEYS.indexOf(ent.dir);
  ent.dir = DIR_KEYS[(cur + 1) % 4];
}

function onKeyDown(e) {
  keys[e.key.toLowerCase()] = true;
  if (e.key.toLowerCase() === 'e') {
    craftingOpen = !craftingOpen;
    if (craftingOpen) {
      craftingPanel.classList.add('open');
      updateUI(game);
    } else {
      craftingPanel.classList.remove('open');
    }
  }
  if (e.key >= '1' && e.key <= '9') {
    game.selectedHotbar = parseInt(e.key) - 1;
  }
  cycleEntityDirection(e);
}

function onKeyUp(e) { keys[e.key.toLowerCase()] = false; }

function movePlayer(g) {
  let dx = 0, dy = 0;
  if (keys['w'] || keys['arrowup']) dy = -1;
  if (keys['s'] || keys['arrowdown']) dy = 1;
  if (keys['a'] || keys['arrowleft']) dx = -1;
  if (keys['d'] || keys['arrowright']) dx = 1;

  if (dx !== 0 || dy !== 0) {
    if (dx !== 0) g.player.dir = dx > 0 ? 'right' : 'left';
    else if (dy !== 0) g.player.dir = dy > 0 ? 'down' : 'up';

    const speed = 0.15;
    const nx = g.player.x + dx * speed;
    const ny = g.player.y + dy * speed;
    const tx = Math.floor(nx), ty = Math.floor(ny);
    if (tx >= 0 && tx < MW && ty >= 0 && ty < MH && game.tiles[tx][ty].type !== 'water') {
      g.player.x = nx;
      g.player.y = ny;
    }
  }
}

function renderPowerOverlay(g) {
  const vw = canvas.width, vh = canvas.height;
  const camX = g.player.x * TILE - vw / 2 + TILE / 2;
  const camY = g.player.y * TILE - vh / 2 + TILE / 2;
  const startTX = Math.max(0, Math.floor(camX / TILE) - 1);
  const startTY = Math.max(0, Math.floor(camY / TILE) - 1);
  const endTX = Math.min(MW - 1, Math.ceil((camX + vw) / TILE) + 1);
  const endTY = Math.min(MH - 1, Math.ceil((camY + vh) / TILE) + 1);
  for (let tx = startTX; tx <= endTX; tx++) {
    for (let ty = startTY; ty <= endTY; ty++) {
      const ent = g.entities[tx]?.[ty];
      if (ent && ent.type === 'power-pole' && ent.x === tx && ent.y === ty) {
        const sx = ent.x * TILE - camX;
        const sy = ent.y * TILE - camY;
        ctx.strokeStyle = 'rgba(90,138,202,0.3)';
        ctx.lineWidth = 1;
        ctx.strokeRect(sx - 7 * TILE, sy - 7 * TILE, (14 + ent.w) * TILE, (14 + ent.h) * TILE);
      }
    }
  }
}

function saveGame(g) {
  const data = {
    tiles: g.tiles,
    entities: g.entities,
    beltItems: g.beltItems.map(bi => ({ x:bi.x, y:bi.y, progress:bi.progress, item:bi.item, dir:bi.dir })),
    player: g.player,
    inventory: g.inventory,
    hotbar: g.hotbar,
    selectedHotbar: g.selectedHotbar,
    researched: g.researched,
  };
  try {
    localStorage.setItem('factorio-web-save', JSON.stringify(data));
    return true;
  } catch(e) { return false; }
}

function loadGame() {
  try {
    const raw = localStorage.getItem('factorio-web-save');
    if (!raw) return null;
    const data = JSON.parse(raw);
    const g = newGame();
    g.tiles = data.tiles;
    g.entities = data.entities;
    g.beltItems = data.beltItems || [];
    g.player = data.player;
    g.inventory = data.inventory || {};
    g.hotbar = data.hotbar || new Array(9).fill(null);
    g.selectedHotbar = data.selectedHotbar || 0;
    g.researched = data.researched || {};
    return g;
  } catch(e) { return null; }
}

function resizeCanvas() {
  const vp = document.getElementById('viewport');
  canvas.width = vp.clientWidth;
  canvas.height = vp.clientHeight;
}

function gameLoop() {
  movePlayer(game);

  if (keys['f']) {
    const pt = playerTile(game);
    const ent = game.entities[pt.x]?.[pt.y];
    if (ent) {
      removeEntity(game, pt.x, pt.y);
    } else {
      mineResource(game, pt.x, pt.y);
    }
    keys['f'] = false;
  }

  game.tick++;
  if (game.tick % 1 === 0) {
    updateEntities(game);
  }

  if (handCraft) {
    handCraftTicks--;
    if (handCraftTicks <= 0) {
      doCraft(game, handCraft);
      handCraft = null;
    }
  }

  render(game);
  if (game.tick % 15 === 0) {
    renderPowerOverlay(game);
    updateUI(game);
    renderMinimap(game);
  }
}

function init() {
  initDOM();
  resizeCanvas();
  window.addEventListener('resize', resizeCanvas);

  const loaded = loadGame();
  game = loaded || newGame();
  if (!loaded) genWorld(game);

  updateUI(game);
  renderMinimap(game);

  canvas.addEventListener('mousemove', onMouseMove);
  canvas.addEventListener('mousedown', onMouseDown);
  canvas.addEventListener('click', onClick);
  canvas.addEventListener('contextmenu', onContextMenu);
  document.addEventListener('keydown', onKeyDown);
  document.addEventListener('keyup', onKeyUp);

  document.getElementById('btn-save').addEventListener('click', () => {
    if (saveGame(game)) alert('Game saved.');
    else alert('Failed to save.');
  });
  document.getElementById('btn-load').addEventListener('click', () => {
    const g = loadGame();
    if (g) { game = g; updateUI(game); renderMinimap(game); alert('Game loaded.'); }
    else alert('No save found.');
  });
  document.getElementById('btn-reset').addEventListener('click', () => {
    if (confirm('Start a new game? All progress will be lost.')) {
      game = newGame();
      genWorld(game);
      updateUI(game);
      renderMinimap(game);
    }
  });

  document.getElementById('crafting-list').addEventListener('click', (e) => {
    const btn = e.target.closest('.craft-btn');
    if (!btn) return;
    const row = e.target.closest('.craft-recipe');
    const rid = row?.dataset.recipe;
    if (rid && canCraft(game, rid)) {
      startHandCraft(rid);
    }
  });

  hotbarEl.addEventListener('click', (e) => {
    const slot = e.target.closest('.hotbar-slot');
    if (slot) {
      game.selectedHotbar = parseInt(slot.dataset.slot);
      updateUI(game);
    }
  });

  document.querySelectorAll('#crafting-panel .craft-recipe').forEach(el => {
    el.addEventListener('click', function(e) {
      if (e.target.tagName === 'BUTTON') return;
    });
  });

  setInterval(gameLoop, 1000 / TPS);
}

init();
</script>
</body>
</html>

Comments

No comments yet. Start the discussion.