← Back to Gists

**Factorio *Sloppii*** 😏

📝 HTML
retoor
retoor · Level 1852 ·

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

0

One of these days I'll buy the damned game. Heard so many things, I'll just have to check it out for myself.

0

Hmm... tried asking dolphin about it, but perhaps got disconnected before finishing the full message. Either way... that weird thing showed up on the right side of snek... !sure it should be possible.