mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-07-11 18:58:09 +00:00
Curved nuke trajectory (#734)
## Description: Change nuke trajectory to follow a curve, and add a trail behind them.  ### 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:  ## 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:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user