Files
Battle-for-Tismo/client/js/map_view/motion.js
T
adrien ede258d39f Added warfare depth, focused research and a reworked HUD
- Tile improvements (fortifications, radar towers, coastal cannons), trenches,
  zone of control, support-unit capture and launch transports, with parabolic
  barrage arcs drawn between attacker and target.
- Focused research that pours points straight into the chosen technology, with
  per-technology progress kept when the focus changes, plus repeatable levels.
- Popularity reworked to integer points, war and pillage effects, minority
  policies and month-over-month arrows on the nation summary.
- City progress bars, square improvement buttons, a technology tab of wide
  short cards with progress bars, transport cargo readout and double-click to
  open a city panel.
2026-09-21 01:07:36 +02:00

567 lines
23 KiB
JavaScript

// Dead-reckoning unit motion between snapshots, path drawing and the local
// move prediction that mirrors GameState.find_path.
import { mapToLocal, key } from "../../../shared/hex.js";
import { HexPathfinder } from "../../../shared/hex_pathfinder.js";
import { TRANSPORT_BY_ID } from "../../../shared/data/improvements.js";
import { CONTROLLED_SPEED_MULTIPLIER } from "../../../shared/game_state/constants.js";
import { CAMERA_TILT, RESYNC_HOURS, PREDICTION_GRACE_HOURS } from "./constants.js";
import { hoursAlong, samePath, pathProgress, makeSegment } from "./utils.js";
// A pathfinding slice runs for this long before handing the thread back, so
// even a cross-map search never blocks a frame for long.
const PATH_SLICE_MS = 5;
const pathNow = () => (typeof performance !== "undefined" ? performance.now() : Date.now());
const pathDefer = (fn) => setTimeout(fn, 0);
export const motionMethods = {
// A living hostile civilian standing on the path blocks dead reckoning: the
// animation must stop before it. An enemy military unit is the fight itself
// and combat is fought on a single tile, so the mover charges onto its tile
// and stops there. Friendly units stack and never block.
_truncateAtOccupant(path, segmentHours, data) {
const proto = this.protoUnits[data.proto];
for (let i = 1; i < path.length; i++) {
const occupant = this._unitsAtCoords(path[i], data.id).find(
(other) => other.civ !== data.civ
);
if (!occupant) continue;
if (proto && proto.military && this._isMilitaryData(occupant)) {
return { path: path.slice(0, i + 1), segmentHours: segmentHours.slice(0, i) };
}
return {
path: path.slice(0, i),
segmentHours: segmentHours.slice(0, Math.max(0, i - 1)),
};
}
return { path, segmentHours };
},
_updateUnitMotion(data) {
const id = data.id;
let path = (data.path || []).map((p) => ({ x: p[0], y: p[1] }));
let segmentHours = data.segmentHours || [];
const clipped = this._truncateAtOccupant(path, segmentHours, data);
path = clipped.path;
segmentHours = clipped.segmentHours;
const existing = this._unitMotion.get(id);
if (path.length < 2 || segmentHours.length === 0) {
if (existing && existing.predicting && existing.predictAge < PREDICTION_GRACE_HOURS) {
return;
}
this._unitMotion.set(id, {
path: [],
segmentHours: [],
pathHours: 0,
coords: { x: data.coords[0], y: data.coords[1] },
predicting: false,
predictAge: 0,
});
return;
}
const authoritative = hoursAlong(data, segmentHours);
let pathHours = authoritative;
if (existing && samePath(existing.path, path)) {
pathHours = Math.abs(authoritative - existing.pathHours) > RESYNC_HOURS
? authoritative
: existing.pathHours;
}
this._unitMotion.set(id, {
path,
segmentHours,
pathHours,
coords: { x: data.coords[0], y: data.coords[1] },
predicting: false,
predictAge: 0,
});
},
animate(dtHours) {
for (const [, motion] of this._unitMotion) {
if (motion.path.length < 2) continue;
motion.pathHours += dtHours;
if (motion.predicting) motion.predictAge += dtHours;
}
this._syncChunks();
this._applyEntityWrap();
this._drawPaths();
this._drawStrikeTargets();
this._flushFog();
this._glRender();
},
// Repositions every unit and city at the wrapped copy nearest the camera.
_applyEntityWrap() {
for (const [id, motion] of this._unitMotion) {
const view = this._unitViews.get(id);
if (!view) continue;
const position = motion.path.length >= 2
? this._positionAlongPath(motion)
: mapToLocal(motion.coords.x, motion.coords.y);
// Choose the wrapped copy from the tile centre, then apply the stack
// offset, so members of one stack never split across the wrap seam.
const wrapped = this._wrappedPosition(position);
const offset = motion.offset || { x: 0, y: 0 };
// Sprites stand on the tilted ground, so only the world Y is
// foreshortened; the stack offset stays a plain pixel spread.
view.style.left = `${wrapped.x + offset.x}px`;
view.style.top = `${wrapped.y * CAMERA_TILT + offset.y}px`;
}
for (const view of this._cityViews.values()) {
if (view._localX === undefined) continue;
const left = view._localX + this._wrapShift(view._localX);
view.style.left = `${left}px`;
view.style.top = `${view._localY * CAMERA_TILT}px`;
if (view._label) {
view._label.style.left = `${left}px`;
view._label.style.top = `${view._localY * CAMERA_TILT}px`;
}
}
for (const marker of this._battleViews.values()) {
if (marker._localX === undefined) continue;
marker.style.left = `${marker._localX + this._wrapShift(marker._localX)}px`;
marker.style.top = `${marker._localY * CAMERA_TILT}px`;
}
},
_positionAlongPath(motion) {
const path = motion.path;
const segmentHours = motion.segmentHours;
let remaining = motion.pathHours;
let index = 0;
while (index < segmentHours.length) {
const step = segmentHours[index];
if (step > 0 && remaining < step) break;
remaining -= step;
index += 1;
}
if (index >= segmentHours.length) {
index = segmentHours.length - 1;
if (index < 0) {
const local = mapToLocal(path[path.length - 1].x, path[path.length - 1].y);
return local;
}
remaining = segmentHours[index];
}
const from = path[index];
const to = path[index + 1] || from;
const a = mapToLocal(from.x, from.y);
const delta = this.topology.pixelDelta(from, to);
const step = segmentHours[index] || 1;
const t = Math.max(0, Math.min(1, remaining / step));
return { x: a.x + delta.x * t, y: a.y + delta.y * t };
},
_drawPaths() {
// With nothing selected there is nothing to draw; leave the layer alone
// rather than emptying and repopulating it every frame.
if (this._selectedUnitIds.size === 0) {
if (this.$paths[0].firstChild) this.$paths.empty();
return;
}
const fragment = document.createDocumentFragment();
for (const [id, motion] of this._unitMotion) {
if (!this._selectedUnitIds.has(id)) continue;
if (motion.path.length < 2) continue;
const index = pathProgress(motion).index;
const base = mapToLocal(motion.path[index].x, motion.path[index].y);
const current = this._positionAlongPath(motion);
let previous = base;
for (let i = index + 1; i < motion.path.length; i++) {
const from = motion.path[i - 1];
const to = motion.path[i];
const delta = this.topology.pixelDelta(from, to);
const next = { x: previous.x + delta.x, y: previous.y + delta.y };
const start = i === index + 1 ? current : previous;
const a = this._wrappedPosition(start);
const b = this._wrappedPosition(next);
fragment.appendChild(makeSegment(a, b, "#ffffff", 4));
previous = next;
}
}
if (!fragment.childNodes.length) {
if (this.$paths[0].firstChild) this.$paths.empty();
return;
}
this.$paths.empty()[0].appendChild(fragment);
},
// Where a unit is drawn right now, whether it is parked or mid-route.
_unitRenderPosition(id) {
const motion = this._unitMotion.get(id);
if (!motion) {
const data = this._unitData.get(id);
if (!data) return null;
return mapToLocal(data.coords[0], data.coords[1]);
}
if (motion.path.length >= 2) return this._positionAlongPath(motion);
return mapToLocal(motion.coords.x, motion.coords.y);
},
// Draws the red dashed barrage arc from every ranged attacker with a standing
// target to the target tile, and a red ring around the target. The shell lobs
// in a parabola rather than along the ground, and the dashes march and a
// glowing shell travels the curve so the direction is unmistakable. They stay
// visible so an ongoing barrage cannot be forgotten, and sit on a layer above
// the terrain but below the unit and city icons.
_drawStrikeTargets() {
if (!this.$targets || !this.$targets.length) return;
const fragment = document.createDocumentFragment();
const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
svg.setAttribute("class", "target-arcs");
const now = Date.now();
let arcs = 0;
for (const [id, data] of this._unitData) {
if (!data.strikeTarget) continue;
const target = { x: data.strikeTarget[0], y: data.strikeTarget[1] };
const targetIds = this.unitIdsAt(target);
// The selected attacker or target stands out from the standing barrages
// the player is not looking at.
const selected = this._selectedUnitIds.has(id) ||
targetIds.some((targetId) => this._selectedUnitIds.has(targetId));
const fromPoint = this._unitRenderPosition(id);
if (!fromPoint) continue;
const targetId = targetIds[0];
const toPoint = targetId
? this._unitRenderPosition(targetId)
: mapToLocal(target.x, target.y);
if (!toPoint) continue;
const a = this._wrappedPosition(fromPoint);
const b = this._wrappedPosition(toPoint);
const distance = Math.hypot(b.x - a.x, b.y - a.y);
// A quadratic Bezier lobs the shell: the control point is lifted above the
// midpoint, its height compensated for the layer's Y foreshortening so the
// arc keeps its shape on screen.
const arc = Math.max(18, distance * 0.4) / CAMERA_TILT;
const cx = (a.x + b.x) / 2;
const cy = (a.y + b.y) / 2 - arc;
const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
path.setAttribute("d", `M ${a.x} ${a.y} Q ${cx} ${cy} ${b.x} ${b.y}`);
path.setAttribute("class", selected ? "target-line target-selected" : "target-line");
// Marching dashes flow from the battery towards the target.
path.style.strokeDashoffset = `${-((now / 22) % 18)}px`;
svg.appendChild(path);
// A glowing shell rides the curve to show exactly where it is going.
const t = (now / 1300) % 1;
const mt = 1 - t;
const shell = document.createElementNS("http://www.w3.org/2000/svg", "circle");
shell.setAttribute("class", "target-shell");
shell.setAttribute("cx", `${mt * mt * a.x + 2 * mt * t * cx + t * t * b.x}`);
shell.setAttribute("cy", `${mt * mt * a.y + 2 * mt * t * cy + t * t * b.y}`);
shell.setAttribute("r", "3.5");
svg.appendChild(shell);
arcs += 1;
const ring = document.createElement("div");
ring.className = selected ? "target-ring target-selected" : "target-ring";
ring.style.left = `${b.x}px`;
ring.style.top = `${b.y}px`;
fragment.appendChild(ring);
}
if (arcs > 0) fragment.insertBefore(svg, fragment.firstChild);
if (!fragment.childNodes.length) {
if (this.$targets[0].firstChild) this.$targets.empty();
return;
}
this.$targets.empty()[0].appendChild(fragment);
},
// -------------------------------------------------------- move preview --
// Mirrors GameState.find_path closely enough that a click feels instant; the
// next snapshot confirms or corrects the route. `planMove` computes the route
// without touching the view, so callers can inspect it (for example to warn
// before entering foreign territory) before committing to the prediction.
planMove(unitData, goal, speed = null, cache = null) {
return this._planMove(unitData, goal, speed, cache).then((plan) => (plan ? plan.route : null));
},
predictMove(unitData, goal, speed = null, route = null) {
if (route) {
this.predictRoute(unitData, route, speed);
return Promise.resolve(route);
}
return this._planMove(unitData, goal, speed).then((plan) => {
if (!plan) return null;
this._applyPrediction(unitData, plan);
return plan.route;
});
},
// Starts a prediction from a route that was already planned, skipping the
// search. Used right after a move order so the route is only found once.
predictRoute(unitData, route, speed = null) {
const proto = this.protoUnits[unitData.proto];
if (!proto || !route || route.length < 2) return null;
const start = this._unitCoords(unitData.id) ||
{ x: unitData.coords[0], y: unitData.coords[1] };
const segmentHours = [];
for (let i = 1; i < route.length; i++) {
segmentHours.push(this._stepHours(proto, unitData.civ, route[i], speed));
}
this._applyPrediction(unitData, { route, segmentHours, start });
return route;
},
_applyPrediction(unitData, plan) {
const existing = this._unitMotion.get(unitData.id);
this._unitMotion.set(unitData.id, {
path: plan.route,
segmentHours: plan.segmentHours,
pathHours: 0,
coords: plan.start,
predicting: true,
predictAge: 0,
offset: existing ? existing.offset : { x: 0, y: 0 },
});
this.animate(0);
},
async _planMove(unitData, goal, speed = null, cache = null) {
const proto = this.protoUnits[unitData.proto];
if (!proto || !proto.moveable || !this.topology) return null;
const start = this._unitCoords(unitData.id) ||
{ x: unitData.coords[0], y: unitData.coords[1] };
if (start.x === goal.x && start.y === goal.y) return null;
// Units of the same type marching from the same tile to the same goal follow
// the same route, so the search (and its per-segment hours) runs once.
const cacheKey = cache
? `${unitData.civ}|${unitData.proto}|${start.x},${start.y}|${goal.x},${goal.y}|${speed}`
: null;
if (cacheKey && cache.has(cacheKey)) return cache.get(cacheKey);
const path = await this._search(unitData, proto, start, goal, speed);
let plan = null;
if (path && path.length >= 2) {
const segmentHours = [];
for (let i = 1; i < path.length; i++) {
segmentHours.push(this._stepHours(proto, unitData.civ, path[i], speed));
}
plan = { route: path, segmentHours, start };
}
if (cacheKey) cache.set(cacheKey, plan);
return plan;
},
// The A* this mirrors recomputes the cheapest movement cost on every node,
// and the tile scan behind it dominated the search. Cache it here (the
// TerrainStats memoises it too) and index the units once instead of scanning
// every unit for every tile.
_search(unitData, proto, start, goal, speed) {
const civ = unitData.civ;
const targetCiv = this._targetCivFor(unitData, goal);
const occupancy = this._buildOccupancy(unitData.id);
const zoc = this._zoneOfControl(civ);
const enterCache = new Map();
const costCache = new Map();
const heuristicCache = new Map();
const bestSpeed = Math.max(speed || proto.speed, 0.0001);
// Mirror GameState._fastestTileTravelHours: a railway (or a road) may
// undercut the cheapest terrain, and the heuristic must stay admissible if
// A* is to find it.
const railCost = unitData.civ === this.localCiv
? TRANSPORT_BY_ID.railway.movementCostMultiplier / (1 + (this.railSpeed || 0))
: TRANSPORT_BY_ID.railway.movementCostMultiplier;
const cheapest = Math.min(
this.terrainStats.minMovementCostMultiplier(),
TRANSPORT_BY_ID.road.movementCostMultiplier,
railCost
);
const fastestHours = cheapest / (bestSpeed * CONTROLLED_SPEED_MULTIPLIER);
const iterator = new HexPathfinder().findPathIter(
start,
goal,
(coords) => this.topology.neighbours(coords.x, coords.y),
(coords, isGoal, from) => {
const fromKey = from ? key(from.x, from.y) : "";
const k = `${key(coords.x, coords.y)}${isGoal ? ":goal" : ""}@${fromKey}`;
if (!enterCache.has(k)) {
enterCache.set(
k,
this._canEnter(civ, proto, coords, unitData.id, isGoal, occupancy, targetCiv, zoc, from)
);
}
return enterCache.get(k);
},
(_from, to) => {
const k = key(to.x, to.y);
if (!costCache.has(k)) costCache.set(k, this._stepHours(proto, civ, to, speed));
return costCache.get(k);
},
(from, to) => {
const k = key(from.x, from.y);
if (!heuristicCache.has(k)) {
const delta = this.topology.pixelDelta(from, to);
heuristicCache.set(k, (Math.hypot(delta.x, delta.y) / this._maxStepLength) * fastestHours);
}
return heuristicCache.get(k);
}
);
return this._runSearch(iterator);
},
// Drives the search in short time slices across timer turns, so a long route
// never freezes the frame. Starting a new search cancels the previous one.
_runSearch(iterator) {
this.cancelPathfinding();
const token = { cancelled: false };
this._pathToken = token;
return new Promise((resolve) => {
const pump = () => {
if (token.cancelled) {
resolve(null);
return;
}
const deadline = pathNow() + PATH_SLICE_MS;
let step = iterator.next();
while (!step.done && pathNow() < deadline) step = iterator.next();
if (step.done) {
this._pathToken = null;
resolve(step.value);
} else {
pathDefer(pump);
}
};
pump();
});
},
cancelPathfinding() {
if (!this._pathToken) return;
this._pathToken.cancelled = true;
this._pathToken = null;
},
_isMilitaryData(data) {
const proto = this.protoUnits[data.proto];
return !!(proto && proto.military);
},
_unitsAtCoords(coords, ignoreId = 0) {
const result = [];
for (const [id, data] of this._unitData) {
if (id === ignoreId) continue;
const motion = this._unitMotion.get(id);
const tile = motion && motion.path.length >= 2
? motion.path[Math.min(pathProgress(motion).index, motion.path.length - 1)]
: { x: data.coords[0], y: data.coords[1] };
if (tile.x === coords.x && tile.y === coords.y) result.push(data);
}
return result;
},
// Same as _unitsAtCoords, but every unit is bucketed by its tile once so a
// search can look up occupancy in constant time instead of scanning them all
// for every tile it considers.
_buildOccupancy(ignoreId) {
const index = new Map();
for (const [id, data] of this._unitData) {
if (id === ignoreId) continue;
const motion = this._unitMotion.get(id);
const tile = motion && motion.path.length >= 2
? motion.path[Math.min(pathProgress(motion).index, motion.path.length - 1)]
: { x: data.coords[0], y: data.coords[1] };
const k = key(tile.x, tile.y);
const list = index.get(k);
if (list) list.push(data);
else index.set(k, [data]);
}
return index;
},
// Mirrors GameState._zoneOfControl: the tiles hostile ground forces deny to
// `civ`, so prediction routes around them the same way the server does.
_zoneOfControl(civ) {
const denied = new Set();
for (const [, data] of this._unitData) {
if (data.civ === civ) continue;
const proto = this.protoUnits[data.proto];
if (!proto || !proto.military || proto.air) continue;
const k = key(data.coords[0], data.coords[1]);
denied.add(k);
for (const neighbour of this.topology.neighbours(data.coords[0], data.coords[1])) {
denied.add(key(neighbour.x, neighbour.y));
}
}
return denied;
},
_unitCoords(id) {
const motion = this._unitMotion.get(id);
if (!motion) return null;
if (motion.path.length >= 2) {
const index = pathProgress(motion).index;
return motion.path[Math.min(index, motion.path.length - 1)];
}
return motion.coords;
},
// The civilisation a route is aimed at: the enemy city or unit on the goal,
// or failing that whoever owns the goal tile. Crossing that civ's land is
// allowed; every other foreign territory is a wall.
_targetCivFor(unitData, goal) {
const k = key(goal.x, goal.y);
const cityCiv = this._cityCiv.get(k);
if (cityCiv !== undefined && cityCiv !== unitData.civ) return cityCiv;
const hostile = this._unitsAtCoords(goal, unitData.id).find((data) => data.civ !== unitData.civ);
if (hostile) return hostile.civ;
return this.territory.has(k) ? this.territory.get(k) : -1;
},
// Mirrors GameState._canUnitEnter: friendly units share a tile, while a
// hostile unit or city is only a valid destination (`isGoal`). Foreign
// territory is a wall too, except for the civilisation being targeted, so a
// route never cuts through a third party and provokes a war.
_canEnter(civ, proto, coords, moverId = 0, isGoal = false, occupancy = null, targetCiv = -1, zoc = null, from = null) {
const k = key(coords.x, coords.y);
const air = !!(proto && proto.air);
const tile = this.tiles[k];
if (!tile) return false;
if (!air && !this.explored.has(k)) return false;
if (!proto.traversableTerrains.includes(tile.terrainClass)) return false;
// Mirrors the server's zone of control: never cross from one denied tile
// straight into another, except as a deliberate attack on the goal.
if (!air && !isGoal && zoc && from &&
zoc.has(key(from.x, from.y)) && zoc.has(key(k))) {
return false;
}
const owner = this.territory.has(k) ? this.territory.get(k) : -1;
if (!air && !isGoal && owner >= 0 && owner !== civ && owner !== targetCiv) return false;
const occupants = occupancy ? (occupancy.get(k) || []) : this._unitsAtCoords(coords, moverId);
const hostile = occupants.filter((data) => data.civ !== civ);
if (hostile.length > 0) {
if (!isGoal) return false;
if (proto.military !== true) return false;
const anyMilitary = hostile.some((data) => this._isMilitaryData(data));
if (!anyMilitary) return false;
}
const cityCiv = this._cityCiv.get(k);
if (cityCiv !== undefined && cityCiv !== civ) {
// Mirrors GameState._canUnitEnter: a hostile city is only a destination,
// open to ground troops capturing it and to aircraft bombing its garrison.
if (!isGoal) return false;
if (proto.military !== true) return false;
}
return true;
},
_stepHours(proto, civ, coords, speed = null) {
const effective = Math.max(speed || proto.speed, 0.0001);
// Mirrors GameState._tileTravelHours: aircraft cross every tile at a flat
// rate, while ground units are shaped by roads and terrain.
if (proto.air) return 1 / effective;
const k = key(coords.x, coords.y);
let pace = effective;
if (this.territory.get(k) === civ) pace *= CONTROLLED_SPEED_MULTIPLIER;
let multiplier;
if (this.railways.has(k)) {
multiplier = TRANSPORT_BY_ID.railway.movementCostMultiplier;
// Only the viewer's own trains technology is known to the browser.
if (civ === this.localCiv) multiplier /= 1 + (this.railSpeed || 0);
} else if (this.roads.has(k)) {
multiplier = TRANSPORT_BY_ID.road.movementCostMultiplier;
} else {
multiplier = this.terrainStats.movementCostMultiplier(coords);
}
return multiplier / pace;
},
};