-
Notifications
You must be signed in to change notification settings - Fork 792
feat: Nuke util layer added; SAMs now change color and transparency based on nuke prediction. #2944
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
adaca7e
581b43b
244d7dc
7118660
2ff97c0
6295742
2b638b0
184cca0
dc1e759
2c6955d
a79dced
9372cda
2302e7b
1517e3d
e7e6445
0554ceb
07bf639
1fe990c
650bab7
663892a
bcb3841
5805cc6
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,338 @@ | ||
| import type { EventBus } from "../../../core/EventBus"; | ||
| import { listAffectedByNuke } from "../../../core/execution/Util"; | ||
| import { UnitType } from "../../../core/game/Game"; | ||
| import { TileRef } from "../../../core/game/GameMap"; | ||
| import type { GameView } from "../../../core/game/GameView"; | ||
| import { UniversalPathFinding } from "../../../core/pathfinding/PathFinder"; | ||
| import { | ||
| GhostStructureChangedEvent, | ||
| MouseMoveEvent, | ||
| SwapRocketDirectionEvent, | ||
| } from "../../InputHandler"; | ||
| import { TransformHandler } from "../TransformHandler"; | ||
| import { UIState } from "../UIState"; | ||
| import { Layer } from "./Layer"; | ||
|
|
||
| /** | ||
| * A layer that calculates nuke shared information | ||
| * for other layers to draw from. | ||
| * Does not draw anything itself! | ||
| */ | ||
| export class NukeRenderUtilLayer implements Layer { | ||
| private mousePos = { x: 0, y: 0 }; | ||
| private currentGhostStructure: UnitType | null = null; | ||
| private nukeGhostActive = false; | ||
| private targetTile: TileRef | null = null; | ||
| private spawnTile: TileRef | null = null; // only updated on tick | ||
|
|
||
| // A list of every player that would have their alliance break if nuked. | ||
| // Includes players not currently allied. | ||
| private affectedPlayers = new Set<number>(); | ||
| // A list of structures that will be destroyed by this nuke. | ||
| private affectedStructures = new Set<number>(); | ||
|
|
||
| // for trajectory prediction | ||
| private trajectoryPoints: TileRef[] = []; | ||
| private untargetableSegmentBounds: [number, number] = [-1, -1]; | ||
| private targetedIndex = -1; | ||
| private lastTrajectoryUpdate: number = 0; | ||
| private lastTargetTile: TileRef | null = null; | ||
|
|
||
| // A list of players currently stressed or intercepting the trajectory. | ||
| private interceptingPlayers = new Set<number>(); | ||
| // A list of SAM units intercepting the trajectory. | ||
| private interceptingSAMs = new Set<number>(); | ||
|
|
||
| constructor( | ||
| private readonly game: GameView, | ||
| private readonly eventBus: EventBus, | ||
| private readonly uiState: UIState, | ||
| private readonly transformHandler: TransformHandler, | ||
| ) {} | ||
|
|
||
| init() { | ||
| this.eventBus.on(MouseMoveEvent, (e) => { | ||
| this.mousePos.x = e.x; | ||
| this.mousePos.y = e.y; | ||
| }); | ||
| this.eventBus.on(GhostStructureChangedEvent, (e) => { | ||
| this.currentGhostStructure = e.ghostStructure; | ||
| this.nukeGhostActive = | ||
| e.ghostStructure === UnitType.AtomBomb || | ||
| e.ghostStructure === UnitType.HydrogenBomb; | ||
| }); | ||
| this.eventBus.on(SwapRocketDirectionEvent, (event) => { | ||
| this.uiState.rocketDirectionUp = event.rocketDirectionUp; | ||
| // Force trajectory recalculation | ||
| this.lastTargetTile = null; | ||
| }); | ||
bibizu marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| } | ||
|
|
||
| /** | ||
| * Update trajectory preview - called from tick() to cache spawn tile via expensive player.actions() call | ||
| * This only runs when target tile changes, minimizing worker thread communication | ||
| */ | ||
| private trajectoryPreviewTick() { | ||
| // Clear trajectory if not a nuke type | ||
| if (!this.nukeGhostActive) { | ||
| this.spawnTile = null; | ||
| return; | ||
| } | ||
|
|
||
| // Throttle updates (similar to StructureIconsLayer.renderGhost) | ||
| const now = performance.now(); | ||
| if (now - this.lastTrajectoryUpdate < 50) { | ||
| return; | ||
| } | ||
| this.lastTrajectoryUpdate = now; | ||
|
|
||
| const player = this.game.myPlayer(); | ||
| if (!player) { | ||
| this.lastTargetTile = null; | ||
| this.spawnTile = null; | ||
| return; | ||
| } | ||
|
|
||
| if (this.findTargetTile() === null) { | ||
| this.spawnTile = null; | ||
| return; | ||
| } | ||
|
|
||
| // Only recalculate if target tile changed | ||
| if (this.lastTargetTile === this.targetTile) { | ||
| return; | ||
| } | ||
|
|
||
| this.lastTargetTile = this.targetTile; | ||
|
|
||
| // Get buildable units to find spawn tile (expensive call - only on tick when tile changes) | ||
| player | ||
| .actions(this.targetTile as number) | ||
| .then((actions) => { | ||
| // Ignore stale results if target changed | ||
| if (this.lastTargetTile !== this.targetTile) { | ||
| return; | ||
| } | ||
| const buildableUnit = actions.buildableUnits.find( | ||
| (bu) => bu.type === this.currentGhostStructure, | ||
| ); | ||
| if (!buildableUnit || buildableUnit.canBuild === false) { | ||
| this.spawnTile = null; | ||
| return; | ||
| } | ||
| const spawnTile = buildableUnit.canBuild; | ||
| if (!spawnTile) { | ||
| this.spawnTile = null; | ||
| return; | ||
| } | ||
| // Cache the spawn tile for use in updateTrajectoryPath() | ||
| this.spawnTile = spawnTile; | ||
| }) | ||
| .catch(() => { | ||
| // Handle errors silently | ||
| this.spawnTile = null; | ||
| }); | ||
| } | ||
|
|
||
| /** | ||
| * Update trajectory path - called from renderLayer() each frame for smooth visual feedback | ||
| * Uses cached spawn tile to avoid expensive player.actions() calls | ||
| */ | ||
| private trajectoryPreviewFrame() { | ||
| // Target is already found for this frame when this is called in renderLayer(). | ||
| // Safety check | ||
| if (!this.spawnTile || !this.targetTile) { | ||
| this.clearCurrentTrajectory(); | ||
| return; | ||
| } | ||
| // Calculate trajectory using ParabolaUniversalPathFinder with cached spawn tile | ||
| const speed = this.game.config().defaultNukeSpeed(); | ||
| const pathFinder = UniversalPathFinding.Parabola(this.game, { | ||
| increment: speed, | ||
| distanceBasedHeight: true, // AtomBomb/HydrogenBomb use distance-based height | ||
| directionUp: this.uiState.rocketDirectionUp, | ||
| }); | ||
|
|
||
| this.trajectoryPoints = | ||
| pathFinder.findPath(this.spawnTile, this.targetTile) ?? []; | ||
|
|
||
| // NOTE: This is a lot to do in the rendering method, naive | ||
| // But trajectory is already calculated here and needed for prediction. | ||
| // From testing, does not seem to have much effect, so I keep it this way. | ||
|
|
||
| // Calculate points when bomb targetability switches | ||
| const targetRangeSquared = | ||
| this.game.config().defaultNukeTargetableRange() ** 2; | ||
|
|
||
| // Find two switch points where bomb transitions: | ||
| // [0]: leaves spawn range, enters untargetable zone | ||
| // [1]: enters target range, becomes targetable again | ||
| this.untargetableSegmentBounds = [-1, -1]; | ||
| for (let i = 0; i < this.trajectoryPoints.length; i++) { | ||
| const tile = this.trajectoryPoints[i]; | ||
| if (this.untargetableSegmentBounds[0] === -1) { | ||
| if ( | ||
| this.game.euclideanDistSquared(tile, this.spawnTile) > | ||
| targetRangeSquared | ||
| ) { | ||
| if ( | ||
| this.game.euclideanDistSquared(tile, this.targetTile) < | ||
| targetRangeSquared | ||
| ) { | ||
| // overlapping spawn & target range | ||
| break; | ||
| } else { | ||
| this.untargetableSegmentBounds[0] = i; | ||
| } | ||
| } | ||
| } else if ( | ||
| this.game.euclideanDistSquared(tile, this.targetTile) < | ||
| targetRangeSquared | ||
| ) { | ||
| this.untargetableSegmentBounds[1] = i; | ||
| break; | ||
| } | ||
| } | ||
| this.interceptingPlayers.clear(); | ||
| this.interceptingSAMs.clear(); | ||
| // Find the point where SAM can intercept | ||
| this.targetedIndex = this.trajectoryPoints.length; | ||
| // Check trajectory | ||
| for (let i = 0; i < this.trajectoryPoints.length; i++) { | ||
| const tile = this.trajectoryPoints[i]; | ||
| for (const sam of this.game.nearbyUnits( | ||
| tile, | ||
| this.game.config().maxSamRange(), | ||
| UnitType.SAMLauncher, | ||
| )) { | ||
| if ( | ||
| sam.unit.owner().isMe() || | ||
| (this.game.myPlayer()?.isFriendly(sam.unit.owner()) && | ||
| !this.affectedPlayers.has(sam.unit.owner().smallID()) && | ||
| !this.interceptingPlayers.has(sam.unit.owner().smallID())) | ||
| ) { | ||
| continue; | ||
| } | ||
| if ( | ||
| sam.distSquared <= | ||
| this.game.config().samRange(sam.unit.level()) ** 2 | ||
| ) { | ||
| this.targetedIndex = i; | ||
| this.interceptingPlayers.add(sam.unit.owner().smallID()); | ||
| this.interceptingSAMs.add(sam.unit.id()); | ||
| } | ||
| } | ||
| if (this.targetedIndex !== this.trajectoryPoints.length) break; | ||
| // Jump over untargetable segment | ||
| if (i === this.untargetableSegmentBounds[0]) | ||
| i = this.untargetableSegmentBounds[1] - 1; | ||
| } | ||
coderabbitai[bot] marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| } | ||
|
|
||
| // Attempts to find the tile the mouse points to. | ||
| // If valid, sets the targetTile property and returns it. | ||
| private findTargetTile(): TileRef | null { | ||
| this.targetTile = null; | ||
| if (!this.nukeGhostActive) { | ||
| return null; | ||
| } | ||
| // Convert mouse position to world coordinates | ||
| const rect = this.transformHandler.boundingRect(); | ||
| if (!rect) { | ||
| return null; | ||
| } | ||
| const localX = this.mousePos.x - rect.left; | ||
| const localY = this.mousePos.y - rect.top; | ||
| const worldCoords = this.transformHandler.screenToWorldCoordinates( | ||
| localX, | ||
| localY, | ||
| ); | ||
| if (!this.game.isValidCoord(worldCoords.x, worldCoords.y)) { | ||
| return null; | ||
| } | ||
| this.targetTile = this.game.ref(worldCoords.x, worldCoords.y); | ||
| return this.targetTile; | ||
|
Comment on lines
+239
to
+254
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 Analysis chain🏁 Script executed: # Find and read the full findTargetTile method in NukeRenderUtilLayer.ts
fd NukeRenderUtilLayer.ts --type fRepository: openfrontio/OpenFrontIO Length of output: 116 🏁 Script executed: # Find TransformHandler to understand screenToWorldCoordinates implementation
fd -e ts | xargs rg -l 'class TransformHandler|screenToWorldCoordinates' | head -5Repository: openfrontio/OpenFrontIO Length of output: 299 🏁 Script executed: # Find StructureIconsLayer.ts and check the renderGhost method mentioned
fd StructureIconsLayer.ts --type fRepository: openfrontio/OpenFrontIO Length of output: 116 🏁 Script executed: # Read the full findTargetTile method and surrounding context
rg -n 'findTargetTile' src/client/graphics/layers/NukeRenderUtilLayer.ts -B5 -A30Repository: openfrontio/OpenFrontIO Length of output: 3670 🏁 Script executed: # Find where TransformHandler is defined
rg -n 'class TransformHandler|interface TransformHandler' --type=tsRepository: openfrontio/OpenFrontIO Length of output: 141 🏁 Script executed: # Find the screenToWorldCoordinates method implementation
rg -n 'screenToWorldCoordinates' --type=ts -B2 -A15 | head -80Repository: openfrontio/OpenFrontIO Length of output: 6045 🏁 Script executed: # Now check StructureIconsLayer.ts renderGhost method mentioned in the review
rg -n 'renderGhost' src/client/graphics/layers/StructureIconsLayer.ts -B5 -A20Repository: openfrontio/OpenFrontIO Length of output: 1662 🏁 Script executed: # Also check all usages of screenToWorldCoordinates to see the pattern
rg -n 'screenToWorldCoordinates' --type=ts -B3 -A3 | grep -E "(\.x|\.y|-|screenToWorldCoordinates)"Repository: openfrontio/OpenFrontIO Length of output: 7540 🏁 Script executed: # Verify the exact context - check if rect is the canvas bounding rect
rg -n 'boundingRect' src/client/graphics/TransformHandler.ts -B2 -A8Repository: openfrontio/OpenFrontIO Length of output: 4010 Remove the intermediate canvas-relative coordinate calculation—pass raw screen coordinates directly to The method expects raw screen coordinates and internally subtracts the canvas bounding rect. Lines 244–245 already subtract Compare with all other usages in the codebase (UnitLayer, TerritoryLayer, EmojiTable, BuildMenu, etc.)—they pass raw coordinates directly. Change to: const worldCoords = this.transformHandler.screenToWorldCoordinates(
this.mousePos.x,
this.mousePos.y,
);Then remove lines 244–245. This same bug exists in StructureIconsLayer.ts lines 255–259. 🤖 Prompt for AI Agents
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Nothing breaks in testing, and I suspect this is not an issue since we use mouse position directly, which needs the window position subtracted first, or something similar, to get it into "screen" coords. If there actually was a double subtraction, the trajectory should be way off.
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 Analysis chain🏁 Script executed: Length of output: 597 🏁 Script executed: Length of output: 330 🏁 Script executed: Length of output: 3517 🏁 Script executed: Length of output: 2490 🏁 Script executed: Length of output: 1579 🏁 Script executed: Length of output: 1172 🏁 Script executed: Length of output: 127 🏁 Script executed: Length of output: 743 🏁 Script executed: Length of output: 44050
However, the double subtraction mathematically exists: Current code flow:
When the bug would surface:
Evidence from codebase:
Recommendation: 🧠 Learnings used |
||
| } | ||
|
|
||
| // Resets variables relating to trajectory prediction | ||
| private clearCurrentTrajectory() { | ||
| // check trajectory already cleared | ||
| if (this.targetedIndex === -1) { | ||
| return; | ||
| } | ||
| this.trajectoryPoints = []; | ||
| this.interceptingPlayers.clear(); | ||
| this.targetedIndex = -1; | ||
| this.untargetableSegmentBounds = [-1, -1]; | ||
| } | ||
|
|
||
| tick() { | ||
| this.trajectoryPreviewTick(); | ||
| } | ||
|
|
||
| renderLayer(context: CanvasRenderingContext2D) { | ||
| if (this.findTargetTile() === null || !this.spawnTile) { | ||
| this.affectedPlayers.clear(); | ||
| this.clearCurrentTrajectory(); | ||
| return; | ||
| } | ||
| const player = this.game.myPlayer(); | ||
| if (!player) { | ||
| return; | ||
| } | ||
bibizu marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
|
||
| // Calculate which players & structures are affected by current nuke placement. | ||
| const affectedByNuke = listAffectedByNuke({ | ||
| game: this.game, | ||
| targetTile: this.targetTile as number, | ||
| magnitude: this.game | ||
| .config() | ||
| .nukeMagnitudes(this.uiState.ghostStructure as UnitType), | ||
| playerID: player.smallID(), | ||
| allySmallIds: new Set( | ||
| this.game | ||
| .myPlayer() | ||
| ?.allies() | ||
| .map((a) => a.smallID()), | ||
| ), | ||
| threshold: this.game.config().nukeAllianceBreakThreshold(), | ||
| }); | ||
| this.affectedPlayers = affectedByNuke.affectedPlayerIDs; | ||
| this.affectedStructures = affectedByNuke.affectedStructureIDs; | ||
|
|
||
| // Calculate possible trajectory | ||
| this.trajectoryPreviewFrame(); | ||
| } | ||
|
|
||
| isNukeGhostActive() { | ||
| return this.nukeGhostActive; | ||
| } | ||
|
|
||
| // players who are targeted by nuke | ||
| getAffectedPlayers() { | ||
| return this.affectedPlayers; | ||
| } | ||
|
|
||
| // structures targeted by nuke | ||
| getAffectedStructures() { | ||
| return this.affectedStructures; | ||
| } | ||
|
|
||
| // players who will shoot the nuke down first are intercepting | ||
| getInterceptingPlayers() { | ||
| return this.interceptingPlayers; | ||
| } | ||
|
|
||
| // Specific SAM units intercepting the nuke | ||
| getInterceptingSAMs() { | ||
| return this.interceptingSAMs; | ||
| } | ||
|
|
||
| getTrajectoryInfo() { | ||
| return { | ||
| trajectoryPoints: this.trajectoryPoints, | ||
| untargetableSegmentBounds: this.untargetableSegmentBounds, | ||
| targetedIndex: this.targetedIndex, | ||
| }; | ||
| } | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think it would be a bit cleaner to merge these two layers into one layer, and then that layer has a nukeRenderUtil layer that it calls into?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Structure Icon Layer was also running more or less the same logic, so there are three layers that all require the same calculation. This also makes it easy if I want to add more layers as well needing it as well. (such as I might wanna work on showing which structures in StructureLayer get destroyed by the nuke).