- Isometric canvas RTS vs dinosaur waves (fan demake of Repterra) - Economy: houses/taxes, farms, foresters, quarries; colonist staffing - Power grid: generators extend build range; brownout + recovery - Defense: walls/gates, watchtowers (AA), cannon towers (ground-only) - Taming: Primal Pen + Tamers collar weakened dinos; pets obey commands - Breeding: tamed pairs incubate eggs at the pen; hatchlings grow up - 7 dino species incl. flying Pteranodons and lake-raiding Suchomimus - Telegraphed waves with direction arrows; day-15 final horde; 3 difficulties - Day/night cycle, fog of war, minimap, synth audio, 1x-3x speeds - Save/Load/Continue + dawn autosave (full JSON state snapshots) - Tests: 80-assertion headless suite, browser boot + E2E, balance harness
309 lines
11 KiB
JavaScript
309 lines
11 KiB
JavaScript
/* =========================================================
|
|
* REPRTERRA WEB — input.js
|
|
* Mouse & keyboard: camera, selection, placement, commands.
|
|
* ========================================================= */
|
|
'use strict';
|
|
window.RTS = window.RTS || {};
|
|
|
|
RTS.input = (function () {
|
|
const U = RTS.util;
|
|
let cv = null;
|
|
const IN = {};
|
|
IN.ui = null; // shared UI state (set by ui.init)
|
|
IN.mouse = { sx: 0, sy: 0, wx: 0, wy: 0, down: false, mid: false, button: 0 };
|
|
IN.keys = {};
|
|
|
|
let dragStart = null; // {sx,sy} screen for rect select
|
|
let panning = false;
|
|
let panStart = null;
|
|
let lastPaintTile = null;
|
|
const C_TILE_W = RTS.CONFIG.WORLD.TILE_W, C_TILE_H = RTS.CONFIG.WORLD.TILE_H;
|
|
const TW2Z = C_TILE_W / 2, TH2Z = C_TILE_H / 2;
|
|
|
|
IN.attach = function (canvas) {
|
|
cv = canvas;
|
|
|
|
cv.addEventListener('contextmenu', e => e.preventDefault());
|
|
|
|
cv.addEventListener('mousedown', (e) => {
|
|
RTS.audio.resume();
|
|
const st = RTS.sim.state();
|
|
if (!st || st.over || RTS.main.state() !== 'playing') return;
|
|
const ui = IN.ui;
|
|
IN.mouse.sx = e.offsetX; IN.mouse.sy = e.offsetY;
|
|
updateWorldPos();
|
|
|
|
if (e.button === 1) { // middle -> pan
|
|
panning = true; panStart = { sx: e.clientX, sy: e.clientY, cx: RTS.render.cam.x, cy: RTS.render.cam.y };
|
|
e.preventDefault();
|
|
return;
|
|
}
|
|
if (e.button === 0) {
|
|
if (ui.placing) {
|
|
tryPlaceAt(true);
|
|
return;
|
|
}
|
|
dragStart = { sx: e.offsetX, sy: e.offsetY };
|
|
ui.dragRect = null;
|
|
}
|
|
});
|
|
|
|
window.addEventListener('mousemove', (e) => {
|
|
// edge scroll + hover even outside canvas
|
|
IN.mouse.cx = e.clientX; IN.mouse.cy = e.clientY;
|
|
});
|
|
|
|
cv.addEventListener('mousemove', (e) => {
|
|
IN.mouse.sx = e.offsetX; IN.mouse.sy = e.offsetY;
|
|
updateWorldPos();
|
|
const ui = IN.ui;
|
|
|
|
if (panning && panStart) {
|
|
const z = RTS.render.cam.zoom;
|
|
const dx = (e.clientX - panStart.sx);
|
|
const dy = (e.clientY - panStart.sy);
|
|
// convert screen delta to world delta (grab-the-map)
|
|
RTS.render.cam.x = panStart.cx - (dx / (TW2Z * z) + dy / (TH2Z * z)) * 0.5;
|
|
RTS.render.cam.y = panStart.cy - (dy / (TH2Z * z) - dx / (TW2Z * z)) * 0.5;
|
|
RTS.render.clampCam();
|
|
return;
|
|
}
|
|
|
|
if (ui.placing) {
|
|
ui.placing.x = Math.round(IN.mouse.wx);
|
|
ui.placing.y = Math.round(IN.mouse.wy);
|
|
if (IN.mouse.down === 'paint') tryPlaceAt(false);
|
|
return;
|
|
}
|
|
|
|
if (dragStart) {
|
|
const dx = IN.mouse.sx - dragStart.sx, dy = IN.mouse.sy - dragStart.sy;
|
|
if (dx * dx + dy * dy > 36) {
|
|
ui.dragRect = { x0: dragStart.sx, y0: dragStart.sy, x1: IN.mouse.sx, y1: IN.mouse.sy };
|
|
}
|
|
}
|
|
});
|
|
|
|
window.addEventListener('mouseup', (e) => {
|
|
const ui = IN.ui;
|
|
if (e.button === 1) { panning = false; panStart = null; }
|
|
if (e.button !== 0 || !cv) return;
|
|
IN.mouse.down = false;
|
|
if (ui.placing) { lastPaintTile = null; return; }
|
|
if (dragStart && ui.dragRect) {
|
|
// rect select units
|
|
const w0 = RTS.render.screenToWorld(ui.dragRect.x0, ui.dragRect.y0);
|
|
const w1 = RTS.render.screenToWorld(ui.dragRect.x1, ui.dragRect.y1);
|
|
const found = RTS.sim.selectUnitsInRect(w0.x, w0.y, w1.x, w1.y);
|
|
const rangers = found.filter(u => u.unitId === 'ranger');
|
|
if (rangers.length) {
|
|
ui.select({ kind: 'units', ids: rangers.map(u => u.id) });
|
|
} else {
|
|
ui.select(null);
|
|
}
|
|
ui.dragRect = null;
|
|
dragStart = null;
|
|
return;
|
|
}
|
|
if (dragStart) {
|
|
// single click select
|
|
dragStart = null;
|
|
const ent = RTS.sim.entityAt(IN.mouse.wx, IN.mouse.wy);
|
|
if (ent && ent.kind === 'unit' && ent.unitId === 'colonist') {
|
|
// colonists not individually selectable; show nothing special
|
|
ui.select(null);
|
|
} else if (ent && ent.kind === 'building') {
|
|
ui.select({ kind: 'building', id: ent.id });
|
|
} else if (ent && ent.kind === 'dino') {
|
|
ui.select({ kind: 'dino', id: ent.id });
|
|
} else if (ent && ent.kind === 'unit') {
|
|
ui.select({ kind: 'units', ids: [ent.id] });
|
|
} else {
|
|
ui.select(null);
|
|
}
|
|
}
|
|
});
|
|
|
|
cv.addEventListener('mouseup', (e) => {
|
|
const ui = IN.ui;
|
|
const st = RTS.sim.state();
|
|
if (!st || e.button !== 2) return;
|
|
if (ui.placing) { ui.setPlacing(null); return; }
|
|
updateWorldPos();
|
|
const ent = RTS.sim.entityAt(IN.mouse.wx, IN.mouse.wy);
|
|
// rally point
|
|
if (ui.sel && ui.sel.kind === 'building') {
|
|
const b = RTS.sim.getBuilding(ui.sel.id);
|
|
if (b && (b.defId === 'barracks' || b.defId === 'primalpen')) {
|
|
RTS.sim.setRally(b.id, IN.mouse.wx, IN.mouse.wy);
|
|
ui.fxPing(IN.mouse.wx, IN.mouse.wy, '#cfe8ff');
|
|
return;
|
|
}
|
|
}
|
|
// tamed pet commands
|
|
if (ui.sel && ui.sel.kind === 'dino' && RTS.sim.selectedPetCommandable(ui.sel.id)) {
|
|
if (ent && ent.kind === 'dino' && !ent.tamed) {
|
|
RTS.sim.commandPetAttack(ui.sel.id, ent.id);
|
|
ui.fxPing(ent.x, ent.y, '#ff5f4a');
|
|
} else {
|
|
RTS.sim.commandPet(ui.sel.id, IN.mouse.wx, IN.mouse.wy);
|
|
ui.fxPing(IN.mouse.wx, IN.mouse.wy, '#7fd6ff');
|
|
}
|
|
return;
|
|
}
|
|
if (ui.selUnits.length) {
|
|
// tamers: right-click a dino = prioritize its capture
|
|
const tamerIds = [];
|
|
const rangerIds = [];
|
|
for (const id of ui.selUnits) {
|
|
const u = st.units.find(v => v.id === id && !v.dead);
|
|
if (!u) continue;
|
|
if (u.unitId === 'tamer') tamerIds.push(id);
|
|
else if (u.unitId === 'ranger') rangerIds.push(id);
|
|
}
|
|
if (ent && ent.kind === 'dino' && !ent.tamed && tamerIds.length) {
|
|
RTS.sim.setCapturePriority(tamerIds, ent.id);
|
|
ui.fxPing(ent.x, ent.y, '#b06fe0');
|
|
return;
|
|
}
|
|
if (rangerIds.length || tamerIds.length) {
|
|
if (rangerIds.length) {
|
|
if (ent && ent.kind === 'dino') {
|
|
for (const id of rangerIds) {
|
|
const u = st.units.find(v => v.id === id && !v.dead);
|
|
if (u) u.targetId = ent.id;
|
|
}
|
|
ui.fxPing(ent.x, ent.y, '#ff5f4a');
|
|
} else {
|
|
RTS.sim.commandMove(rangerIds, IN.mouse.wx, IN.mouse.wy, false);
|
|
ui.fxPing(IN.mouse.wx, IN.mouse.wy, '#7fff9f');
|
|
}
|
|
} else {
|
|
// tamers only selected: send them toward the spot
|
|
for (const tid of tamerIds) {
|
|
const u = st.units.find(v => v.id === tid && !v.dead);
|
|
if (u) { u.tx = IN.mouse.wx; u.ty = IN.mouse.wy; u.path = [{ x: IN.mouse.wx, y: IN.mouse.wy }]; u.pathI = 0; }
|
|
}
|
|
ui.fxPing(IN.mouse.wx, IN.mouse.wy, '#b06fe0');
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
cv.addEventListener('dblclick', () => {
|
|
const ui = IN.ui;
|
|
// select all rangers & tamers on screen
|
|
const found = [];
|
|
for (const u of RTS.sim.state().units) {
|
|
if (u.dead || (u.unitId !== 'ranger' && u.unitId !== 'tamer')) continue;
|
|
const sp = RTS.render.worldToScreen(u.x, u.y);
|
|
if (sp.x >= 0 && sp.x <= cv.clientWidth && sp.y >= 0 && sp.y <= cv.clientHeight) found.push(u.id);
|
|
}
|
|
if (found.length) ui.select({ kind: 'units', ids: found });
|
|
});
|
|
|
|
cv.addEventListener('wheel', (e) => {
|
|
e.preventDefault();
|
|
const cam = RTS.render.cam;
|
|
const before = RTS.render.screenToWorld(e.offsetX, e.offsetY);
|
|
cam.zoom *= e.deltaY < 0 ? 1.12 : 1 / 1.12;
|
|
RTS.render.clampCam();
|
|
const after = RTS.render.screenToWorld(e.offsetX, e.offsetY);
|
|
cam.x += before.x - after.x;
|
|
cam.y += before.y - after.y;
|
|
RTS.render.clampCam();
|
|
}, { passive: false });
|
|
|
|
window.addEventListener('keydown', (e) => {
|
|
const k = e.key.toLowerCase();
|
|
IN.keys[k] = true;
|
|
const ui = IN.ui;
|
|
if (RTS.main.state() !== 'playing' && k !== 'escape') {
|
|
if (k === 'enter' || k === ' ') RTS.main.hotkey(e.key);
|
|
return;
|
|
}
|
|
if (k >= '1' && k <= '9') {
|
|
const slot = parseInt(k) - 1;
|
|
const flat = RTS.ui.flatPalette();
|
|
if (flat[slot]) ui.togglePlacing(flat[slot]);
|
|
e.preventDefault();
|
|
}
|
|
switch (k) {
|
|
case 'escape':
|
|
if (ui.placing) ui.setPlacing(null);
|
|
else ui.select(null);
|
|
break;
|
|
case ' ': case 'p': RTS.main.togglePause(); e.preventDefault(); break;
|
|
case 'f': {
|
|
const q = RTS.sim.hq();
|
|
if (q) { RTS.render.cam.x = q.x; RTS.render.cam.y = q.y; }
|
|
break;
|
|
}
|
|
case 'delete': if (ui.sel && ui.sel.kind === 'building') RTS.sim.demolish(ui.sel.id); break;
|
|
case 'm': ui.toggleMute(); break;
|
|
case '+': case '=': RTS.main.bumpSpeed(1); break;
|
|
case '-': RTS.main.bumpSpeed(-1); break;
|
|
case '0': RTS.main.setSpeed(1); break;
|
|
}
|
|
});
|
|
window.addEventListener('keyup', (e) => { IN.keys[e.key.toLowerCase()] = false; });
|
|
window.addEventListener('blur', () => { IN.keys = {}; });
|
|
};
|
|
|
|
function updateWorldPos() {
|
|
const w = RTS.render.screenToWorld(IN.mouse.sx, IN.mouse.sy);
|
|
IN.mouse.wx = w.x; IN.mouse.wy = w.y;
|
|
}
|
|
|
|
function tryPlaceAt(click) {
|
|
const ui = IN.ui;
|
|
const st = RTS.sim.state();
|
|
const defId = ui.placing.defId;
|
|
const tx = Math.round(IN.mouse.wx), ty = Math.round(IN.mouse.wy);
|
|
const tileKey = tx + ',' + ty;
|
|
if (C_isLine(defId) && !click && lastPaintTile === tileKey) return; // same tile
|
|
const res = RTS.sim.place(defId, tx, ty);
|
|
if (!res.ok) {
|
|
if (click) { RTS.audio.deny(); ui.toast(res.why, 'bad'); }
|
|
return;
|
|
}
|
|
lastPaintTile = tileKey;
|
|
if (C_isLine(defId)) {
|
|
IN.mouse.down = 'paint';
|
|
} else if (!IN.keys['shift']) {
|
|
// stop placing unless shift-held (multi place)
|
|
ui.setPlacing(null);
|
|
}
|
|
}
|
|
function C_isLine(defId) { return defId === 'wall' || defId === 'gate'; }
|
|
|
|
IN.tick = function (dtReal) {
|
|
const ui = IN.ui;
|
|
if (!ui) return;
|
|
const z = RTS.render.cam.zoom;
|
|
const v = 520 / z * dtReal; // px/sec along screen axes
|
|
let ex = 0, ey = 0; // screen-space direction
|
|
if (IN.keys['w'] || IN.keys['arrowup']) ey -= 1;
|
|
if (IN.keys['s'] || IN.keys['arrowdown']) ey += 1;
|
|
if (IN.keys['a'] || IN.keys['arrowleft']) ex -= 1;
|
|
if (IN.keys['d'] || IN.keys['arrowright']) ex += 1;
|
|
// edge scroll
|
|
if (IN.mouse.cx != null && document.hasFocus()) {
|
|
const m = 22, w = window.innerWidth, h = window.innerHeight;
|
|
if (IN.mouse.cx < m) ex -= 1; else if (IN.mouse.cx > w - m) ex += 1;
|
|
if (IN.mouse.cy < m) ey -= 1; else if (IN.mouse.cy > h - m) ey += 1;
|
|
}
|
|
if (ex || ey) {
|
|
const len = Math.hypot(ex, ey); ex /= len; ey /= len;
|
|
// screen dir -> world dir
|
|
const wxd = (ex / TW2Z + ey / TH2Z) * 0.5;
|
|
const wyd = (ey / TH2Z - ex / TW2Z) * 0.5;
|
|
RTS.render.cam.x += wxd * v;
|
|
RTS.render.cam.y += wyd * v;
|
|
RTS.render.clampCam();
|
|
}
|
|
};
|
|
|
|
return IN;
|
|
})();
|