diff --git a/resources/lang/en.json b/resources/lang/en.json index 074a35c4ad..8d372517e3 100644 --- a/resources/lang/en.json +++ b/resources/lang/en.json @@ -129,7 +129,8 @@ "icon_embargo": "Dollar stop sign - Embargo. This player has stopped trading with you automatically or manually.", "icon_request": "Envelope - Alliance request. This player has sent you an alliance request.", "info_enemy_panel": "Enemy info panel", - "exit_confirmation": "Are you sure you want to exit the game?" + "exit_confirmation": "Are you sure you want to exit the game?", + "bomb_direction": "Atom / Hydrogen bomb arc direction" }, "single_modal": { "title": "Single Player", diff --git a/src/client/HelpModal.ts b/src/client/HelpModal.ts index 1b840c27ba..dac511056a 100644 --- a/src/client/HelpModal.ts +++ b/src/client/HelpModal.ts @@ -55,6 +55,10 @@ export class HelpModal extends LitElement { Space ${translateText("help_modal.action_alt_view")} + + U + ${translateText("help_modal.bomb_direction")} +
diff --git a/src/client/InputHandler.ts b/src/client/InputHandler.ts index 26d8f6c27d..7d4fb5e48c 100644 --- a/src/client/InputHandler.ts +++ b/src/client/InputHandler.ts @@ -89,6 +89,8 @@ export class GhostStructureChangedEvent implements GameEvent { constructor(public readonly ghostStructure: UnitType | null) {} } +export class SwapRocketDirectionEvent implements GameEvent {} + export class ShowBuildMenuEvent implements GameEvent { constructor( public readonly x: number, @@ -200,6 +202,7 @@ export class InputHandler { attackRatioUp: "KeyY", boatAttack: "KeyB", groundAttack: "KeyG", + swapDirection: "KeyU", modifierKey: isMac ? "MetaLeft" : "ControlLeft", altKey: "AltLeft", buildCity: "Digit1", @@ -417,6 +420,11 @@ export class InputHandler { this.setGhostStructure(UnitType.MIRV); } + if (e.code === this.keybinds.swapDirection) { + e.preventDefault(); + this.eventBus.emit(new SwapRocketDirectionEvent()); + } + // Shift-D to toggle performance overlay console.log(e.code, e.shiftKey, e.ctrlKey, e.altKey, e.metaKey); if (e.code === "KeyD" && e.shiftKey) { diff --git a/src/client/Transport.ts b/src/client/Transport.ts index 9f4f1f5a7f..96958b0823 100644 --- a/src/client/Transport.ts +++ b/src/client/Transport.ts @@ -90,6 +90,7 @@ export class BuildUnitIntentEvent implements GameEvent { constructor( public readonly unit: UnitType, public readonly tile: TileRef, + public readonly rocketDirectionUp?: boolean, ) {} } @@ -571,6 +572,7 @@ export class Transport { clientID: this.lobbyConfig.clientID, unit: event.unit, tile: event.tile, + rocketDirectionUp: event.rocketDirectionUp, }); } diff --git a/src/client/graphics/GameRenderer.ts b/src/client/graphics/GameRenderer.ts index 50911c8dbc..c553cfa2ca 100644 --- a/src/client/graphics/GameRenderer.ts +++ b/src/client/graphics/GameRenderer.ts @@ -50,7 +50,11 @@ export function createRenderer( const transformHandler = new TransformHandler(game, eventBus, canvas); const userSettings = new UserSettings(); - const uiState = { attackRatio: 20, ghostStructure: null } as UIState; + const uiState = { + attackRatio: 20, + ghostStructure: null, + rocketDirectionUp: true, + } as UIState; //hide when the game renders const startingModal = document.querySelector( @@ -73,6 +77,7 @@ export function createRenderer( } buildMenu.game = game; buildMenu.eventBus = eventBus; + buildMenu.uiState = uiState; buildMenu.transformHandler = transformHandler; const leaderboard = document.querySelector("leader-board") as Leaderboard; @@ -245,7 +250,7 @@ export function createRenderer( new UnitLayer(game, eventBus, transformHandler), new FxLayer(game), new UILayer(game, eventBus, transformHandler), - new NukeTrajectoryPreviewLayer(game, eventBus, transformHandler), + new NukeTrajectoryPreviewLayer(game, eventBus, transformHandler, uiState), new StructureIconsLayer(game, eventBus, uiState, transformHandler), new NameLayer(game, transformHandler, eventBus), eventsDisplay, diff --git a/src/client/graphics/UIState.ts b/src/client/graphics/UIState.ts index 01c4a60cb9..f47acef885 100644 --- a/src/client/graphics/UIState.ts +++ b/src/client/graphics/UIState.ts @@ -3,4 +3,5 @@ import { UnitType } from "../../core/game/Game"; export interface UIState { attackRatio: number; ghostStructure: UnitType | null; + rocketDirectionUp: boolean; } diff --git a/src/client/graphics/layers/BuildMenu.ts b/src/client/graphics/layers/BuildMenu.ts index 46adc33677..39fd604a11 100644 --- a/src/client/graphics/layers/BuildMenu.ts +++ b/src/client/graphics/layers/BuildMenu.ts @@ -33,6 +33,7 @@ import { } from "../../Transport"; import { renderNumber } from "../../Utils"; import { TransformHandler } from "../TransformHandler"; +import { UIState } from "../UIState"; import { Layer } from "./Layer"; export interface BuildItemDisplay { @@ -125,6 +126,7 @@ export const flattenedBuildTable = buildTable.flat(); export class BuildMenu extends LitElement implements Layer { public game: GameView; public eventBus: EventBus; + public uiState: UIState; private clickedTile: TileRef; public playerActions: PlayerActions | null; private filteredBuildTable: BuildItemDisplay[][] = buildTable; @@ -395,7 +397,14 @@ export class BuildMenu extends LitElement implements Layer { ), ); } else if (buildableUnit.canBuild) { - this.eventBus.emit(new BuildUnitIntentEvent(buildableUnit.type, tile)); + const rocketDirectionUp = + buildableUnit.type === UnitType.AtomBomb || + buildableUnit.type === UnitType.HydrogenBomb + ? this.uiState.rocketDirectionUp + : undefined; + this.eventBus.emit( + new BuildUnitIntentEvent(buildableUnit.type, tile, rocketDirectionUp), + ); } this.hideMenu(); } diff --git a/src/client/graphics/layers/NukeTrajectoryPreviewLayer.ts b/src/client/graphics/layers/NukeTrajectoryPreviewLayer.ts index c0e74af83e..67632585dd 100644 --- a/src/client/graphics/layers/NukeTrajectoryPreviewLayer.ts +++ b/src/client/graphics/layers/NukeTrajectoryPreviewLayer.ts @@ -3,8 +3,13 @@ import { UnitType } from "../../../core/game/Game"; import { TileRef } from "../../../core/game/GameMap"; import { GameView } from "../../../core/game/GameView"; import { ParabolaPathFinder } from "../../../core/pathfinding/PathFinding"; -import { GhostStructureChangedEvent, MouseMoveEvent } from "../../InputHandler"; +import { + GhostStructureChangedEvent, + MouseMoveEvent, + SwapRocketDirectionEvent, +} from "../../InputHandler"; import { TransformHandler } from "../TransformHandler"; +import { UIState } from "../UIState"; import { Layer } from "./Layer"; /** @@ -27,6 +32,7 @@ export class NukeTrajectoryPreviewLayer implements Layer { private game: GameView, private eventBus: EventBus, private transformHandler: TransformHandler, + private uiState: UIState, ) {} shouldTransform(): boolean { @@ -50,6 +56,12 @@ export class NukeTrajectoryPreviewLayer implements Layer { this.cachedSpawnTile = null; } }); + this.eventBus.on(SwapRocketDirectionEvent, () => { + // Toggle rocket direction + this.uiState.rocketDirectionUp = !this.uiState.rocketDirectionUp; + // Force trajectory recalculation + this.lastTargetTile = null; + }); } tick() { @@ -210,6 +222,7 @@ export class NukeTrajectoryPreviewLayer implements Layer { targetTile, speed, distanceBasedHeight, + this.uiState.rocketDirectionUp, ); this.trajectoryPoints = pathFinder.allTiles(); diff --git a/src/client/graphics/layers/StructureIconsLayer.ts b/src/client/graphics/layers/StructureIconsLayer.ts index 1458e7affd..0b12cb90d7 100644 --- a/src/client/graphics/layers/StructureIconsLayer.ts +++ b/src/client/graphics/layers/StructureIconsLayer.ts @@ -334,10 +334,16 @@ export class StructureIconsLayer implements Layer { ), ); } else if (this.ghostUnit.buildableUnit.canBuild) { + const unitType = this.ghostUnit.buildableUnit.type; + const rocketDirectionUp = + unitType === UnitType.AtomBomb || unitType === UnitType.HydrogenBomb + ? this.uiState.rocketDirectionUp + : undefined; this.eventBus.emit( new BuildUnitIntentEvent( - this.ghostUnit.buildableUnit.type, + unitType, this.game.ref(tile.x, tile.y), + rocketDirectionUp, ), ); } diff --git a/src/core/Schemas.ts b/src/core/Schemas.ts index 74b999b13c..c0eb8b0306 100644 --- a/src/core/Schemas.ts +++ b/src/core/Schemas.ts @@ -304,6 +304,7 @@ export const BuildUnitIntentSchema = BaseIntentSchema.extend({ type: z.literal("build_unit"), unit: z.enum(UnitType), tile: z.number(), + rocketDirectionUp: z.boolean().optional(), }); export const UpgradeStructureIntentSchema = BaseIntentSchema.extend({ diff --git a/src/core/execution/ConstructionExecution.ts b/src/core/execution/ConstructionExecution.ts index 799556fe06..2fdd92da1c 100644 --- a/src/core/execution/ConstructionExecution.ts +++ b/src/core/execution/ConstructionExecution.ts @@ -21,6 +21,7 @@ export class ConstructionExecution implements Execution { private player: Player, private constructionType: UnitType, private tile: TileRef, + private rocketDirectionUp?: boolean, ) {} init(mg: Game, ticks: number): void { @@ -104,7 +105,15 @@ export class ConstructionExecution implements Execution { case UnitType.AtomBomb: case UnitType.HydrogenBomb: this.mg.addExecution( - new NukeExecution(this.constructionType, player, this.tile), + new NukeExecution( + this.constructionType, + player, + this.tile, + null, + -1, + 0, + this.rocketDirectionUp, + ), ); break; case UnitType.MIRV: diff --git a/src/core/execution/ExecutionManager.ts b/src/core/execution/ExecutionManager.ts index 4a8e5df916..af97167adb 100644 --- a/src/core/execution/ExecutionManager.ts +++ b/src/core/execution/ExecutionManager.ts @@ -105,7 +105,12 @@ export class Executor { case "embargo_all": return new EmbargoAllExecution(player, intent.action); case "build_unit": - return new ConstructionExecution(player, intent.unit, intent.tile); + return new ConstructionExecution( + player, + intent.unit, + intent.tile, + intent.rocketDirectionUp, + ); case "allianceExtension": { return new AllianceExtensionExecution(player, intent.recipient); } diff --git a/src/core/execution/NukeExecution.ts b/src/core/execution/NukeExecution.ts index 131533784b..f3b97249ad 100644 --- a/src/core/execution/NukeExecution.ts +++ b/src/core/execution/NukeExecution.ts @@ -30,6 +30,7 @@ export class NukeExecution implements Execution { private src?: TileRef | null, private speed: number = -1, private waitTicks = 0, + private rocketDirectionUp: boolean = true, ) {} init(mg: Game, ticks: number): void { @@ -115,6 +116,7 @@ export class NukeExecution implements Execution { this.dst, this.speed, this.nukeType !== UnitType.MIRVWarhead, + this.rocketDirectionUp, ); this.nuke = this.player.buildUnit(this.nukeType, spawn, { targetTile: this.dst, diff --git a/src/core/pathfinding/PathFinding.ts b/src/core/pathfinding/PathFinding.ts index 2050cc162d..d62487f0b0 100644 --- a/src/core/pathfinding/PathFinding.ts +++ b/src/core/pathfinding/PathFinding.ts @@ -16,6 +16,7 @@ export class ParabolaPathFinder { dst: TileRef, increment: number = 3, distanceBasedHeight = true, + directionUp = 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) }; @@ -25,14 +26,28 @@ export class ParabolaPathFinder { const maxHeight = distanceBasedHeight ? Math.max(distance / 3, parabolaMinHeight) : 0; - // Use a bezier curve always pointing up + // Use a bezier curve pointing up or down based on directionUp parameter + const heightMultiplier = directionUp ? -1 : 1; + const mapHeight = this.mg.height(); const p1 = { x: p0.x + (p3.x - p0.x) / 4, - y: Math.max(p0.y + (p3.y - p0.y) / 4 - maxHeight, 0), + y: Math.max( + 0, + Math.min( + p0.y + (p3.y - p0.y) / 4 + heightMultiplier * maxHeight, + mapHeight - 1, + ), + ), }; const p2 = { x: p0.x + ((p3.x - p0.x) * 3) / 4, - y: Math.max(p0.y + ((p3.y - p0.y) * 3) / 4 - maxHeight, 0), + y: Math.max( + 0, + Math.min( + p0.y + ((p3.y - p0.y) * 3) / 4 + heightMultiplier * maxHeight, + mapHeight - 1, + ), + ), }; this.curve = new DistanceBasedBezierCurve(p0, p1, p2, p3, increment); diff --git a/tests/InputHandler.test.ts b/tests/InputHandler.test.ts index 210d65739e..16cadc97ff 100644 --- a/tests/InputHandler.test.ts +++ b/tests/InputHandler.test.ts @@ -37,7 +37,7 @@ describe("InputHandler AutoUpgrade", () => { eventBus = new EventBus(); inputHandler = new InputHandler( - { attackRatio: 20, ghostStructure: null }, + { attackRatio: 20, ghostStructure: null, rocketDirectionUp: true }, mockCanvas, eventBus, );