Repterra Web — full game: base building, power grid, taming & breeding, aquatic raiders, day/night, save/load
- 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
This commit is contained in:
+308
@@ -0,0 +1,308 @@
|
||||
/* =========================================================
|
||||
* 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;
|
||||
})();
|
||||
Reference in New Issue
Block a user