Curved nuke trajectory (#734)

## Description:

Change nuke trajectory to follow a curve, and add a trail behind them.


![image](https://github.com/user-attachments/assets/f0fac8a0-d173-4e13-a889-3d19c36f85b8)

### Details:
- Use a look-up table to approximate the distance travelled, so the
speed remains constant along the curve
- The nuke speed is higher than a single pixel, so draw a line behind
for the trail
- Added a new "utility" file for the Bresenham/Bezier algorith: please
tell me if that's ok

### Edge cases (literally the edges):
The control points remains in the map so the curve can never go outside:


![image](https://github.com/user-attachments/assets/bb3a02fd-d76d-4643-a123-8bbc48c0ca87)



## Please complete the following:

- [x] I have added screenshots for all UI updates
- [x] I confirm I have thoroughly tested these changes and take full
responsibility for any bugs introduced
- [x] I understand that submitting code with bugs that could have been
caught through manual testing blocks releases and new features for all
contributors

## Please put your Discord username so you can be contacted if a bug or
regression is found:

<DISCORD USERNAME>

---------

Co-authored-by: Scott Anderson <scottanderson@users.noreply.github.com>
This commit is contained in:
DevelopingTom
2025-05-13 07:30:31 +02:00
committed by GitHub
parent 36759eaa87
commit 7c163e2361
5 changed files with 319 additions and 72 deletions
+92 -45
View File
@@ -6,6 +6,7 @@ import { UnitType } from "../../../core/game/Game";
import { TileRef } from "../../../core/game/GameMap";
import { GameUpdateType } from "../../../core/game/GameUpdates";
import { GameView, PlayerView, UnitView } from "../../../core/game/GameView";
import { BezenhamLine } from "../../../core/utilities/Line";
import {
AlternateViewEvent,
MouseUpEvent,
@@ -31,9 +32,9 @@ export class UnitLayer implements Layer {
private canvas: HTMLCanvasElement;
private context: CanvasRenderingContext2D;
private transportShipTrailCanvas: HTMLCanvasElement;
private transportShipTrailContext: CanvasRenderingContext2D;
private unitTrailContext: CanvasRenderingContext2D;
private boatToTrail = new Map<UnitView, TileRef[]>();
private unitToTrail = new Map<UnitView, TileRef[]>();
private theme: Theme = null;
@@ -190,8 +191,7 @@ export class UnitLayer implements Layer {
this.canvas = document.createElement("canvas");
this.context = this.canvas.getContext("2d");
this.transportShipTrailCanvas = document.createElement("canvas");
this.transportShipTrailContext =
this.transportShipTrailCanvas.getContext("2d");
this.unitTrailContext = this.transportShipTrailCanvas.getContext("2d");
this.canvas.width = this.game.width();
this.canvas.height = this.game.height();
@@ -200,7 +200,7 @@ export class UnitLayer implements Layer {
this.updateUnitsSprites();
this.boatToTrail.forEach((trail, unit) => {
this.unitToTrail.forEach((trail, unit) => {
for (const t of trail) {
this.paintCell(
this.game.x(t),
@@ -208,7 +208,7 @@ export class UnitLayer implements Layer {
this.relationship(unit),
this.theme.territoryColor(unit.owner()),
150,
this.transportShipTrailContext,
this.unitTrailContext,
);
}
});
@@ -333,8 +333,85 @@ export class UnitLayer implements Layer {
this.drawSprite(unit);
}
private drawTrail(trail: number[], color: Colord, rel: Relationship) {
// Paint new trail
for (const t of trail) {
this.paintCell(
this.game.x(t),
this.game.y(t),
rel,
color,
150,
this.unitTrailContext,
);
}
}
private clearTrail(unit: UnitView) {
const trail = this.unitToTrail.get(unit);
const rel = this.relationship(unit);
for (const t of trail) {
this.clearCell(this.game.x(t), this.game.y(t), this.unitTrailContext);
}
this.unitToTrail.delete(unit);
// Repaint overlapping trails
const trailSet = new Set(trail);
for (const [other, trail] of this.unitToTrail) {
for (const t of trail) {
if (trailSet.has(t)) {
this.paintCell(
this.game.x(t),
this.game.y(t),
rel,
this.theme.territoryColor(other.owner()),
150,
this.unitTrailContext,
);
}
}
}
}
private handleNuke(unit: UnitView) {
const rel = this.relationship(unit);
if (!this.unitToTrail.has(unit)) {
this.unitToTrail.set(unit, []);
}
let newTrailSize = 1;
const trail = this.unitToTrail.get(unit);
// It can move faster than 1 pixel, draw a line for the trail or else it will be dotted
if (trail.length >= 1) {
const cur = {
x: this.game.x(unit.lastTile()),
y: this.game.y(unit.lastTile()),
};
const prev = {
x: this.game.x(trail[trail.length - 1]),
y: this.game.y(trail[trail.length - 1]),
};
const line = new BezenhamLine(prev, cur);
let point = line.increment();
while (point !== true) {
trail.push(this.game.ref(point.x, point.y));
point = line.increment();
}
newTrailSize = line.size();
} else {
trail.push(unit.lastTile());
}
this.drawTrail(
trail.slice(-newTrailSize),
this.theme.territoryColor(unit.owner()),
rel,
);
this.drawSprite(unit);
if (!unit.isActive()) {
this.clearTrail(unit);
}
}
private handleMIRVWarhead(unit: UnitView) {
@@ -361,52 +438,22 @@ export class UnitLayer implements Layer {
private handleBoatEvent(unit: UnitView) {
const rel = this.relationship(unit);
if (!this.boatToTrail.has(unit)) {
this.boatToTrail.set(unit, []);
if (!this.unitToTrail.has(unit)) {
this.unitToTrail.set(unit, []);
}
const trail = this.boatToTrail.get(unit);
const trail = this.unitToTrail.get(unit);
trail.push(unit.lastTile());
// Paint trail
for (const t of trail.slice(-1)) {
this.paintCell(
this.game.x(t),
this.game.y(t),
rel,
this.theme.territoryColor(unit.owner()),
150,
this.transportShipTrailContext,
);
}
this.drawTrail(
trail.slice(-1),
this.theme.territoryColor(unit.owner()),
rel,
);
this.drawSprite(unit);
if (!unit.isActive()) {
for (const t of trail) {
this.clearCell(
this.game.x(t),
this.game.y(t),
this.transportShipTrailContext,
);
}
this.boatToTrail.delete(unit);
// Repaint overlapping trails
const trailSet = new Set(trail);
for (const [other, trail] of this.boatToTrail) {
for (const t of trail) {
if (trailSet.has(t)) {
this.paintCell(
this.game.x(t),
this.game.y(t),
rel,
this.theme.territoryColor(other.owner()),
150,
this.transportShipTrailContext,
);
}
}
}
this.clearTrail(unit);
}
}
+14 -15
View File
@@ -10,7 +10,7 @@ import {
UnitType,
} from "../game/Game";
import { TileRef } from "../game/GameMap";
import { AirPathFinder } from "../pathfinding/PathFinding";
import { ParabolaPathFinder } from "../pathfinding/PathFinding";
import { PseudoRandom } from "../PseudoRandom";
import { simpleHash } from "../Util";
import { NukeExecution } from "./NukeExecution";
@@ -29,12 +29,14 @@ export class MirvExecution implements Execution {
private random: PseudoRandom;
private pathFinder: AirPathFinder;
private pathFinder: ParabolaPathFinder;
private targetPlayer: Player | TerraNullius;
private separateDst: TileRef;
private speed: number = -1;
constructor(
private senderID: PlayerID,
private dst: TileRef,
@@ -49,9 +51,10 @@ export class MirvExecution implements Execution {
this.random = new PseudoRandom(mg.ticks() + simpleHash(this.senderID));
this.mg = mg;
this.pathFinder = new AirPathFinder(mg, this.random);
this.pathFinder = new ParabolaPathFinder(mg);
this.player = mg.player(this.senderID);
this.targetPlayer = this.mg.owner(this.dst);
this.speed = this.mg.config().defaultNukeSpeed();
this.mg
.stats()
@@ -76,6 +79,7 @@ export class MirvExecution implements Execution {
);
const y = Math.max(0, this.mg.y(this.dst) - 500) + 50;
this.separateDst = this.mg.ref(x, y);
this.pathFinder.computeControlPoints(spawn, this.separateDst);
this.mg.displayMessage(
`⚠️⚠️⚠️ ${this.player.name()} - MIRV INBOUND ⚠️⚠️⚠️`,
@@ -84,18 +88,13 @@ export class MirvExecution implements Execution {
);
}
for (let i = 0; i < 4; i++) {
const result = this.pathFinder.nextTile(
this.nuke.tile(),
this.separateDst,
);
if (result === true) {
this.separate();
this.active = false;
return;
} else {
this.nuke.move(result);
}
const result = this.pathFinder.nextTile(this.speed);
if (result === true) {
this.separate();
this.active = false;
return;
} else {
this.nuke.move(result);
}
}
+15 -12
View File
@@ -11,7 +11,7 @@ import {
UnitType,
} from "../game/Game";
import { TileRef } from "../game/GameMap";
import { AirPathFinder } from "../pathfinding/PathFinding";
import { ParabolaPathFinder } from "../pathfinding/PathFinding";
import { PseudoRandom } from "../PseudoRandom";
export class NukeExecution implements Execution {
@@ -21,7 +21,7 @@ export class NukeExecution implements Execution {
private nuke: Unit;
private random: PseudoRandom;
private pathFinder: AirPathFinder;
private pathFinder: ParabolaPathFinder;
constructor(
private type: NukeType,
@@ -45,7 +45,7 @@ export class NukeExecution implements Execution {
if (this.speed == -1) {
this.speed = this.mg.config().defaultNukeSpeed();
}
this.pathFinder = new AirPathFinder(mg, this.random);
this.pathFinder = new ParabolaPathFinder(mg);
}
public target(): Player | TerraNullius {
@@ -95,6 +95,11 @@ export class NukeExecution implements Execution {
this.active = false;
return;
}
this.pathFinder.computeControlPoints(
spawn,
this.dst,
this.type != UnitType.MIRVWarhead,
);
this.nuke = this.player.buildUnit(this.type, spawn, {
detonationDst: this.dst,
});
@@ -146,15 +151,13 @@ export class NukeExecution implements Execution {
return;
}
for (let i = 0; i < this.speed; i++) {
// Move to next tile
const nextTile = this.pathFinder.nextTile(this.nuke.tile(), this.dst);
if (nextTile === true) {
this.detonate();
return;
} else {
this.nuke.move(nextTile);
}
// Move to next tile
const nextTile = this.pathFinder.nextTile(this.speed);
if (nextTile === true) {
this.detonate();
return;
} else {
this.nuke.move(nextTile);
}
}
+45
View File
@@ -2,9 +2,54 @@ import { consolex } from "../Consolex";
import { Game } from "../game/Game";
import { GameMap, TileRef } from "../game/GameMap";
import { PseudoRandom } from "../PseudoRandom";
import { DistanceBasedBezierCurve } from "../utilities/Line";
import { AStar, PathFindResultType, TileResult } from "./AStar";
import { MiniAStar } from "./MiniAStar";
const parabolaMinHeight = 50;
export class ParabolaPathFinder {
constructor(private mg: GameMap) {}
private curve: DistanceBasedBezierCurve | undefined;
computeControlPoints(
orig: TileRef,
dst: TileRef,
distanceBasedHeight = true,
) {
const p0 = { x: this.mg.x(orig), y: this.mg.y(orig) };
const p3 = { x: this.mg.x(dst), y: this.mg.y(dst) };
const dx = p3.x - p0.x;
const dy = p3.y - p0.y;
const distance = Math.sqrt(dx * dx + dy * dy);
const maxHeight = distanceBasedHeight
? Math.max(distance / 3, parabolaMinHeight)
: 0;
// Use a bezier curve always pointing up
const p1 = {
x: p0.x + (p3.x - p0.x) / 4,
y: Math.max(p0.y + (p3.y - p0.y) / 4 - maxHeight, 0),
};
const p2 = {
x: p0.x + ((p3.x - p0.x) * 3) / 4,
y: Math.max(p0.y + ((p3.y - p0.y) * 3) / 4 - maxHeight, 0),
};
this.curve = new DistanceBasedBezierCurve(p0, p1, p2, p3);
}
nextTile(speed: number): TileRef | true {
if (!this.curve) {
return;
}
const nextPoint = this.curve.increment(speed);
if (!nextPoint) {
return true;
}
return this.mg.ref(Math.floor(nextPoint.x), Math.floor(nextPoint.y));
}
}
export class AirPathFinder {
constructor(
private mg: GameMap,
+153
View File
@@ -0,0 +1,153 @@
type Point = { x: number; y: number };
export class BezenhamLine {
constructor(
private p1: Point,
private p2: Point,
) {
this.dx = Math.abs(p2.x - p1.x);
this.dy = Math.abs(p2.y - p1.y);
this.sx = p1.x < p2.x ? 1 : -1;
this.sy = p1.y < p2.y ? 1 : -1;
this.error = this.dx - this.dy;
}
private dx: number;
private dy: number;
private sx: number;
private sy: number;
private error: number;
size() {
return Math.max(this.dx, this.dy) + 1;
}
// Increment either by 1 in x or y
increment(): Point | true {
if (this.p1.x === this.p2.x && this.p1.y === this.p2.y) {
return true;
}
const x = this.p1.x;
const y = this.p1.y;
const err2 = 2 * this.error;
if (err2 > -this.dy) {
this.error -= this.dy;
this.p1.x += this.sx;
}
if (err2 < this.dx) {
this.error += this.dx;
this.p1.y += this.sy;
}
return { x, y };
}
}
export class CubicBezierCurve {
constructor(
private p0: Point,
private p1: Point,
private p2: Point,
private p3: Point,
) {}
getPointAt(t: number): Point {
const T = 1 - t;
const TT = T * T;
const TTT = TT * T;
const tt = t * t;
const ttt = tt * t;
const x =
TTT * this.p0.x +
3 * TT * t * this.p1.x +
3 * T * tt * this.p2.x +
ttt * this.p3.x;
const y =
TTT * this.p0.y +
3 * TT * t * this.p1.y +
3 * T * tt * this.p2.y +
ttt * this.p3.y;
return { x, y };
}
}
/**
* Use a cumulative distance LUT to approximate the traveled distance
* Useful to compute regular steps based on the curve rather than a t
*/
export class DistanceBasedBezierCurve extends CubicBezierCurve {
private totalDistance: number = 0;
private distanceLUT: Array<{ t: number; distance: number }> = [];
private lastFoundIndex: number = 0; // To keep track of the last found index
increment(distance: number): Point {
this.totalDistance += distance;
const targetDistance = Math.min(
this.totalDistance,
this.distanceLUT[this.distanceLUT.length - 1]?.distance ||
this.totalDistance,
);
const t = this.computeTForDistance(targetDistance);
if (t >= 1) {
return null; // end reached
}
return this.getPointAt(t);
}
/**
* Generate @p numSteps segments, starting from the beginning of the curve
* Each segment size is added in the LUT
*/
generateCumulativeDistanceLUT(numSteps: number = 500): void {
this.distanceLUT = [];
let cumulativeDistance = 0;
let prevPoint = this.getPointAt(0);
for (let i = 1; i <= numSteps; i++) {
const t = i / numSteps;
const currentPoint = this.getPointAt(t);
const dx = currentPoint.x - prevPoint.x;
const dy = currentPoint.y - prevPoint.y;
const segmentLength = Math.sqrt(dx * dx + dy * dy);
cumulativeDistance += segmentLength;
this.distanceLUT.push({ t, distance: cumulativeDistance });
prevPoint = currentPoint;
}
}
computeTForDistance(distance: number): number {
if (this.distanceLUT.length === 0) {
this.generateCumulativeDistanceLUT();
}
if (distance <= 0) return 0;
if (distance >= this.distanceLUT[this.distanceLUT.length - 1].distance) {
return 1;
}
let lowerIndex = this.lastFoundIndex;
let upperIndex = this.distanceLUT.length - 1;
// Binary search for the closest range
while (upperIndex - lowerIndex > 1) {
const midIndex = Math.floor((upperIndex + lowerIndex) / 2);
if (this.distanceLUT[midIndex].distance < distance) {
lowerIndex = midIndex;
} else {
upperIndex = midIndex;
}
}
const lower = this.distanceLUT[lowerIndex];
const upper = this.distanceLUT[upperIndex];
this.lastFoundIndex = lowerIndex;
// Linear interpolation of t based on the distance
const t =
lower.t +
((distance - lower.distance) * (upper.t - lower.t)) /
(upper.distance - lower.distance);
return t;
}
}