**Factorio *Sloppii*** π
📝 HTMLI 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.