From 373de1a75282c96d008b75f85cdac34508ed33cb Mon Sep 17 00:00:00 2001 From: Loymdayddaud <145969603+TheGiraffe3@users.noreply.github.com> Date: Thu, 22 May 2025 21:48:53 +0300 Subject: [PATCH 1/3] New bot names (#435) ## Description: Adds several new names for bots to use. Fixes #465 ## 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: N/A --- src/core/execution/utils/BotNames.ts | 26 ++++++++++++++++++++++++-- 1 file changed, 24 insertions(+), 2 deletions(-) diff --git a/src/core/execution/utils/BotNames.ts b/src/core/execution/utils/BotNames.ts index 59133b5ce..72e30aaf8 100644 --- a/src/core/execution/utils/BotNames.ts +++ b/src/core/execution/utils/BotNames.ts @@ -171,6 +171,13 @@ export const BOT_NAME_PREFIXES = [ "Baloch", "Afghan", "Persian", + "Kenyan", + "Ugandan", + "Bhutanese", + "Latin", + "Moldovan", + "Militant", + "Spartan", ]; export const BOT_NAME_SUFFIXES = [ "Empire", @@ -224,9 +231,24 @@ export const BOT_NAME_SUFFIXES = [ "Ascendancy", "Supremacy", "Province", - "Kingdoms", - "Tribes", + "Tribe", "Dominion", "Assembly", "Republics", + "Army", + "Dictatorship", + "Country", + "Oligarchy", + "Monkdom", + "Throng", + "Host", + "Area", + "District", + "Fief", + "Wilderness", + "Settlement", + "Parliament", + "Anarchy", + "Democracy", + "Autocracy", ]; From 9302af868d64a596bca45799804d3e7db54200a2 Mon Sep 17 00:00:00 2001 From: Scott Anderson Date: Thu, 22 May 2025 15:06:30 -0400 Subject: [PATCH 2/3] Combine analytics and game types (#839) ## Description: Combine analytics and game types. Simplify and remove redundant player information. - Remove ip address. - Add playerID. - Combine redundant player tables. - Move game metadata in to GameEndInfo type, an extension of GameStartInfo ## 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 --------- Co-authored-by: Scott Anderson <662325+scottanderson@users.noreply.github.com> --- src/client/ClientGameRunner.ts | 5 +- src/client/LocalPersistantStats.ts | 2 +- src/client/LocalServer.ts | 4 +- src/client/Main.ts | 2 +- src/core/Schemas.ts | 34 +++++----- .../{ArchiveSchemas.ts => StatsSchemas.ts} | 0 src/core/Util.ts | 64 ++++++++----------- src/core/execution/NukeExecution.ts | 2 +- src/core/execution/SAMMissileExecution.ts | 2 +- src/core/game/Stats.ts | 2 +- src/core/game/StatsImpl.ts | 4 +- src/server/Archive.ts | 52 ++++++--------- src/server/GameServer.ts | 4 +- src/server/Worker.ts | 2 - 14 files changed, 75 insertions(+), 104 deletions(-) rename src/core/{ArchiveSchemas.ts => StatsSchemas.ts} (100%) diff --git a/src/client/ClientGameRunner.ts b/src/client/ClientGameRunner.ts index df9a97a19..f1b233987 100644 --- a/src/client/ClientGameRunner.ts +++ b/src/client/ClientGameRunner.ts @@ -187,9 +187,10 @@ export class ClientGameRunner { } private saveGame(update: WinUpdate) { + if (this.myPlayer === null) throw new Error("Not initialized"); const players: PlayerRecord[] = [ { - ip: null, + playerID: this.myPlayer.id(), persistentID: getPersistentIDFromCookie(), username: this.lobby.playerName, clientID: this.lobby.clientID, @@ -210,7 +211,7 @@ export class ClientGameRunner { } const record = createGameRecord( this.lobby.gameStartInfo.gameID, - this.lobby.gameStartInfo, + this.lobby.gameStartInfo.config, players, // Not saving turns locally [], diff --git a/src/client/LocalPersistantStats.ts b/src/client/LocalPersistantStats.ts index 9f8c93bd6..32ac81d72 100644 --- a/src/client/LocalPersistantStats.ts +++ b/src/client/LocalPersistantStats.ts @@ -47,7 +47,7 @@ export function endGame(gameRecord: GameRecord) { } const stats = getStats(); - const gameStat = stats[gameRecord.id]; + const gameStat = stats[gameRecord.info.gameID]; if (!gameStat) { consolex.log("LocalPersistantStats: game not found"); diff --git a/src/client/LocalServer.ts b/src/client/LocalServer.ts index 93f910433..0467f1e26 100644 --- a/src/client/LocalServer.ts +++ b/src/client/LocalServer.ts @@ -176,7 +176,7 @@ export class LocalServer { } const players: PlayerRecord[] = [ { - ip: null, + playerID: this.lobbyConfig.clientID, // hack? persistentID: getPersistentIDFromCookie(), username: this.lobbyConfig.playerName, clientID: this.lobbyConfig.clientID, @@ -188,7 +188,7 @@ export class LocalServer { } const record = createGameRecord( this.lobbyConfig.gameStartInfo.gameID, - this.lobbyConfig.gameStartInfo, + this.lobbyConfig.gameStartInfo.config, players, this.turns, this.startedAt, diff --git a/src/client/Main.ts b/src/client/Main.ts index 9955333ba..3e896afbd 100644 --- a/src/client/Main.ts +++ b/src/client/Main.ts @@ -291,7 +291,7 @@ class Client { playerName: this.usernameInput?.getCurrentUsername() ?? "", token: localStorage.getItem("token") ?? getPersistentIDFromCookie(), clientID: lobby.clientID, - gameStartInfo: lobby.gameStartInfo ?? lobby.gameRecord?.gameStartInfo, + gameStartInfo: lobby.gameStartInfo ?? lobby.gameRecord?.info, gameRecord: lobby.gameRecord, }, () => { diff --git a/src/core/Schemas.ts b/src/core/Schemas.ts index 7e2fff5e2..377db35b0 100644 --- a/src/core/Schemas.ts +++ b/src/core/Schemas.ts @@ -1,6 +1,5 @@ import { z } from "zod"; import quickChatData from "../../resources/QuickChat.json" with { type: "json" }; -import { PlayerStatsSchema } from "./ArchiveSchemas"; import { AllPlayers, Difficulty, @@ -11,6 +10,7 @@ import { PlayerType, UnitType, } from "./game/Game"; +import { PlayerStatsSchema } from "./StatsSchemas"; import { flattenedEmojiTable } from "./Util"; export type GameID = string; @@ -88,9 +88,6 @@ export type ClientJoinMessage = z.infer; export type ClientLogMessage = z.infer; export type ClientHashMessage = z.infer; -export type PlayerRecord = z.infer; -export type GameRecord = z.infer; - export type AllPlayersStats = z.infer; export type Player = z.infer; export type GameStartInfo = z.infer; @@ -442,26 +439,31 @@ export const ClientMessageSchema = z.union([ ClientHashSchema, ]); -export const PlayerRecordSchema = z.object({ - clientID: ID, - username: SafeString, - ip: SafeString.nullable(), // WARNING: PII +export const PlayerRecordSchema = PlayerSchema.extend({ persistentID: PersistentIdSchema, // WARNING: PII stats: PlayerStatsSchema, }); +export type PlayerRecord = z.infer; -export const GameRecordSchema = z.object({ - id: ID, - gameStartInfo: GameStartInfoSchema, +export const GameEndInfoSchema = GameStartInfoSchema.extend({ players: z.array(PlayerRecordSchema), - startTimestampMS: z.number(), - endTimestampMS: z.number(), - durationSeconds: z.number(), - date: SafeString, + start: z.number(), + end: z.number(), + duration: z.number().nonnegative(), num_turns: z.number(), - turns: z.array(TurnSchema), winner: z.union([ID, SafeString]).nullable().optional(), winnerType: z.enum(["player", "team"]).nullable().optional(), +}); +export type GameEndInfo = z.infer; + +export const AnalyticsRecordSchema = z.object({ + info: GameEndInfoSchema, version: z.literal("v0.0.2"), gitCommit: z.string(), }); +export type AnalyticsRecord = z.infer; + +export const GameRecordSchema = AnalyticsRecordSchema.extend({ + turns: z.array(TurnSchema), +}); +export type GameRecord = z.infer; diff --git a/src/core/ArchiveSchemas.ts b/src/core/StatsSchemas.ts similarity index 100% rename from src/core/ArchiveSchemas.ts rename to src/core/StatsSchemas.ts diff --git a/src/core/Util.ts b/src/core/Util.ts index a793d8742..8cea53baf 100644 --- a/src/core/Util.ts +++ b/src/core/Util.ts @@ -5,9 +5,9 @@ import { Cell, Team, Unit } from "./game/Game"; import { GameMap, TileRef } from "./game/GameMap"; import { ClientID, + GameConfig, GameID, GameRecord, - GameStartInfo, PlayerRecord, Turn, } from "./Schemas"; @@ -184,53 +184,39 @@ export function onlyImages(html: string) { } export function createGameRecord( - id: GameID, - gameStartInfo: GameStartInfo, + gameID: GameID, + config: GameConfig, // username does not need to be set. players: PlayerRecord[], - turns: Turn[], - startTimestampMS: number, - endTimestampMS: number, + allTurns: Turn[], + start: number, + end: number, winner: ClientID | Team | null, winnerType: "player" | "team" | null, ): GameRecord { - const durationSeconds = Math.floor( - (endTimestampMS - startTimestampMS) / 1000, - ); - const date = new Date().toISOString().split("T")[0]; + const duration = Math.floor((end - start) / 1000); const version = "v0.0.2"; const gitCommit = ""; + const num_turns = allTurns.length; + const turns = allTurns.filter( + (t) => t.intents.length !== 0 || t.hash !== undefined, + ); const record: GameRecord = { - gitCommit, - id, - gameStartInfo, - players, - startTimestampMS, - endTimestampMS, - durationSeconds, - date, - num_turns: 0, - turns: [], + info: { + gameID, + config, + players, + start, + end, + duration, + num_turns, + winner, + winnerType, + }, version, - winner, - winnerType, + gitCommit, + turns, }; - - for (const turn of turns) { - if (turn.intents.length !== 0 || turn.hash !== undefined) { - record.turns.push(turn); - for (const intent of turn.intents) { - if (intent.type === "spawn") { - for (const playerRecord of players) { - if (playerRecord.clientID === intent.clientID) { - playerRecord.username = intent.name; - } - } - } - } - } - } - record.num_turns = turns.length; return record; } @@ -249,7 +235,7 @@ export function decompressGameRecord(gameRecord: GameRecord) { lastTurnNum = turn.turnNumber; } const turnLength = turns.length; - for (let i = turnLength; i < gameRecord.num_turns; i++) { + for (let i = turnLength; i < gameRecord.info.num_turns; i++) { turns.push({ turnNumber: i, intents: [], diff --git a/src/core/execution/NukeExecution.ts b/src/core/execution/NukeExecution.ts index a7ed24478..74e38ca35 100644 --- a/src/core/execution/NukeExecution.ts +++ b/src/core/execution/NukeExecution.ts @@ -1,4 +1,3 @@ -import { NukeType } from "../ArchiveSchemas"; import { consolex } from "../Consolex"; import { Execution, @@ -13,6 +12,7 @@ import { import { TileRef } from "../game/GameMap"; import { ParabolaPathFinder } from "../pathfinding/PathFinding"; import { PseudoRandom } from "../PseudoRandom"; +import { NukeType } from "../StatsSchemas"; export class NukeExecution implements Execution { private active = true; diff --git a/src/core/execution/SAMMissileExecution.ts b/src/core/execution/SAMMissileExecution.ts index e33bd9b61..fdfe635da 100644 --- a/src/core/execution/SAMMissileExecution.ts +++ b/src/core/execution/SAMMissileExecution.ts @@ -1,4 +1,3 @@ -import { NukeType } from "../ArchiveSchemas"; import { Execution, Game, @@ -10,6 +9,7 @@ import { import { TileRef } from "../game/GameMap"; import { AirPathFinder } from "../pathfinding/PathFinding"; import { PseudoRandom } from "../PseudoRandom"; +import { NukeType } from "../StatsSchemas"; export class SAMMissileExecution implements Execution { private active = true; diff --git a/src/core/game/Stats.ts b/src/core/game/Stats.ts index 8a6058027..3290efa21 100644 --- a/src/core/game/Stats.ts +++ b/src/core/game/Stats.ts @@ -1,5 +1,5 @@ -import { NukeType, OtherUnitType, PlayerStats } from "../ArchiveSchemas"; import { AllPlayersStats } from "../Schemas"; +import { NukeType, OtherUnitType, PlayerStats } from "../StatsSchemas"; import { Player, TerraNullius } from "./Game"; export interface Stats { diff --git a/src/core/game/StatsImpl.ts b/src/core/game/StatsImpl.ts index c2bc66a64..e81f1d74c 100644 --- a/src/core/game/StatsImpl.ts +++ b/src/core/game/StatsImpl.ts @@ -1,3 +1,4 @@ +import { AllPlayersStats } from "../Schemas"; import { ATTACK_INDEX_CANCEL, ATTACK_INDEX_RECV, @@ -23,8 +24,7 @@ import { PlayerStats, unitTypeToBombUnit, unitTypeToOtherUnit, -} from "../ArchiveSchemas"; -import { AllPlayersStats } from "../Schemas"; +} from "../StatsSchemas"; import { Player, TerraNullius } from "./Game"; import { Stats } from "./Stats"; diff --git a/src/server/Archive.ts b/src/server/Archive.ts index bee248213..f2fb29a0a 100644 --- a/src/server/Archive.ts +++ b/src/server/Archive.ts @@ -1,6 +1,6 @@ import { S3 } from "@aws-sdk/client-s3"; import { getServerConfigFromServer } from "../core/configuration/ConfigLoader"; -import { GameID, GameRecord } from "../core/Schemas"; +import { AnalyticsRecord, GameID, GameRecord } from "../core/Schemas"; import { logger } from "./Logger"; const config = getServerConfigFromServer(); @@ -30,12 +30,12 @@ export async function archive(gameRecord: GameRecord) { // Archive full game if there are turns if (gameRecord.turns.length > 0) { log.info( - `${gameRecord.id}: game has more than zero turns, attempting to write to full game to R2`, + `${gameRecord.info.gameID}: game has more than zero turns, attempting to write to full game to R2`, ); await archiveFullGameToR2(gameRecord); } } catch (error) { - log.error(`${gameRecord.id}: Final archive error: ${error}`, { + log.error(`${gameRecord.info.gameID}: Final archive error: ${error}`, { message: error?.message || error, stack: error?.stack, name: error?.name, @@ -46,29 +46,16 @@ export async function archive(gameRecord: GameRecord) { async function archiveAnalyticsToR2(gameRecord: GameRecord) { // Create analytics data object - const analyticsData = { - id: gameRecord.id, - env: config.env(), - start_time: new Date(gameRecord.startTimestampMS).toISOString(), - end_time: new Date(gameRecord.endTimestampMS).toISOString(), - duration_seconds: gameRecord.durationSeconds, - number_turns: gameRecord.num_turns, - game_mode: gameRecord.gameStartInfo.config.gameType, - winner: gameRecord.winner, - difficulty: gameRecord.gameStartInfo.config.difficulty, - mapType: gameRecord.gameStartInfo.config.gameMap, - players: gameRecord.players.map((p) => ({ - username: p.username, - ip: p.ip, - persistentID: p.persistentID, - clientID: p.clientID, - stats: p.stats, - })), + const { info, version, gitCommit } = gameRecord; + const analyticsData: AnalyticsRecord = { + info, + version, + gitCommit, }; try { // Store analytics data using just the game ID as the key - const analyticsKey = `${gameRecord.id}.json`; + const analyticsKey = `${info.gameID}.json`; await r2.putObject({ Bucket: bucket, @@ -77,17 +64,14 @@ async function archiveAnalyticsToR2(gameRecord: GameRecord) { ContentType: "application/json", }); - log.info(`${gameRecord.id}: successfully wrote game analytics to R2`); + log.info(`${info.gameID}: successfully wrote game analytics to R2`); } catch (error) { - log.error( - `${gameRecord.id}: Error writing game analytics to R2: ${error}`, - { - message: error?.message || error, - stack: error?.stack, - name: error?.name, - ...(error && typeof error === "object" ? error : {}), - }, - ); + log.error(`${info.gameID}: Error writing game analytics to R2: ${error}`, { + message: error?.message || error, + stack: error?.stack, + name: error?.name, + ...(error && typeof error === "object" ? error : {}), + }); throw error; } } @@ -110,11 +94,11 @@ async function archiveFullGameToR2(gameRecord: GameRecord) { ContentType: "application/json", }); } catch (error) { - log.error(`error saving game ${gameRecord.id}`); + log.error(`error saving game ${gameRecord.info.gameID}`); throw error; } - log.info(`${gameRecord.id}: game record successfully written to R2`); + log.info(`${gameRecord.info.gameID}: game record successfully written to R2`); } export async function readGameRecord( diff --git a/src/server/GameServer.ts b/src/server/GameServer.ts index ec9059468..d10a6e050 100644 --- a/src/server/GameServer.ts +++ b/src/server/GameServer.ts @@ -393,7 +393,7 @@ export class GameServer { ); } return { - ip: ipAnonymize(client.ip), + playerID: client.playerID, clientID: client.clientID, username: client.username, persistentID: client.persistentID, @@ -403,7 +403,7 @@ export class GameServer { archive( createGameRecord( this.id, - this.gameStartInfo, + this.gameStartInfo.config, playerRecords, this.turns, this._startTime ?? 0, diff --git a/src/server/Worker.ts b/src/server/Worker.ts index 19caf04ea..5ff087242 100644 --- a/src/server/Worker.ts +++ b/src/server/Worker.ts @@ -242,14 +242,12 @@ export function startWorker() { "/api/archive_singleplayer_game", gatekeeper.httpHandler(LimiterType.Post, async (req, res) => { const gameRecord: GameRecord = req.body; - const clientIP = req.ip || req.socket.remoteAddress || "unknown"; if (!gameRecord) { log.info("game record not found in request"); res.status(404).json({ error: "Game record not found" }); return; } - gameRecord.players.forEach((p) => (p.ip = clientIP)); archive(gameRecord); res.json({ success: true, From 85c03d659c6ac00583757ce3c9318cbcfd6dcb4a Mon Sep 17 00:00:00 2001 From: DevelopingTom Date: Thu, 22 May 2025 23:11:31 +0200 Subject: [PATCH 3/3] Add SAM interception FX (#830) ## Description: Add SAM interception animation: https://github.com/user-attachments/assets/5bfae4f2-f040-41cb-8fba-790538091807 Previously an intercepted nuke detonated with the regular nuke explosion, which was confusing. ## 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: IngloriousTom --- resources/sprites/samExplosion.png | Bin 0 -> 2334 bytes src/client/graphics/AnimatedSpriteLoader.ts | 10 ++++++ src/client/graphics/fx/Fx.ts | 1 + src/client/graphics/fx/SAMExplosionFx.ts | 34 ++++++++++++++++++ src/client/graphics/layers/FxLayer.ts | 37 +++++++++++++++----- src/core/execution/SAMLauncherExecution.ts | 5 ++- src/core/execution/SAMMissileExecution.ts | 1 + src/core/game/Game.ts | 2 ++ src/core/game/GameUpdates.ts | 1 + src/core/game/GameView.ts | 3 ++ src/core/game/UnitImpl.ts | 10 ++++++ 11 files changed, 94 insertions(+), 10 deletions(-) create mode 100644 resources/sprites/samExplosion.png create mode 100644 src/client/graphics/fx/SAMExplosionFx.ts diff --git a/resources/sprites/samExplosion.png b/resources/sprites/samExplosion.png new file mode 100644 index 0000000000000000000000000000000000000000..0e6cb854482dd76602f9a6604df9c9ac944e7aca GIT binary patch literal 2334 zcmV+(3E}pMP)Px-*hxe|RCt{2oxM&QO%#UDesYCGDw3WWatkE5f|9~1kfPiHmU1CT&K*RND1stN zsDN+_ftn&!AQh2ZS(>jP$2lI)&d&dj_dJrNWbK_fv+T~h@B9KV48t%C!!S(Wm`#Ra zHW`ZCaky}>w=3>^pR^F-O|M~3~{QUMS zsGIq?_6lOV7v${Xs_Bb}9qjFjn#-Chezu)Vb}q2gy%?-8@fP^5n2Z^OsMpa>7f zm)XjJF^tZ|(%W&D11Q-cs45j~x|5sma>q_9eAmh_R~dY-W3Img-Up?mSJcD9*kf9m zqj&-2b1oF>1m)OO52su~l~Q@iv9t+*>bsm}kmIjO075&D)B+GX|IngR6NB6VFn*vY z%>V=nfvRb4nmDd52II>HMNIc5T|!fj3L2}*oltbgOQ?8Cy~ZKYx#S$Qz0~-m6`NA- zS4vUAF!oTfT06O&3!07+9GLFG{ru(iLf?>FrVsCb06;korj676X5QDOqJ-uvUs9K$ zEA}p-YIDme%0XY$Vx%3j3@OCRC6DYC8RL%cFzlQ{CRZ%7Xz7?9Zo`N7KZ+Bu(|xmW zWKkErCMT*54Ys zJf%9A+TmgJmGI)_rzXY__*|2L5uah?I>$4c3`L5`&Nku&@K3mC>^f0NHSM8m*f#y{ z$!O_ffq6Q=tnUI09ZqZJ{5=}lnp3in>Tk#;qIdt)CfB;g181f)HpYAvC^mw#1DRMBrY+_XSG#^{{MNtWt+ypcjUp6>zFGN}7Youxl zH0A8#s*x*yRdd|g#Z_}WeSqWX1Nf|)sB%QE;)TfPnoO>Bab~HS_F_%a&f!K)^W?XT zzbuH>_jl?az42)r4YKbaBhE zsJ^oqA}yy8q6{j>ql!i4pnUQ}+S1d_{WDEJg!|H^ugXXDd(&%{x?d{v$jY4kY9@KJ z0|O#n-`_od-h5Sm@OxNpVvG&Cqc#Eh^Lq@+_e_OUGt%Q6Gv!^Yd;!t zJblPo(c(38jiIy=i>oeLl;8rU6ROfgEX06}l!H33Q=hWFF#&l5p-Eb)9dX-3)iyZ8 z1ze${Hrhra){IRVO5NIJ6`I*r{ay+xU?u`pl!%3JPzha_40TXAQq8srEvE|$AzY<6 zAyZdVa%I;?R-uAd(U<#-2XXWq-Tbi7#CWLafRNBlV-%CVEtAU7WG%m#%MFDdX5AKR zjFl{6hz(9!R`tV$-ZmsuS~jeLkm<_60@hQW9*>lRH~{{wVi6VG_G8 zhLYa)L&OqGR8m3b-o;)BH&)txhXt%MLXEO}@$yq6Pj1GvV@(2%0rq>9*8gSdcI1(i zwN!Sw;tcH`SHYpE2z8xGzaN7Nn90C^l7`cVh$Uxv&_Vh1E2eBhL`vzma)DNOZPNi% zdzzUHpHI#G&X$vd!gJczLrjJ1svf%YORun*MS?ppU4>qfc-2e@cO;8mO2-xQt(S^G z>bGfJ)6nO9nV4me)5hWNHvM<>cfP~tZID+LIoR73zWK{rtHfS-xJGfzy|8gt-o2m5 z4B+lyN?A^7ePd!B3!TUua`g>UYTi^4AWmhJb5AYP{B1Xs3{8y5#bUpCb6VCvrByDIz= zHX*dR#-%J%l|gR0niF$38>;%MsEC>LW#PJc)5GoJ z1*`^KEA`xK0LqwD9wi7a#bqUVli#JnHGb6#QVHd8VHL_y9O8N;=AV*Q4^e>gwp@1a z&AtEEY%+9;JJk8U_;&v1o=a8ZzyeC`@dMaRFpn`Ay=GYLR)pBQD97UI@ksc%K;HrZk(7T5$EawE-D zf5-csEdd@oGAq-Aau?=h;^DHNTC7+YhDl70!0o8=gW zVR}po0ShU-lvI67ER+nxFiaF;31li*AQ^^X7^ZOk2bB{H@3%-s!T> = { originX: 30, originY: 30, }, + [FxType.SAMExplosion]: { + url: SAMExplosion, + frameWidth: 48, + frameCount: 9, + frameDuration: 70, + looping: false, + originX: 23, + originY: 19, + }, }; const animatedSpriteImageMap: Map = new Map(); diff --git a/src/client/graphics/fx/Fx.ts b/src/client/graphics/fx/Fx.ts index 8956c4ca4..a80bae2af 100644 --- a/src/client/graphics/fx/Fx.ts +++ b/src/client/graphics/fx/Fx.ts @@ -4,4 +4,5 @@ export interface Fx { export enum FxType { Nuke = "Nuke", + SAMExplosion = "SAMExplosion", } diff --git a/src/client/graphics/fx/SAMExplosionFx.ts b/src/client/graphics/fx/SAMExplosionFx.ts new file mode 100644 index 000000000..3be5c3a79 --- /dev/null +++ b/src/client/graphics/fx/SAMExplosionFx.ts @@ -0,0 +1,34 @@ +import { AnimatedSprite } from "../AnimatedSprite"; +import { createAnimatedSpriteForUnit } from "../AnimatedSpriteLoader"; +import { Fx, FxType } from "./Fx"; + +/** + * Explosion effect: sprite animation of an explosion + */ +export class SAMExplosionFx implements Fx { + private lifeTime: number = 0; + private explosionSprite: AnimatedSprite | null; + constructor( + private x: number, + private y: number, + private duration: number, + ) { + this.explosionSprite = createAnimatedSpriteForUnit(FxType.SAMExplosion); + } + + renderTick(frameTime: number, ctx: CanvasRenderingContext2D): boolean { + if (this.explosionSprite) { + this.lifeTime += frameTime; + if (this.lifeTime >= this.duration) { + return false; + } + if (this.explosionSprite.isActive()) { + this.explosionSprite.update(frameTime); + this.explosionSprite.draw(ctx, this.x, this.y); + return true; + } + return false; + } + return false; + } +} diff --git a/src/client/graphics/layers/FxLayer.ts b/src/client/graphics/layers/FxLayer.ts index f19147b65..34b46b458 100644 --- a/src/client/graphics/layers/FxLayer.ts +++ b/src/client/graphics/layers/FxLayer.ts @@ -4,6 +4,7 @@ import { GameView, UnitView } from "../../../core/game/GameView"; import { loadAllAnimatedSpriteImages } from "../AnimatedSpriteLoader"; import { Fx } from "../fx/Fx"; import { NukeExplosionFx, ShockwaveFx } from "../fx/NukeFx"; +import { SAMExplosionFx } from "../fx/SAMExplosionFx"; import { Layer } from "./Layer"; export class FxLayer implements Layer { @@ -35,25 +36,43 @@ export class FxLayer implements Layer { switch (unit.type()) { case UnitType.AtomBomb: case UnitType.MIRVWarhead: - this.handleNukeExplosion(unit, 70); + this.handleNukes(unit, 70); break; case UnitType.HydrogenBomb: - this.handleNukeExplosion(unit, 250); + this.handleNukes(unit, 250); break; } } - handleNukeExplosion(unit: UnitView, shockwaveRadius: number) { + handleNukes(unit: UnitView, shockwaveRadius: number) { if (!unit.isActive()) { - const x = this.game.x(unit.lastTile()); - const y = this.game.y(unit.lastTile()); - const nuke = new NukeExplosionFx(x, y, 1000); - this.allFx.push(nuke as Fx); - const shockwave = new ShockwaveFx(x, y, 1500, shockwaveRadius); - this.allFx.push(shockwave as Fx); + if (unit.wasInterceptedBySAM()) { + this.handleSAMInterception(unit); + } else { + // Kaboom + this.handleNukeExplosion(unit, shockwaveRadius); + } } } + handleNukeExplosion(unit: UnitView, shockwaveRadius: number) { + const x = this.game.x(unit.lastTile()); + const y = this.game.y(unit.lastTile()); + const nuke = new NukeExplosionFx(x, y, 1000); + this.allFx.push(nuke as Fx); + const shockwave = new ShockwaveFx(x, y, 1500, shockwaveRadius); + this.allFx.push(shockwave as Fx); + } + + handleSAMInterception(unit: UnitView) { + const x = this.game.x(unit.lastTile()); + const y = this.game.y(unit.lastTile()); + const interception = new SAMExplosionFx(x, y, 1000); + this.allFx.push(interception as Fx); + const shockwave = new ShockwaveFx(x, y, 800, 40); + this.allFx.push(shockwave as Fx); + } + async init() { this.redraw(); try { diff --git a/src/core/execution/SAMLauncherExecution.ts b/src/core/execution/SAMLauncherExecution.ts index 2439f4462..9910a6aa3 100644 --- a/src/core/execution/SAMLauncherExecution.ts +++ b/src/core/execution/SAMLauncherExecution.ts @@ -180,7 +180,10 @@ export class SAMLauncherExecution implements Execution { this.sam.owner().id(), ); // Delete warheads - mirvWarheadTargets.forEach((u) => u.delete()); + mirvWarheadTargets.forEach((u) => { + u.setInterceptedBySam(); + u.delete(); + }); } else if (target !== null) { target.setTargetedBySAM(true); this.mg.addExecution( diff --git a/src/core/execution/SAMMissileExecution.ts b/src/core/execution/SAMMissileExecution.ts index fdfe635da..9e1ad5b0d 100644 --- a/src/core/execution/SAMMissileExecution.ts +++ b/src/core/execution/SAMMissileExecution.ts @@ -66,6 +66,7 @@ export class SAMMissileExecution implements Execution { this._owner.id(), ); this.active = false; + this.target.setInterceptedBySam(); this.target.delete(true, this._owner); this.SAMMissile.delete(false); diff --git a/src/core/game/Game.ts b/src/core/game/Game.ts index 39956ac14..31f581586 100644 --- a/src/core/game/Game.ts +++ b/src/core/game/Game.ts @@ -345,6 +345,8 @@ export interface Unit { targetUnit(): Unit | undefined; setTargetedBySAM(targeted: boolean): void; targetedBySAM(): boolean; + setInterceptedBySam(): void; + interceptedBySam(): boolean; // Health hasHealth(): boolean; diff --git a/src/core/game/GameUpdates.ts b/src/core/game/GameUpdates.ts index 8c3df8906..839ca330c 100644 --- a/src/core/game/GameUpdates.ts +++ b/src/core/game/GameUpdates.ts @@ -73,6 +73,7 @@ export interface UnitUpdate { pos: TileRef; lastPos: TileRef; isActive: boolean; + wasIntercepted: boolean; retreating: boolean; targetUnitId?: number; // Only for trade ships targetTile?: TileRef; // Only for nukes diff --git a/src/core/game/GameView.ts b/src/core/game/GameView.ts index 060142dfe..5ce1266f5 100644 --- a/src/core/game/GameView.ts +++ b/src/core/game/GameView.ts @@ -93,6 +93,9 @@ export class UnitView { isActive(): boolean { return this.data.isActive; } + wasInterceptedBySAM(): boolean { + return this.data.wasIntercepted; + } hasHealth(): boolean { return this.data.health !== undefined; } diff --git a/src/core/game/UnitImpl.ts b/src/core/game/UnitImpl.ts index 44564880c..a68f566d7 100644 --- a/src/core/game/UnitImpl.ts +++ b/src/core/game/UnitImpl.ts @@ -21,6 +21,7 @@ export class UnitImpl implements Unit { private _lastTile: TileRef; private _retreating: boolean = false; private _targetedBySAM = false; + private _interceptedBySAM = false; private _lastSetSafeFromPirates: number; // Only for trade ships private _constructionType: UnitType | undefined; private _lastOwner: PlayerImpl | null = null; @@ -85,6 +86,7 @@ export class UnitImpl implements Unit { ownerID: this._owner.smallID(), lastOwnerID: this._lastOwner?.smallID(), isActive: this._active, + wasIntercepted: this._interceptedBySAM, retreating: this._retreating, pos: this._tile, lastPos: this._lastTile, @@ -304,6 +306,14 @@ export class UnitImpl implements Unit { return this._targetedBySAM; } + setInterceptedBySam(): void { + this._interceptedBySAM = true; + } + + interceptedBySam(): boolean { + return this._interceptedBySAM; + } + setSafeFromPirates(): void { this._lastSetSafeFromPirates = this.mg.ticks(); }