Files
Battle-for-Tismo/client/js/map_view/motion.js
T

506 lines
20 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 { ROADS } from "../../../shared/data/roads.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 line from every ranged attacker with a standing target
// to the target tile, and a red ring around the target. Both appear only
// while the attacker (or the unit taking the fire) is selected, and sit on a
// layer above the terrain but below the unit and city icons.
_drawStrikeTargets() {
if (!this.$targets || !this.$targets.length) return;
// Strike lines only appear for a selected attacker or target.
if (this._selectedUnitIds.size === 0) {
if (this.$targets[0].firstChild) this.$targets.empty();
return;
}
const fragment = document.createDocumentFragment();
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);
const highlighted =
this._selectedUnitIds.has(id) ||
targetIds.some((targetId) => this._selectedUnitIds.has(targetId));
if (!highlighted) continue;
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 dx = b.x - a.x;
const dy = b.y - a.y;
const line = document.createElement("div");
line.className = "target-line";
line.style.left = `${a.x}px`;
line.style.top = `${a.y}px`;
line.style.width = `${Math.hypot(dx, dy)}px`;
line.style.transform = `rotate(${Math.atan2(dy, dx)}rad)`;
fragment.appendChild(line);
const ring = document.createElement("div");
ring.className = "target-ring";
ring.style.left = `${b.x}px`;
ring.style.top = `${b.y}px`;
fragment.appendChild(ring);
}
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 enterCache = new Map();
const costCache = new Map();
const heuristicCache = new Map();
const bestSpeed = Math.max(speed || proto.speed, 0.0001);
const fastestHours =
this.terrainStats.minMovementCostMultiplier() / (bestSpeed * CONTROLLED_SPEED_MULTIPLIER);
const iterator = new HexPathfinder().findPathIter(
start,
goal,
(coords) => this.topology.neighbours(coords.x, coords.y),
(coords, isGoal) => {
const k = key(coords.x, coords.y) + (isGoal ? ":goal" : "");
if (!enterCache.has(k)) {
enterCache.set(k, this._canEnter(civ, proto, coords, unitData.id, isGoal, occupancy, targetCiv));
}
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;
},
_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) {
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;
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;
const multiplier = this.roads.has(k)
? ROADS.movementCostMultiplier
: this.terrainStats.movementCostMultiplier(coords);
return multiplier / pace;
},
};