From 7c163e23613b929c2825f4139286353405c6ff7f Mon Sep 17 00:00:00 2001 From: DevelopingTom Date: Tue, 13 May 2025 07:30:31 +0200 Subject: [PATCH] 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: --------- Co-authored-by: Scott Anderson --- src/client/graphics/layers/UnitLayer.ts | 137 ++++++++++++++------- src/core/execution/MIRVExecution.ts | 29 +++-- src/core/execution/NukeExecution.ts | 27 +++-- src/core/pathfinding/PathFinding.ts | 45 +++++++ src/core/utilities/Line.ts | 153 ++++++++++++++++++++++++ 5 files changed, 319 insertions(+), 72 deletions(-) create mode 100644 src/core/utilities/Line.ts diff --git a/src/client/graphics/layers/UnitLayer.ts b/src/client/graphics/layers/UnitLayer.ts index b071fd9cc..6dbd6498c 100644 --- a/src/client/graphics/layers/UnitLayer.ts +++ b/src/client/graphics/layers/UnitLayer.ts @@ -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(); + private unitToTrail = new Map(); 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); } } diff --git a/src/core/execution/MIRVExecution.ts b/src/core/execution/MIRVExecution.ts index 66d38f8e1..61b38df81 100644 --- a/src/core/execution/MIRVExecution.ts +++ b/src/core/execution/MIRVExecution.ts @@ -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); } } diff --git a/src/core/execution/NukeExecution.ts b/src/core/execution/NukeExecution.ts index 666f2f9c7..4572eefc5 100644 --- a/src/core/execution/NukeExecution.ts +++ b/src/core/execution/NukeExecution.ts @@ -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); } } diff --git a/src/core/pathfinding/PathFinding.ts b/src/core/pathfinding/PathFinding.ts index 1a255c8dd..1ddca9882 100644 --- a/src/core/pathfinding/PathFinding.ts +++ b/src/core/pathfinding/PathFinding.ts @@ -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, diff --git a/src/core/utilities/Line.ts b/src/core/utilities/Line.ts new file mode 100644 index 000000000..521c66d92 --- /dev/null +++ b/src/core/utilities/Line.ts @@ -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; + } +}