Merge branch 'main' into patterned-territory

This commit is contained in:
Aotumuri
2025-05-29 06:22:10 +09:00
committed by GitHub
119 changed files with 4787 additions and 1589 deletions
+29 -27
View File
@@ -1,3 +1,4 @@
import { translateText } from "../client/Utils";
import { consolex, initRemoteSender } from "../core/Consolex";
import { EventBus } from "../core/EventBus";
import {
@@ -7,11 +8,12 @@ import {
GameStartInfo,
PlayerRecord,
ServerMessage,
Winner,
} from "../core/Schemas";
import { createGameRecord } from "../core/Util";
import { ServerConfig } from "../core/configuration/Config";
import { getConfig } from "../core/configuration/ConfigLoader";
import { Cell, Team, UnitType } from "../core/game/Game";
import { Cell, UnitType } from "../core/game/Game";
import { TileRef } from "../core/game/GameMap";
import {
ErrorUpdate,
@@ -26,7 +28,7 @@ import { UserSettings } from "../core/game/UserSettings";
import { WorkerClient } from "../core/worker/WorkerClient";
import { InputHandler, MouseMoveEvent, MouseUpEvent } from "./InputHandler";
import { endGame, startGame, startTime } from "./LocalPersistantStats";
import { getPersistentIDFromCookie } from "./Main";
import { getPersistentID } from "./Main";
import {
SendAttackIntentEvent,
SendBoatAttackIntentEvent,
@@ -187,25 +189,26 @@ export class ClientGameRunner {
this.lastMessageTime = Date.now();
}
private getWinner(update: WinUpdate): Winner {
if (update.winner[0] !== "player") return update.winner;
const clientId = this.gameView.playerBySmallID(update.winner[1]).clientID();
if (clientId === null) return;
return ["player", clientId];
}
private saveGame(update: WinUpdate) {
if (this.myPlayer === null) throw new Error("Not initialized");
if (this.myPlayer === null) {
return;
}
const players: PlayerRecord[] = [
{
playerID: this.myPlayer.id(),
persistentID: getPersistentIDFromCookie(),
persistentID: getPersistentID(),
username: this.lobby.playerName,
clientID: this.lobby.clientID,
stats: update.allPlayersStats[this.lobby.clientID],
},
];
let winner: ClientID | Team | null = null;
if (update.winnerType === "player") {
winner = this.gameView
.playerBySmallID(update.winner as number)
.clientID();
} else {
winner = update.winner as Team;
}
const winner = this.getWinner(update);
if (this.lobby.gameStartInfo === undefined) {
throw new Error("missing gameStartInfo");
@@ -219,7 +222,6 @@ export class ClientGameRunner {
startTime(),
Date.now(),
winner,
update.winnerType,
);
endGame(record);
}
@@ -307,7 +309,7 @@ export class ClientGameRunner {
this.lobby.gameStartInfo.gameID,
this.lobby.clientID,
true,
"You are desynced from other players. What you see might differ from other players.",
translateText("error_modal.desync_notice"),
);
}
if (message.type === "turn") {
@@ -496,7 +498,7 @@ function showErrorModal(
gameID: GameID,
clientID: ClientID,
closable = false,
heading = "Game crashed!",
heading = translateText("error_modal.crashed"),
) {
const errorText = `Error: ${errMsg}\nStack: ${stack}`;
@@ -505,37 +507,37 @@ function showErrorModal(
}
const modal = document.createElement("div");
const content = `${heading}\n game id: ${gameID}, client id: ${clientID}\nPlease paste the following in your bug report in Discord:\n${errorText}`;
modal.id = "error-modal";
const content = `${translateText(heading)}\n game id: ${gameID}, client id: ${clientID}\n${translateText("error_modal.paste_discord")}\n${errorText}`;
// Create elements
const pre = document.createElement("pre");
pre.textContent = content;
const button = document.createElement("button");
button.textContent = "Copy to clipboard";
button.style.cssText =
"padding: 8px 16px; margin-top: 10px; background: #4CAF50; color: white; border: none; border-radius: 4px; cursor: pointer;";
button.textContent = translateText("error_modal.copy_clipboard");
button.className = "copy-btn";
button.addEventListener("click", () => {
navigator.clipboard
.writeText(content)
.then(() => (button.textContent = "Copied!"))
.catch(() => (button.textContent = "Failed to copy"));
.then(() => (button.textContent = translateText("error_modal.copied")))
.catch(
() => (button.textContent = translateText("error_modal.failed_copy")),
);
});
const closeButton = document.createElement("button");
closeButton.textContent = "X";
closeButton.style.cssText =
"color: white;top: 0px;right: 0px;cursor: pointer;background: red;margin-right: 0px;position: fixed;width: 40px;";
closeButton.className = "close-btn";
closeButton.addEventListener("click", () => {
modal.style.display = "none";
});
// Add to modal
modal.style.cssText =
"position:fixed; padding:20px; background:white; border:1px solid black; top:50%; left:50%; transform:translate(-50%,-50%); z-index:9999;";
modal.appendChild(pre);
modal.appendChild(button);
modal.id = "error-modal";
if (closable) {
modal.appendChild(closeButton);
}
+4
View File
@@ -5,11 +5,13 @@ import "./LanguageModal";
import ar from "../../resources/lang/ar.json";
import bg from "../../resources/lang/bg.json";
import bn from "../../resources/lang/bn.json";
import cs from "../../resources/lang/cs.json";
import de from "../../resources/lang/de.json";
import en from "../../resources/lang/en.json";
import eo from "../../resources/lang/eo.json";
import es from "../../resources/lang/es.json";
import fr from "../../resources/lang/fr.json";
import he from "../../resources/lang/he.json";
import hi from "../../resources/lang/hi.json";
import it from "../../resources/lang/it.json";
import ja from "../../resources/lang/ja.json";
@@ -53,6 +55,8 @@ export class LangSelector extends LitElement {
tr,
tp,
uk,
cs,
he,
};
createRenderRoot() {
+2 -1
View File
@@ -1,5 +1,6 @@
import { consolex } from "../core/Consolex";
import { GameConfig, GameID, GameRecord } from "../core/Schemas";
import { replacer } from "../core/Util";
export interface LocalStatsData {
[key: GameID]: {
@@ -19,7 +20,7 @@ function getStats(): LocalStatsData {
function save(stats: LocalStatsData) {
// To execute asynchronously
setTimeout(
() => localStorage.setItem("game-records", JSON.stringify(stats)),
() => localStorage.setItem("game-records", JSON.stringify(stats, replacer)),
0,
);
}
+10 -9
View File
@@ -11,9 +11,9 @@ import {
ServerStartGameMessageSchema,
Turn,
} from "../core/Schemas";
import { createGameRecord, decompressGameRecord } from "../core/Util";
import { createGameRecord, decompressGameRecord, replacer } from "../core/Util";
import { LobbyConfig } from "./ClientGameRunner";
import { getPersistentIDFromCookie } from "./Main";
import { getPersistentID } from "./Main";
export class LocalServer {
// All turns from the game record on replay.
@@ -176,8 +176,7 @@ export class LocalServer {
}
const players: PlayerRecord[] = [
{
playerID: this.lobbyConfig.clientID, // hack?
persistentID: getPersistentIDFromCookie(),
persistentID: getPersistentID(),
username: this.lobbyConfig.playerName,
clientID: this.lobbyConfig.clientID,
stats: this.allPlayersStats[this.lobbyConfig.clientID],
@@ -193,17 +192,19 @@ export class LocalServer {
this.turns,
this.startedAt,
Date.now(),
this.winner?.winner ?? null,
this.winner?.winnerType ?? null,
this.winner?.winner,
);
if (!saveFullGame) {
// Clear turns because beacon only supports up to 64kb
record.turns = [];
}
// For unload events, sendBeacon is the only reliable method
const blob = new Blob([JSON.stringify(GameRecordSchema.parse(record))], {
type: "application/json",
});
const blob = new Blob(
[JSON.stringify(GameRecordSchema.parse(record), replacer)],
{
type: "application/json",
},
);
const workerPath = this.lobbyConfig.serverConfig.workerPath(
this.lobbyConfig.gameStartInfo.gameID,
);
+20 -10
View File
@@ -172,17 +172,16 @@ class Client {
TerritoryModal.open();
});
const claims = isLoggedIn();
if (claims === false) {
if (isLoggedIn() === false) {
// Not logged in
loginDiscordButton.disable = false;
loginDiscordButton.translationKey = "main.login_discord";
loginDiscordButton.addEventListener("click", discordLogin);
logoutDiscordButton.hidden = true;
} else {
// JWT appears to be valid, assume we are logged in
// JWT appears to be valid
loginDiscordButton.disable = true;
loginDiscordButton.translationKey = "main.logged_in";
loginDiscordButton.translationKey = "main.checking_login";
logoutDiscordButton.hidden = false;
logoutDiscordButton.addEventListener("click", () => {
// Log out
@@ -204,6 +203,8 @@ class Client {
return;
}
// TODO: Update the page for logged in user
loginDiscordButton.translationKey = "main.logged_in";
const { user, player } = userMeResponse;
});
}
@@ -303,7 +304,7 @@ class Client {
? ""
: this.flagInput.getCurrentFlag(),
playerName: this.usernameInput?.getCurrentUsername() ?? "",
token: localStorage.getItem("token") ?? getPersistentIDFromCookie(),
token: getPlayToken(),
clientID: lobby.clientID,
gameStartInfo: lobby.gameStartInfo ?? lobby.gameRecord?.info,
gameRecord: lobby.gameRecord,
@@ -382,12 +383,21 @@ function setFavicon(): void {
}
// WARNING: DO NOT EXPOSE THIS ID
export function getPersistentIDFromCookie(): string {
const claims = isLoggedIn();
if (claims !== false && claims.sub) {
return claims.sub;
}
function getPlayToken(): string {
const result = isLoggedIn();
if (result !== false) return result.token;
return getPersistentIDFromCookie();
}
// WARNING: DO NOT EXPOSE THIS ID
export function getPersistentID(): string {
const result = isLoggedIn();
if (result !== false) return result.claims.sub;
return getPersistentIDFromCookie();
}
// WARNING: DO NOT EXPOSE THIS ID
function getPersistentIDFromCookie(): string {
const COOKIE_NAME = "player_persistent_id";
// Try to get existing cookie
+34 -31
View File
@@ -100,47 +100,50 @@ export class PublicLobby extends LitElement {
<button
@click=${() => this.lobbyClicked(lobby)}
?disabled=${this.isButtonDebounced}
class="w-full mx-auto p-4 md:p-6 ${this.isLobbyHighlighted
class="isolate grid h-40 grid-cols-[100%] grid-rows-[100%] place-content-stretch w-full overflow-hidden ${this
.isLobbyHighlighted
? "bg-gradient-to-r from-green-600 to-green-500"
: "bg-gradient-to-r from-blue-600 to-blue-500"} text-white font-medium rounded-xl transition-opacity duration-200 hover:opacity-90 ${this
.isButtonDebounced
? "opacity-70 cursor-not-allowed"
: ""}"
>
<div class="text-lg md:text-2xl font-semibold mb-2">
${translateText("public_lobby.join")}
</div>
<div class="flex">
<img
src="${getMapsImage(lobby.gameConfig.gameMap)}"
alt="${lobby.gameConfig.gameMap}"
class="w-1/3 md:w-1/5 md:h-[80px]"
style="border: 1px solid rgba(255, 255, 255, 0.5)"
/>
<div
class="w-full flex flex-col md:flex-row items-center justify-center md:justify-evenly"
>
<div class="flex flex-col items-center">
<div class="text-md font-medium text-blue-100 mb-4">
<!-- ${lobby.gameConfig.gameMap} -->
${translateText(
`map.${lobby.gameConfig.gameMap.toLowerCase().replace(/\s+/g, "")}`,
)}
</div>
<div class="text-md font-medium text-blue-100">
<img
src="${getMapsImage(lobby.gameConfig.gameMap)}"
alt="${lobby.gameConfig.gameMap}"
class="place-self-start col-span-full row-span-full h-full -z-10"
style="mask-image: linear-gradient(to left, transparent, #fff)"
/>
<div
class="flex flex-col justify-between h-full col-span-full row-span-full p-4 md:p-6 text-right z-0"
>
<div>
<div class="text-lg md:text-2xl font-semibold">
${translateText("public_lobby.join")}
</div>
<div class="text-md font-medium text-blue-100">
<span
class="text-sm ${this.isLobbyHighlighted
? "text-green-600"
: "text-blue-600"} bg-white rounded-sm px-1"
>
${lobby.gameConfig.gameMode === GameMode.Team
? translateText("public_lobby.teams", { num: teamCount ?? 0 })
: translateText("game_mode.ffa")}
</div>
: translateText("game_mode.ffa")}</span
>
<span
>${translateText(
`map.${lobby.gameConfig.gameMap.toLowerCase().replace(/\s+/g, "")}`,
)}</span
>
</div>
<div class="flex flex-col items-center">
<div class="text-md font-medium text-blue-100 mb-4">
${lobby.numClients} / ${lobby.gameConfig.maxPlayers}
</div>
<div class="text-md font-medium text-blue-100">
${timeDisplay}
</div>
</div>
<div>
<div class="text-md font-medium text-blue-100">
${lobby.numClients} / ${lobby.gameConfig.maxPlayers}
</div>
<div class="text-md font-medium text-blue-100">${timeDisplay}</div>
</div>
</div>
</button>
-1
View File
@@ -435,7 +435,6 @@ export class SinglePlayerModal extends LitElement {
gameID: gameID,
players: [
{
playerID: generateID(),
clientID,
username: usernameInput.getCurrentUsername(),
flag:
+4 -6
View File
@@ -6,7 +6,6 @@ import {
GameType,
PlayerID,
PlayerType,
Team,
Tick,
UnitType,
} from "../core/game/Game";
@@ -14,7 +13,6 @@ import { PlayerView } from "../core/game/GameView";
import {
AllPlayersStats,
ClientHashMessage,
ClientID,
ClientIntentMessage,
ClientJoinMessage,
ClientLogMessage,
@@ -23,7 +21,9 @@ import {
Intent,
ServerMessage,
ServerMessageSchema,
Winner,
} from "../core/Schemas";
import { replacer } from "../core/Util";
import { LobbyConfig } from "./ClientGameRunner";
import { LocalServer } from "./LocalServer";
@@ -142,9 +142,8 @@ export class SendSetTargetTroopRatioEvent implements GameEvent {
export class SendWinnerEvent implements GameEvent {
constructor(
public readonly winner: ClientID | Team,
public readonly winner: Winner,
public readonly allPlayersStats: AllPlayersStats,
public readonly winnerType: "player" | "team",
) {}
}
export class SendHashEvent implements GameEvent {
@@ -539,9 +538,8 @@ export class Transport {
type: "winner",
winner: event.winner,
allPlayersStats: event.allPlayersStats,
winnerType: event.winnerType,
} satisfies ClientSendWinnerMessage;
this.sendMsg(JSON.stringify(msg));
this.sendMsg(JSON.stringify(msg, replacer));
} else {
console.log(
"WebSocket is not open. Current state:",
+1 -1
View File
@@ -47,7 +47,7 @@ export class UsernameInput extends LitElement {
/>
${this.validationError
? html`<div
class="mt-2 px-3 py-1 text-lg border rounded bg-white text-red-600 border-red-600 dark:bg-gray-700 dark:text-red-300 dark:border-red-300"
class="absolute w-full mt-2 px-3 py-1 text-lg border rounded bg-white text-red-600 border-red-600 dark:bg-gray-700 dark:text-red-300 dark:border-red-300"
>
${this.validationError}
</div>`
+1 -1
View File
@@ -6,6 +6,7 @@ import { getMapsImage } from "../utilities/Maps";
// Add map descriptions
export const MapDescription: Record<keyof typeof GameMapType, string> = {
World: "World",
WorldMapGiant: "Giant World Map",
Europe: "Europe",
EuropeClassic: "Europe Classic",
Mena: "MENA",
@@ -23,7 +24,6 @@ export const MapDescription: Record<keyof typeof GameMapType, string> = {
Iceland: "Iceland",
Japan: "Japan",
BetweenTwoSeas: "Between Two Seas",
KnownWorld: "Known World",
FaroeIslands: "Faroe Islands",
DeglaciatedAntarctica: "Deglaciated Antarctica",
FalklandIslands: "Falkland Islands",
+11 -36
View File
@@ -1,4 +1,4 @@
import { LitElement, css, html } from "lit";
import { LitElement, html } from "lit";
import { customElement, property } from "lit/decorators.js";
import megaphone from "../../../resources/images/Megaphone.svg";
import { NewsModal } from "../NewsModal";
@@ -9,38 +9,6 @@ export class NewsButton extends LitElement {
@property({ type: Boolean })
hidden = false;
static styles = css`
.news-button {
opacity: 0.75;
transition: opacity 0.2s ease;
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
padding: 0;
margin: 0;
border: none;
background: none;
cursor: pointer;
}
.news-button:hover {
opacity: 1;
}
.news-button img {
width: 24px;
height: 24px;
display: block;
margin-left: 12px;
}
.hidden {
display: none !important;
}
`;
private handleClick() {
const newsModal = document.querySelector("news-modal") as NewsModal;
if (newsModal) {
@@ -50,9 +18,16 @@ export class NewsButton extends LitElement {
render() {
return html`
<div class="text-center mb-0.5 ${this.hidden ? "hidden" : ""}">
<button class="news-button" @click=${this.handleClick}>
<img src="${megaphone}" alt=${translateText("news.title")} />
<div class="flex relative ${this.hidden ? "parent-hidden" : ""}">
<button
class="border p-[4px] rounded-lg flex cursor-pointer border-black/30 dark:border-gray-300/60 bg-white/70 dark:bg-[rgba(55,65,81,0.7)]"
@click=${this.handleClick}
>
<img
class="size-[48px]"
src="${megaphone}"
alt=${translateText("news.title")}
/>
</button>
</div>
`;
+2 -2
View File
@@ -24,7 +24,7 @@
"name": "Afghanistan"
},
{
"code": "african union",
"code": "African union",
"continent": "Africa",
"name": "African Union"
},
@@ -300,7 +300,7 @@
"name": "British Indian Ocean Territory"
},
{
"code": "Brittany",
"code": "brittany",
"continent": "Europe",
"name": "Brittany"
},
+8 -1
View File
@@ -9,7 +9,7 @@ export class AnimatedSprite {
private frameWidth: number,
private frameCount: number,
private frameDuration: number, // in milliseconds
private looping: boolean = true,
private looping: boolean = false,
private originX: number,
private originY: number,
) {
@@ -42,6 +42,13 @@ export class AnimatedSprite {
return this.active;
}
lifeTime(): number | undefined {
if (this.looping) {
return undefined;
}
return this.frameDuration * this.frameCount;
}
draw(ctx: CanvasRenderingContext2D, x: number, y: number) {
const drawX = x - this.originX;
const drawY = y - this.originY;
+177 -42
View File
@@ -1,7 +1,17 @@
import miniBigSmoke from "../../../resources/sprites/bigsmoke.png";
import miniExplosion from "../../../resources/sprites/miniExplosion.png";
import miniFire from "../../../resources/sprites/minifire.png";
import nuke from "../../../resources/sprites/nukeExplosion.png";
import SAMExplosion from "../../../resources/sprites/samExplosion.png";
import sinkingShip from "../../../resources/sprites/sinkingShip.png";
import miniSmoke from "../../../resources/sprites/smoke.png";
import miniSmokeAndFire from "../../../resources/sprites/smokeAndFire.png";
import unitExplosion from "../../../resources/sprites/unitExplosion.png";
import { Theme } from "../../core/configuration/Config";
import { PlayerView } from "../../core/game/GameView";
import { AnimatedSprite } from "./AnimatedSprite";
import { FxType } from "./fx/Fx";
import { colorizeCanvas } from "./SpriteLoader";
type AnimatedSpriteConfig = {
url: string;
@@ -14,6 +24,69 @@ type AnimatedSpriteConfig = {
};
const ANIMATED_SPRITE_CONFIG: Partial<Record<FxType, AnimatedSpriteConfig>> = {
[FxType.MiniFire]: {
url: miniFire,
frameWidth: 7,
frameCount: 6,
frameDuration: 100,
looping: true,
originX: 3,
originY: 11,
},
[FxType.MiniSmoke]: {
url: miniSmoke,
frameWidth: 11,
frameCount: 4,
frameDuration: 120,
looping: true,
originX: 2,
originY: 10,
},
[FxType.MiniBigSmoke]: {
url: miniBigSmoke,
frameWidth: 24,
frameCount: 5,
frameDuration: 120,
looping: true,
originX: 9,
originY: 14,
},
[FxType.MiniSmokeAndFire]: {
url: miniSmokeAndFire,
frameWidth: 24,
frameCount: 5,
frameDuration: 120,
looping: true,
originX: 9,
originY: 14,
},
[FxType.MiniExplosion]: {
url: miniExplosion,
frameWidth: 13,
frameCount: 4,
frameDuration: 70,
looping: false,
originX: 6,
originY: 6,
},
[FxType.UnitExplosion]: {
url: unitExplosion,
frameWidth: 19,
frameCount: 4,
frameDuration: 70,
looping: false,
originX: 9,
originY: 9,
},
[FxType.SinkingShip]: {
url: sinkingShip,
frameWidth: 16,
frameCount: 14,
frameDuration: 90,
looping: false,
originX: 7,
originY: 7,
},
[FxType.Nuke]: {
url: nuke,
frameWidth: 60,
@@ -34,53 +107,115 @@ const ANIMATED_SPRITE_CONFIG: Partial<Record<FxType, AnimatedSpriteConfig>> = {
},
};
const animatedSpriteImageMap: Map<FxType, CanvasImageSource> = new Map();
export class AnimatedSpriteLoader {
private animatedSpriteImageMap: Map<FxType, HTMLCanvasElement> = new Map();
// Do not color the same sprite twice
private coloredAnimatedSpriteCache: Map<string, HTMLCanvasElement> =
new Map();
export const loadAllAnimatedSpriteImages = async (): Promise<void> => {
const entries = Object.entries(ANIMATED_SPRITE_CONFIG);
public async loadAllAnimatedSpriteImages(): Promise<void> {
const entries = Object.entries(ANIMATED_SPRITE_CONFIG);
await Promise.all(
entries.map(async ([fxType, config]) => {
const typedFxType = fxType as FxType;
if (!config?.url) return;
await Promise.all(
entries.map(async ([fxType, config]) => {
const typedFxType = fxType as FxType;
if (!config?.url) return;
try {
const img = new Image();
img.crossOrigin = "anonymous";
img.src = config.url;
try {
const img = new Image();
img.crossOrigin = "anonymous";
img.src = config.url;
await new Promise<void>((resolve, reject) => {
img.onload = () => resolve();
img.onerror = (e) => reject(e);
});
await new Promise<void>((resolve, reject) => {
img.onload = () => resolve();
img.onerror = (e) => reject(e);
});
const canvas = document.createElement("canvas");
canvas.width = img.width;
canvas.height = img.height;
canvas.getContext("2d")!.drawImage(img, 0, 0);
const canvas = document.createElement("canvas");
canvas.width = img.width;
canvas.height = img.height;
canvas.getContext("2d")!.drawImage(img, 0, 0);
animatedSpriteImageMap.set(typedFxType, canvas);
} catch (err) {
console.error(`Failed to load sprite for ${typedFxType}:`, err);
}
}),
);
};
this.animatedSpriteImageMap.set(typedFxType, canvas);
} catch (err) {
console.error(`Failed to load sprite for ${typedFxType}:`, err);
}
}),
);
}
export const createAnimatedSpriteForUnit = (
fxType: FxType,
): AnimatedSprite | null => {
const config = ANIMATED_SPRITE_CONFIG[fxType];
const image = animatedSpriteImageMap.get(fxType);
if (!config || !image) return null;
private createRegularAnimatedSprite(fxType: FxType): AnimatedSprite | null {
const config = ANIMATED_SPRITE_CONFIG[fxType];
const image = this.animatedSpriteImageMap.get(fxType);
if (!config || !image) return null;
return new AnimatedSprite(
image,
config.frameWidth,
config.frameCount,
config.frameDuration,
config.looping ?? true,
config.originX,
config.originY,
);
};
return new AnimatedSprite(
image,
config.frameWidth,
config.frameCount,
config.frameDuration,
config.looping ?? true,
config.originX,
config.originY,
);
}
private getColoredAnimatedSprite(
owner: PlayerView,
fxType: FxType,
theme: Theme,
): HTMLCanvasElement | null {
const baseImage = this.animatedSpriteImageMap.get(fxType);
const config = ANIMATED_SPRITE_CONFIG[fxType];
if (!baseImage || !config) return null;
const territoryColor = theme.territoryColor(owner);
const borderColor = theme.borderColor(owner);
const spawnHighlightColor = theme.spawnHighlightColor();
const key = `${fxType}-${owner.id()}`;
let coloredCanvas: HTMLCanvasElement;
if (this.coloredAnimatedSpriteCache.has(key)) {
coloredCanvas = this.coloredAnimatedSpriteCache.get(key)!;
} else {
coloredCanvas = colorizeCanvas(
baseImage,
territoryColor,
borderColor,
spawnHighlightColor,
);
this.coloredAnimatedSpriteCache.set(key, coloredCanvas);
}
return coloredCanvas;
}
private createColoredAnimatedSpriteForUnit(
fxType: FxType,
owner: PlayerView,
theme: Theme,
): AnimatedSprite | null {
const config = ANIMATED_SPRITE_CONFIG[fxType];
const image = this.getColoredAnimatedSprite(owner, fxType, theme);
if (!config || !image) return null;
return new AnimatedSprite(
image,
config.frameWidth,
config.frameCount,
config.frameDuration,
config.looping ?? true,
config.originX,
config.originY,
);
}
public createAnimatedSprite(
fxType: FxType,
owner?: PlayerView,
theme?: Theme,
): AnimatedSprite | null {
if (owner && theme) {
return this.createColoredAnimatedSpriteForUnit(fxType, owner, theme);
}
return this.createRegularAnimatedSprite(fxType);
}
}
+19 -1
View File
@@ -29,6 +29,7 @@ import { TerrainLayer } from "./layers/TerrainLayer";
import { TerritoryLayer } from "./layers/TerritoryLayer";
import { TopBar } from "./layers/TopBar";
import { UILayer } from "./layers/UILayer";
import { UnitInfoModal } from "./layers/UnitInfoModal";
import { UnitLayer } from "./layers/UnitLayer";
import { WinModal } from "./layers/WinModal";
@@ -171,10 +172,26 @@ export function createRenderer(
}
playerTeamLabel.game = game;
const unitInfoModal = document.querySelector(
"unit-info-modal",
) as UnitInfoModal;
if (!(unitInfoModal instanceof UnitInfoModal)) {
console.error("unit info modal not found");
}
unitInfoModal.game = game;
const structureLayer = new StructureLayer(
game,
eventBus,
transformHandler,
unitInfoModal,
);
unitInfoModal.structureLayer = structureLayer;
// unitInfoModal.eventBus = eventBus;
const layers: Layer[] = [
new TerrainLayer(game, transformHandler),
new TerritoryLayer(game, eventBus),
new StructureLayer(game, eventBus),
structureLayer,
new UnitLayer(game, eventBus, clientID, transformHandler),
new FxLayer(game),
new UILayer(game, eventBus, clientID, transformHandler),
@@ -203,6 +220,7 @@ export function createRenderer(
topBar,
playerPanel,
playerTeamLabel,
unitInfoModal,
multiTabModal,
];
+56 -43
View File
@@ -71,7 +71,53 @@ export const isSpriteReady = (unitType: UnitType): boolean => {
const coloredSpriteCache: Map<string, HTMLCanvasElement> = new Map();
// puts the sprite in an canvas colors it and caches the colored canvas
/**
* Load a canvas and replace grayscale with border colors
*/
export const colorizeCanvas = (
source: CanvasImageSource & { width: number; height: number },
colorA: Colord,
colorB: Colord,
colorC: Colord,
): HTMLCanvasElement => {
const canvas = document.createElement("canvas");
canvas.width = source.width;
canvas.height = source.height;
const ctx = canvas.getContext("2d")!;
ctx.drawImage(source, 0, 0);
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
const data = imageData.data;
const colorARgb = colorA.toRgb();
const colorBRgb = colorB.toRgb();
const colorCRgb = colorC.toRgb();
for (let i = 0; i < data.length; i += 4) {
const r = data[i],
g = data[i + 1],
b = data[i + 2];
if (r === 180 && g === 180 && b === 180) {
data[i] = colorARgb.r;
data[i + 1] = colorARgb.g;
data[i + 2] = colorARgb.b;
} else if (r === 70 && g === 70 && b === 70) {
data[i] = colorBRgb.r;
data[i + 1] = colorBRgb.g;
data[i + 2] = colorBRgb.b;
} else if (r === 130 && g === 130 && b === 130) {
data[i] = colorCRgb.r;
data[i + 1] = colorCRgb.g;
data[i + 2] = colorCRgb.b;
}
}
ctx.putImageData(imageData, 0, 0);
return canvas;
};
export const getColoredSprite = (
unit: UnitView,
theme: Theme,
@@ -82,8 +128,7 @@ export const getColoredSprite = (
const territoryColor = customTerritoryColor ?? theme.territoryColor(owner);
const borderColor = customBorderColor ?? theme.borderColor(owner);
const spawnHighlightColor = theme.spawnHighlightColor();
const colorKey = territoryColor.toRgbString() + borderColor.toRgbString();
const key = unit.type() + colorKey;
const key = `${unit.type()}-${owner.id()}`;
if (coloredSpriteCache.has(key)) {
return coloredSpriteCache.get(key)!;
@@ -94,45 +139,13 @@ export const getColoredSprite = (
throw new Error(`Failed to load sprite for ${unit.type()}`);
}
const territoryRgb = territoryColor.toRgb();
const borderRgb = borderColor.toRgb();
const spawnHighlightRgb = spawnHighlightColor.toRgb();
const coloredCanvas = colorizeCanvas(
sprite,
territoryColor,
borderColor,
spawnHighlightColor,
);
const canvas = document.createElement("canvas");
const ctx = canvas.getContext("2d")!;
canvas.width = sprite.width;
canvas.height = sprite.height;
ctx.drawImage(sprite, 0, 0);
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
const data = imageData.data;
for (let i = 0; i < data.length; i += 4) {
const r = data[i];
const g = data[i + 1];
const b = data[i + 2];
if (r === 180 && g === 180 && b === 180) {
data[i] = territoryRgb.r;
data[i + 1] = territoryRgb.g;
data[i + 2] = territoryRgb.b;
}
if (r === 70 && g === 70 && b === 70) {
data[i] = borderRgb.r;
data[i + 1] = borderRgb.g;
data[i + 2] = borderRgb.b;
}
if (r === 130 && g === 130 && b === 130) {
data[i] = spawnHighlightRgb.r;
data[i + 1] = spawnHighlightRgb.g;
data[i + 2] = spawnHighlightRgb.b;
}
}
ctx.putImageData(imageData, 0.5, 0.5);
coloredSpriteCache.set(key, canvas);
return canvas;
coloredSpriteCache.set(key, coloredCanvas);
return coloredCanvas;
};
+41 -26
View File
@@ -2,12 +2,21 @@ import { EventBus } from "../../core/EventBus";
import { Cell } from "../../core/game/Game";
import { GameView } from "../../core/game/GameView";
import { CenterCameraEvent, DragEvent, ZoomEvent } from "../InputHandler";
import { GoToPlayerEvent, GoToUnitEvent } from "./layers/Leaderboard";
import {
GoToPlayerEvent,
GoToPositionEvent,
GoToUnitEvent,
} from "./layers/Leaderboard";
export const GOTO_INTERVAL_MS = 16;
export const CAMERA_MAX_SPEED = 15;
export const CAMERA_SMOOTHING = 0.03;
export class TransformHandler {
public scale: number = 1.8;
private offsetX: number = -350;
private offsetY: number = -200;
private lastGoToCallTime: number | null = null;
private target: Cell | null;
private intervalID: NodeJS.Timeout | null = null;
@@ -21,6 +30,7 @@ export class TransformHandler {
this.eventBus.on(ZoomEvent, (e) => this.onZoom(e));
this.eventBus.on(DragEvent, (e) => this.onMove(e));
this.eventBus.on(GoToPlayerEvent, (e) => this.onGoToPlayer(e));
this.eventBus.on(GoToPositionEvent, (e) => this.onGoToPosition(e));
this.eventBus.on(GoToUnitEvent, (e) => this.onGoToUnit(e));
this.eventBus.on(CenterCameraEvent, () => this.centerCamera());
}
@@ -146,7 +156,13 @@ export class TransformHandler {
event.player.nameLocation().x,
event.player.nameLocation().y,
);
this.intervalID = setInterval(() => this.goTo(), 1);
this.intervalID = setInterval(() => this.goTo(), GOTO_INTERVAL_MS);
}
onGoToPosition(event: GoToPositionEvent) {
this.clearTarget();
this.target = new Cell(event.x, event.y);
this.intervalID = setInterval(() => this.goTo(), GOTO_INTERVAL_MS);
}
onGoToUnit(event: GoToUnitEvent) {
@@ -155,7 +171,7 @@ export class TransformHandler {
this.game.x(event.unit.lastTile()),
this.game.y(event.unit.lastTile()),
);
this.intervalID = setInterval(() => this.goTo(), 1);
this.intervalID = setInterval(() => this.goTo(), GOTO_INTERVAL_MS);
}
centerCamera() {
@@ -163,43 +179,42 @@ export class TransformHandler {
const player = this.game.myPlayer();
if (!player || !player.nameLocation()) return;
this.target = new Cell(player.nameLocation().x, player.nameLocation().y);
this.intervalID = setInterval(() => this.goTo(), 1);
this.intervalID = setInterval(() => this.goTo(), GOTO_INTERVAL_MS);
}
private goTo() {
const { screenX, screenY } = this.screenCenter();
const screenMapCenter = new Cell(screenX, screenY);
if (this.target === null) throw new Error("null target");
if (
this.game.manhattanDist(
this.game.ref(screenX, screenY),
this.game.ref(this.target.x, this.target.y),
) < 2
Math.abs(this.target.x - screenX) + Math.abs(this.target.y - screenY) <
2
) {
this.clearTarget();
return;
}
const dX = Math.abs(screenMapCenter.x - this.target.x);
if (dX > 2) {
const offsetDx = Math.max(1, Math.floor(dX / 25));
if (screenMapCenter.x > this.target.x) {
this.offsetX -= offsetDx;
} else {
this.offsetX += offsetDx;
}
}
const dY = Math.abs(screenMapCenter.y - this.target.y);
if (dY > 2) {
const offsetDy = Math.max(1, Math.floor(dY / 25));
if (screenMapCenter.y > this.target.y) {
this.offsetY -= offsetDy;
} else {
this.offsetY += offsetDy;
}
let dt: number;
const now = window.performance.now();
if (this.lastGoToCallTime === null) {
dt = GOTO_INTERVAL_MS;
} else {
dt = now - this.lastGoToCallTime;
}
this.lastGoToCallTime = now;
const r = 1 - Math.pow(CAMERA_SMOOTHING, dt / 1000);
this.offsetX += Math.max(
Math.min((this.target.x - screenX) * r, CAMERA_MAX_SPEED),
-CAMERA_MAX_SPEED,
);
this.offsetY += Math.max(
Math.min((this.target.y - screenY) * r, CAMERA_MAX_SPEED),
-CAMERA_MAX_SPEED,
);
this.changed = true;
}
+7
View File
@@ -3,6 +3,13 @@ export interface Fx {
}
export enum FxType {
MiniFire = "MiniFire",
MiniSmoke = "MiniSmoke",
MiniBigSmoke = "MiniBigSmoke",
MiniSmokeAndFire = "MiniSmokeAndFire",
MiniExplosion = "MiniExplosion",
UnitExplosion = "UnitExplosion",
SinkingShip = "SinkingShip",
Nuke = "Nuke",
SAMExplosion = "SAMExplosion",
}
+75 -27
View File
@@ -1,6 +1,7 @@
import { AnimatedSprite } from "../AnimatedSprite";
import { createAnimatedSpriteForUnit } from "../AnimatedSpriteLoader";
import { GameView } from "../../../core/game/GameView";
import { AnimatedSpriteLoader } from "../AnimatedSpriteLoader";
import { Fx, FxType } from "./Fx";
import { FadeFx, SpriteFx } from "./SpriteFx";
/**
* Shockwave effect: draw a growing 1px white circle
@@ -31,32 +32,79 @@ export class ShockwaveFx implements Fx {
}
/**
* Explosion effect: sprite animation of an explosion
* Spawn @p number of @p type animation within a perimeter
*/
export class NukeExplosionFx implements Fx {
private lifeTime: number = 0;
private nukeExplosionSprite: AnimatedSprite | null;
constructor(
private x: number,
private y: number,
private duration: number,
) {
this.nukeExplosionSprite = createAnimatedSpriteForUnit(FxType.Nuke);
}
renderTick(frameTime: number, ctx: CanvasRenderingContext2D): boolean {
if (this.nukeExplosionSprite) {
this.lifeTime += frameTime;
if (this.lifeTime >= this.duration) {
return false;
}
if (this.nukeExplosionSprite.isActive()) {
this.nukeExplosionSprite.update(frameTime);
this.nukeExplosionSprite.draw(ctx, this.x, this.y);
return true;
}
return false;
function addSpriteInCircle(
animatedSpriteLoader: AnimatedSpriteLoader,
x: number,
y: number,
radius: number,
num: number,
type: FxType,
result: Fx[],
game: GameView,
) {
const count = Math.max(0, Math.floor(num));
for (let i = 0; i < count; i++) {
const angle = Math.random() * 2 * Math.PI;
const distance = Math.random() * (radius / 2);
const spawnX = Math.floor(x + Math.cos(angle) * distance);
const spawnY = Math.floor(y + Math.sin(angle) * distance);
if (
game.isValidCoord(spawnX, spawnY) &&
game.isLand(game.ref(spawnX, spawnY))
) {
const sprite = new FadeFx(
new SpriteFx(animatedSpriteLoader, spawnX, spawnY, type, 6000),
0.1,
0.8,
);
result.push(sprite as Fx);
}
return false;
}
}
/**
* Explosion effect:
* - explosion animation
* - shockwave
* - ruins and desolation fx
*/
export function nukeFxFactory(
animatedSpriteLoader: AnimatedSpriteLoader,
x: number,
y: number,
radius: number,
game: GameView,
): Fx[] {
const nukeFx: Fx[] = [];
// Explosion animation
nukeFx.push(new SpriteFx(animatedSpriteLoader, x, y, FxType.Nuke));
// Shockwave animation
nukeFx.push(new ShockwaveFx(x, y, 1500, radius * 1.5));
// Ruins and desolation sprites
const debrisPlan: Array<{
type: FxType;
radiusFactor: number;
density: number;
}> = [
{ type: FxType.MiniFire, radiusFactor: 1.0, density: 1 / 25 },
{ type: FxType.MiniSmoke, radiusFactor: 1.0, density: 1 / 28 },
{ type: FxType.MiniBigSmoke, radiusFactor: 0.9, density: 1 / 70 },
{ type: FxType.MiniSmokeAndFire, radiusFactor: 0.9, density: 1 / 70 },
];
for (const { type, radiusFactor, density } of debrisPlan) {
addSpriteInCircle(
animatedSpriteLoader,
x,
y,
radius * radiusFactor,
radius * density,
type,
nukeFx,
game,
);
}
return nukeFx;
}
-34
View File
@@ -1,34 +0,0 @@
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;
}
}
+92
View File
@@ -0,0 +1,92 @@
import { Theme } from "../../../core/configuration/Config";
import { consolex } from "../../../core/Consolex";
import { PlayerView } from "../../../core/game/GameView";
import { AnimatedSprite } from "../AnimatedSprite";
import { AnimatedSpriteLoader } from "../AnimatedSpriteLoader";
import { Fx, FxType } from "./Fx";
function fadeInOut(
t: number,
fadeIn: number = 0.3,
fadeOut: number = 0.7,
): number {
if (t < fadeIn) {
const f = t / fadeIn; // Map to [0, 1]
return f * f;
} else if (t < fadeOut) {
return 1;
} else {
const f = (t - fadeOut) / (1 - fadeOut); // Map to [0, 1]
return 1 - f * f;
}
}
/**
* Fade in/out another FX
*/
export class FadeFx implements Fx {
constructor(
private fxToFade: SpriteFx,
private fadeIn: number,
private fadeOut: number,
) {}
renderTick(duration: number, ctx: CanvasRenderingContext2D): boolean {
const t = this.fxToFade.getElapsedTime() / this.fxToFade.getDuration();
ctx.save();
ctx.globalAlpha = fadeInOut(t, this.fadeIn, this.fadeOut);
const result = this.fxToFade.renderTick(duration, ctx);
ctx.restore();
return result;
}
}
/**
* Animated sprite. Can be colored if provided an owner/theme
*/
export class SpriteFx implements Fx {
protected animatedSprite: AnimatedSprite | null;
protected elapsedTime = 0;
protected duration = 1000;
constructor(
animatedSpriteLoader: AnimatedSpriteLoader,
protected x: number,
protected y: number,
fxType: FxType,
duration?: number,
private owner?: PlayerView,
private theme?: Theme,
) {
this.animatedSprite = animatedSpriteLoader.createAnimatedSprite(
fxType,
owner,
theme,
);
if (!this.animatedSprite) {
consolex.error("Could not load animated sprite", fxType);
} else {
this.duration = duration ?? this.animatedSprite.lifeTime() ?? 1000;
}
}
renderTick(frameTime: number, ctx: CanvasRenderingContext2D): boolean {
if (!this.animatedSprite) return false;
this.elapsedTime += frameTime;
if (this.elapsedTime >= this.duration) return false;
if (!this.animatedSprite.isActive()) return false;
const t = this.elapsedTime / this.duration;
this.animatedSprite.update(frameTime);
this.animatedSprite.draw(ctx, this.x, this.y);
return true;
}
getElapsedTime(): number {
return this.elapsedTime;
}
getDuration(): number {
return this.duration;
}
}
+33
View File
@@ -0,0 +1,33 @@
type TimedTask = {
delay: number;
action: () => void;
triggered: boolean;
};
/**
* Basic timeline to chain actions
*/
export class Timeline {
private tasks: TimedTask[] = [];
private timeElapsed = 0;
add(delay: number, action: () => void): Timeline {
this.tasks.push({ delay, action, triggered: false });
return this;
}
update(dt: number) {
this.timeElapsed += dt;
for (const task of this.tasks) {
if (!task.triggered && this.timeElapsed >= task.delay) {
task.action();
task.triggered = true;
}
}
}
isComplete() {
return this.tasks.every((t) => t.triggered);
}
}
+47
View File
@@ -0,0 +1,47 @@
import { GameView } from "../../../core/game/GameView";
import { AnimatedSpriteLoader } from "../AnimatedSpriteLoader";
import { Fx, FxType } from "./Fx";
import { SpriteFx } from "./SpriteFx";
import { Timeline } from "./Timeline";
/**
* Explosion Effect: a few timed explosions
*/
export class UnitExplosionFx implements Fx {
private timeline = new Timeline();
private explosions: Fx[] = [];
constructor(
animatedSpriteLoader: AnimatedSpriteLoader,
private x: number,
private y: number,
game: GameView,
) {
const config = [
{ dx: 0, dy: 0, delay: 0, type: FxType.UnitExplosion },
{ dx: 4, dy: -6, delay: 80, type: FxType.UnitExplosion },
{ dx: -6, dy: 4, delay: 160, type: FxType.UnitExplosion },
];
for (const { dx, dy, delay, type } of config) {
this.timeline.add(delay, () => {
if (game.isValidCoord(x + dx, y + dy)) {
this.explosions.push(
new SpriteFx(animatedSpriteLoader, x + dx, y + dy, type),
);
}
});
}
}
renderTick(frameTime: number, ctx: CanvasRenderingContext2D): boolean {
this.timeline.update(frameTime);
let allDone = true;
for (const fx of this.explosions) {
if (fx.renderTick(frameTime, ctx)) {
allDone = false;
}
}
return !allDone || !this.timeline.isComplete();
}
}
+31 -6
View File
@@ -34,7 +34,11 @@ import { Layer } from "./Layer";
import { GameView, PlayerView, UnitView } from "../../../core/game/GameView";
import { onlyImages } from "../../../core/Util";
import { renderTroops } from "../../Utils";
import { GoToPlayerEvent, GoToUnitEvent } from "./Leaderboard";
import {
GoToPlayerEvent,
GoToPositionEvent,
GoToUnitEvent,
} from "./Leaderboard";
import { translateText } from "../../Utils";
@@ -393,6 +397,10 @@ export class EventsDisplay extends LitElement implements Layer {
this.eventBus.emit(new GoToPlayerEvent(attacker));
}
emitGoToPositionEvent(x: number, y: number) {
this.eventBus.emit(new GoToPositionEvent(x, y));
}
emitGoToUnitEvent(unit: UnitView) {
this.eventBus.emit(new GoToUnitEvent(unit));
}
@@ -476,6 +484,26 @@ export class EventsDisplay extends LitElement implements Layer {
: event.description;
}
private async attackWarningOnClick(attack: AttackUpdate) {
const playerView = this.game.playerBySmallID(attack.attackerID);
if (playerView !== undefined) {
if (playerView instanceof PlayerView) {
const averagePosition = await playerView.attackAveragePosition(
attack.attackerID,
attack.id,
);
if (averagePosition === null) {
this.emitGoToPlayerEvent(attack.attackerID);
} else {
this.emitGoToPositionEvent(averagePosition.x, averagePosition.y);
}
}
} else {
this.emitGoToPlayerEvent(attack.attackerID);
}
}
private renderIncomingAttacks() {
return html`
${this.incomingAttacks.length > 0
@@ -487,10 +515,7 @@ export class EventsDisplay extends LitElement implements Layer {
<button
translate="no"
class="ml-2"
@click=${() => {
attack.attackerID &&
this.emitGoToPlayerEvent(attack.attackerID);
}}
@click=${() => this.attackWarningOnClick(attack)}
>
${renderTroops(attack.troops)}
${(
@@ -520,7 +545,7 @@ export class EventsDisplay extends LitElement implements Layer {
<button
translate="no"
class="ml-2"
@click=${() => this.emitGoToPlayerEvent(attack.targetID)}
@click=${async () => this.attackWarningOnClick(attack)}
>
${renderTroops(attack.troops)}
${(
+84 -22
View File
@@ -1,10 +1,12 @@
import { Theme } from "../../../core/configuration/Config";
import { UnitType } from "../../../core/game/Game";
import { GameUpdateType } from "../../../core/game/GameUpdates";
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 { AnimatedSpriteLoader } from "../AnimatedSpriteLoader";
import { Fx, FxType } from "../fx/Fx";
import { nukeFxFactory, ShockwaveFx } from "../fx/NukeFx";
import { SpriteFx } from "../fx/SpriteFx";
import { UnitExplosionFx } from "../fx/UnitExplosionFx";
import { Layer } from "./Layer";
export class FxLayer implements Layer {
@@ -13,10 +15,15 @@ export class FxLayer implements Layer {
private lastRefresh: number = 0;
private refreshRate: number = 10;
private theme: Theme;
private animatedSpriteLoader: AnimatedSpriteLoader =
new AnimatedSpriteLoader();
private allFx: Fx[] = [];
constructor(private game: GameView) {}
constructor(private game: GameView) {
this.theme = this.game.config().theme();
}
shouldTransform(): boolean {
return true;
@@ -36,47 +43,102 @@ export class FxLayer implements Layer {
switch (unit.type()) {
case UnitType.AtomBomb:
case UnitType.MIRVWarhead:
this.handleNukes(unit, 70);
this.onNukeEvent(unit, 70);
break;
case UnitType.HydrogenBomb:
this.handleNukes(unit, 250);
this.onNukeEvent(unit, 160);
break;
case UnitType.Warship:
this.onWarshipEvent(unit);
break;
case UnitType.Shell:
this.onShellEvent(unit);
break;
}
}
handleNukes(unit: UnitView, shockwaveRadius: number) {
onShellEvent(unit: UnitView) {
if (!unit.isActive()) {
if (unit.wasInterceptedBySAM()) {
this.handleSAMInterception(unit);
} else {
// Kaboom
this.handleNukeExplosion(unit, shockwaveRadius);
if (unit.reachedTarget()) {
const x = this.game.x(unit.lastTile());
const y = this.game.y(unit.lastTile());
const shipExplosion = new SpriteFx(
this.animatedSpriteLoader,
x,
y,
FxType.MiniExplosion,
);
this.allFx.push(shipExplosion);
}
}
}
handleNukeExplosion(unit: UnitView, shockwaveRadius: number) {
onWarshipEvent(unit: UnitView) {
if (!unit.isActive()) {
const x = this.game.x(unit.lastTile());
const y = this.game.y(unit.lastTile());
const shipExplosion = new UnitExplosionFx(
this.animatedSpriteLoader,
x,
y,
this.game,
);
this.allFx.push(shipExplosion);
const sinkingShip = new SpriteFx(
this.animatedSpriteLoader,
x,
y,
FxType.SinkingShip,
undefined,
unit.owner(),
this.theme,
);
this.allFx.push(sinkingShip);
}
}
onNukeEvent(unit: UnitView, radius: number) {
if (!unit.isActive()) {
if (!unit.reachedTarget()) {
this.handleSAMInterception(unit);
} else {
// Kaboom
this.handleNukeExplosion(unit, radius);
}
}
}
handleNukeExplosion(unit: UnitView, radius: 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);
const nukeFx = nukeFxFactory(
this.animatedSpriteLoader,
x,
y,
radius,
this.game,
);
this.allFx = this.allFx.concat(nukeFx);
}
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 explosion = new SpriteFx(
this.animatedSpriteLoader,
x,
y,
FxType.SAMExplosion,
);
this.allFx.push(explosion);
const shockwave = new ShockwaveFx(x, y, 800, 40);
this.allFx.push(shockwave as Fx);
this.allFx.push(shockwave);
}
async init() {
this.redraw();
try {
await loadAllAnimatedSpriteImages();
this.animatedSpriteLoader.loadAllAnimatedSpriteImages();
console.log("FX sprites loaded successfully");
} catch (err) {
console.error("Failed to load FX sprites:", err);
@@ -22,6 +22,13 @@ export class GoToPlayerEvent implements GameEvent {
constructor(public player: PlayerView) {}
}
export class GoToPositionEvent implements GameEvent {
constructor(
public x: number,
public y: number,
) {}
}
export class GoToUnitEvent implements GameEvent {
constructor(public unit: UnitView) {}
}
+10
View File
@@ -308,6 +308,16 @@ export class PlayerPanel extends LitElement implements Layer {
</div>
</div>
<!-- Betrayals -->
<div class="flex flex-col gap-1">
<div class="text-white text-opacity-80 text-sm px-2">
${translateText("player_panel.betrayals")}
</div>
<div class="bg-opacity-50 bg-gray-700 rounded p-2 text-white">
${other.data.betrayals ?? 0}
</div>
</div>
<!-- Embargo -->
<div class="flex flex-col gap-1">
<div class="text-white text-opacity-80 text-sm px-2">
+1 -1
View File
@@ -63,7 +63,7 @@ export class SpawnTimer implements Layer {
let x = 0;
let filledRatio = 0;
for (let i = 0; i < this.ratios.length && i < this.colors.length; i++) {
const ratio = this.ratios[i];
const ratio = this.ratios[i] ?? 1 - filledRatio;
const segmentWidth = barWidth * ratio;
context.fillStyle = this.colors[i];
@@ -1,7 +1,10 @@
import { colord, Colord } from "colord";
import { Theme } from "../../../core/configuration/Config";
import { EventBus } from "../../../core/EventBus";
import { MouseUpEvent } from "../../InputHandler";
import { TransformHandler } from "../TransformHandler";
import { Layer } from "./Layer";
import { UnitInfoModal } from "./UnitInfoModal";
import cityIcon from "../../../../resources/images/buildings/cityAlt1.png";
import shieldIcon from "../../../../resources/images/buildings/fortAlt2.png";
@@ -22,6 +25,7 @@ import { GameView, UnitView } from "../../../core/game/GameView";
const underConstructionColor = colord({ r: 150, g: 150, b: 150 });
const reloadingColor = colord({ r: 255, g: 0, b: 0 });
const selectedUnitColor = colord({ r: 0, g: 255, b: 255 });
type DistanceFunction = typeof euclDistFN;
@@ -44,6 +48,8 @@ export class StructureLayer implements Layer {
private context: CanvasRenderingContext2D;
private unitIcons: Map<string, ImageData> = new Map();
private theme: Theme;
private selectedStructureUnit: UnitView | null = null;
private previouslySelected: UnitView | null = null;
// Configuration for supported unit types only
private readonly unitConfigs: Partial<Record<UnitType, UnitRenderConfig>> = {
@@ -82,7 +88,15 @@ export class StructureLayer implements Layer {
constructor(
private game: GameView,
private eventBus: EventBus,
private transformHandler: TransformHandler,
private unitInfoModal: UnitInfoModal | null,
) {
if (!unitInfoModal) {
throw new Error(
"UnitInfoModal instance must be provided to StructureLayer.",
);
}
this.unitInfoModal = unitInfoModal;
this.theme = game.config().theme();
this.loadIconData();
this.loadIcon("reloadingSam", {
@@ -147,6 +161,7 @@ export class StructureLayer implements Layer {
init() {
this.redraw();
this.eventBus.on(MouseUpEvent, (e) => this.onMouseUp(e));
}
redraw() {
@@ -265,6 +280,10 @@ export class StructureLayer implements Layer {
borderColor = underConstructionColor;
}
if (this.selectedStructureUnit === unit) {
borderColor = selectedUnitColor;
}
this.drawBorder(unit, borderColor, config, drawFunction);
const startX = this.game.x(unit.tile()) - Math.floor(icon.width / 2);
@@ -316,4 +335,80 @@ export class StructureLayer implements Layer {
clearCell(cell: Cell) {
this.context.clearRect(cell.x, cell.y, 1, 1);
}
private findStructureUnitAtCell(
cell: { x: number; y: number },
maxDistance: number = 10,
): UnitView | null {
const targetRef = this.game.ref(cell.x, cell.y);
const allUnitTypes = Object.values(UnitType);
const nearby = this.game.nearbyUnits(targetRef, maxDistance, allUnitTypes);
for (const { unit } of nearby) {
if (unit.isActive() && this.isUnitTypeSupported(unit.type())) {
return unit;
}
}
return null;
}
private onMouseUp(event: MouseUpEvent) {
const cell = this.transformHandler.screenToWorldCoordinates(
event.x,
event.y,
);
if (!this.game.isValidCoord(cell.x, cell.y)) {
return;
}
const clickedUnit = this.findStructureUnitAtCell(cell);
this.previouslySelected = this.selectedStructureUnit;
if (clickedUnit) {
const wasSelected = this.previouslySelected === clickedUnit;
if (wasSelected) {
this.selectedStructureUnit = null;
if (this.previouslySelected) {
this.handleUnitRendering(this.previouslySelected);
}
this.unitInfoModal?.onCloseStructureModal();
} else {
this.selectedStructureUnit = clickedUnit;
if (
this.previouslySelected &&
this.previouslySelected !== clickedUnit
) {
this.handleUnitRendering(this.previouslySelected);
}
this.handleUnitRendering(clickedUnit);
const screenPos = this.transformHandler.worldToScreenCoordinates(cell);
const unitTile = clickedUnit.tile();
this.unitInfoModal?.onOpenStructureModal({
unit: clickedUnit,
x: screenPos.x,
y: screenPos.y,
tileX: this.game.x(unitTile),
tileY: this.game.y(unitTile),
});
}
} else {
this.selectedStructureUnit = null;
if (this.previouslySelected) {
this.handleUnitRendering(this.previouslySelected);
}
this.unitInfoModal?.onCloseStructureModal();
}
}
public unSelectStructureUnit() {
if (this.selectedStructureUnit) {
this.previouslySelected = this.selectedStructureUnit;
this.selectedStructureUnit = null;
this.handleUnitRendering(this.previouslySelected);
}
}
}
+164
View File
@@ -0,0 +1,164 @@
import { LitElement, css, html } from "lit";
import { customElement, property } from "lit/decorators.js";
import { UnitType } from "../../../core/game/Game";
import { GameView, UnitView } from "../../../core/game/GameView";
import { Layer } from "./Layer";
import { StructureLayer } from "./StructureLayer";
@customElement("unit-info-modal")
export class UnitInfoModal extends LitElement implements Layer {
@property({ type: Boolean }) open = false;
@property({ type: Number }) x = 0;
@property({ type: Number }) y = 0;
@property({ type: Object }) unit: UnitView | null = null;
public game: GameView;
public structureLayer: StructureLayer | null = null;
constructor() {
super();
}
init() {}
tick() {
if (this.unit) {
this.requestUpdate();
}
}
public onOpenStructureModal = ({
unit,
x,
y,
tileX,
tileY,
}: {
unit: UnitView;
x: number;
y: number;
tileX: number;
tileY: number;
}) => {
if (!this.game) return;
this.x = x;
this.y = y;
const targetRef = this.game.ref(tileX, tileY);
const allUnitTypes = Object.values(UnitType);
const matchingUnits = this.game
.nearbyUnits(targetRef, 10, allUnitTypes)
.filter(({ unit }) => unit.isActive());
if (matchingUnits.length > 0) {
matchingUnits.sort((a, b) => a.distSquared - b.distSquared);
this.unit = matchingUnits[0].unit;
} else {
this.unit = null;
}
this.open = this.unit !== null;
};
public onCloseStructureModal = () => {
this.open = false;
this.unit = null;
};
connectedCallback() {
super.connectedCallback();
}
disconnectedCallback() {
super.disconnectedCallback();
}
static styles = css`
:host {
position: fixed;
pointer-events: none;
z-index: 1000;
}
.modal {
pointer-events: auto;
background: rgba(30, 30, 30, 0.95);
color: #f8f8f8;
border: 1px solid #555;
padding: 12px 18px;
border-radius: 8px;
min-width: 220px;
max-width: 300px;
box-shadow: 0 6px 12px rgba(0, 0, 0, 0.5);
font-family: "Segoe UI", sans-serif;
font-size: 15px;
line-height: 1.6;
backdrop-filter: blur(6px);
position: relative;
}
.modal strong {
color: #e0e0e0;
}
.close-button {
background: #d00;
color: #fff;
border: none;
border-radius: 4px;
font-size: 14px;
font-weight: bold;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
line-height: 1;
padding: 6px 12px;
}
.close-button:hover {
background: #a00;
}
`;
render() {
if (!this.unit) return null;
const cooldown = this.unit.ticksLeftInCooldown() ?? 0;
const secondsLeft = Math.ceil(cooldown / 10);
return html`
<div
class="modal"
style="display: ${this.open ? "block" : "none"}; left: ${this
.x}px; top: ${this.y}px; position: absolute;"
>
<div style="margin-bottom: 8px; font-size: 16px; font-weight: bold;">
Structure Info
</div>
<div style="margin-bottom: 4px;">
<strong>Type:</strong> ${this.unit.type?.() ?? "Unknown"}
</div>
${secondsLeft > 0
? html`<div style="margin-bottom: 4px;">
<strong>Cooldown:</strong> ${secondsLeft}s
</div>`
: ""}
<div style="margin-top: 14px; display: flex; justify-content: center;">
<button
@click=${() => {
this.onCloseStructureModal();
if (this.structureLayer) {
this.structureLayer.unSelectStructureUnit();
}
}}
class="close-button"
title="Close"
style="width: 100px; height: 32px;"
>
CLOSE
</button>
</div>
</div>
`;
}
}
+13 -14
View File
@@ -2,9 +2,8 @@ import { LitElement, css, html } from "lit";
import { customElement, state } from "lit/decorators.js";
import { translateText } from "../../../client/Utils";
import { EventBus } from "../../../core/EventBus";
import { Team } from "../../../core/game/Game";
import { GameUpdateType } from "../../../core/game/GameUpdates";
import { GameView, PlayerView } from "../../../core/game/GameView";
import { GameView } from "../../../core/game/GameView";
import { SendWinnerEvent } from "../../Transport";
import { Layer } from "./Layer";
@@ -185,32 +184,32 @@ export class WinModal extends LitElement implements Layer {
const updates = this.game.updatesSinceLastTick();
const winUpdates = updates !== null ? updates[GameUpdateType.Win] : [];
winUpdates.forEach((wu) => {
if (wu.winnerType === "team") {
this.eventBus.emit(
new SendWinnerEvent(wu.winner as Team, wu.allPlayersStats, "team"),
);
if (wu.winner === this.game.myPlayer()?.team()) {
if (wu.winner[0] === "team") {
this.eventBus.emit(new SendWinnerEvent(wu.winner, wu.allPlayersStats));
if (wu.winner[1] === this.game.myPlayer()?.team()) {
this._title = translateText("win_modal.your_team");
} else {
this._title = translateText("win_modal.other_team", {
team: wu.winner,
team: wu.winner[1],
});
}
this.show();
} else {
const winner = this.game.playerBySmallID(
wu.winner as number,
) as PlayerView;
const winner = this.game.playerBySmallID(wu.winner[1]);
if (!winner.isPlayer()) return;
const winnerClient = winner.clientID();
if (winnerClient !== null) {
this.eventBus.emit(
new SendWinnerEvent(winnerClient, wu.allPlayersStats, "player"),
new SendWinnerEvent(["player", winnerClient], wu.allPlayersStats),
);
}
if (winner === this.game.myPlayer()) {
if (
winnerClient !== null &&
winnerClient === this.game.myPlayer()?.clientID()
) {
this._title = translateText("win_modal.you_won");
} else {
this._title = translateText("win_modal.you_won", {
this._title = translateText("win_modal.other_won", {
player: winner.name(),
});
}
+13 -43
View File
@@ -135,25 +135,6 @@
gtag("config", "G-WQGQQ8RDN4");
</script>
<!-- AdinPlay Ads -->
<script>
var aiptag = aiptag || {};
aiptag.cmd = aiptag.cmd || [];
aiptag.cmd.display = aiptag.cmd.display || [];
aiptag.cmd.player = aiptag.cmd.player || [];
//CMP tool settings
aiptag.cmp = {
show: true,
button: true,
buttonText: "Privacy settings",
buttonPosition: "top-right", //bottom-left, bottom-right, top-left, top-right
};
</script>
<script
async
src="//api.adinplay.com/libs/aiptag/pub/OFI/openfront.io/tag.min.js"
></script>
</head>
<body
@@ -222,26 +203,6 @@
</header>
<div class="bg-image"></div>
<!-- Left gutter ad placement - full height, no empty space -->
<div class="left-gutter-ad ad">
<div id="openfront-io_300x600">
<script type="text/javascript">
aiptag.cmd.display.push(function () {
aipDisplayTag.display("openfront-io_300x600");
});
</script>
</div>
</div>
<div class="right-gutter-ad ad">
<div id="openfront-io_300x600_2">
<script type="text/javascript">
aiptag.cmd.display.push(function () {
aipDisplayTag.display("openfront-io_300x600_2");
});
</script>
</div>
</div>
<!-- Main container with responsive padding -->
<main class="flex justify-center flex-grow">
<div class="container pt-12">
@@ -269,12 +230,12 @@
title="Pick a pattern!"
></button>
</territory-patterns-modal>
<username-input class="w-full"></username-input>
<news-button class="mt-3"></news-button>
<username-input class="relative w-full"></username-input>
<news-button class="w-[20%] md:w-[15%]"></news-button>
</div>
<div></div>
<div>
<public-lobby class="w-full"></public-lobby>
<public-lobby class="block"></public-lobby>
</div>
<div class="container__row container__row--equal">
<o-button
@@ -396,10 +357,17 @@
<div class="l-footer__col t-text-white">
<a
href="https://github.com/openfrontio/OpenFrontIO"
class="t-link"
class="t-link inline-flex items-center space-x-2"
target="_blank"
>
©2025 OpenFront™
<img
src="../../resources/icons/github-mark-white.svg"
alt="GitHub"
width="20"
height="20"
class="ml-2 mr-4"
/>
</a>
<a href="/privacy-policy.html" class="t-link" target="_blank">
Privacy Policy
@@ -427,7 +395,9 @@
<chat-modal></chat-modal>
<user-setting></user-setting>
<multi-tab-modal></multi-tab-modal>
<unit-info-modal></unit-info-modal>
<news-modal></news-modal>
<left-in-game-ad></left-in-game-ad>
<div
id="language-modal"
class="fixed inset-0 bg-black bg-opacity-50 z-50 hidden flex justify-center items-center"
+10 -4
View File
@@ -65,14 +65,17 @@ export async function logOut(allSessions: boolean = false) {
return true;
}
let __isLoggedIn: TokenPayload | false | undefined = undefined;
export function isLoggedIn(): TokenPayload | false {
export type IsLoggedInResponse =
| { token: string; claims: TokenPayload }
| false;
let __isLoggedIn: IsLoggedInResponse | undefined = undefined;
export function isLoggedIn(): IsLoggedInResponse {
if (__isLoggedIn === undefined) {
__isLoggedIn = _isLoggedIn();
}
return __isLoggedIn;
}
export function _isLoggedIn(): TokenPayload | false {
function _isLoggedIn(): IsLoggedInResponse {
try {
const token = getToken();
if (!token) {
@@ -144,7 +147,8 @@ export function _isLoggedIn(): TokenPayload | false {
return false;
}
return result.data;
const claims = result.data;
return { token, claims };
} catch (e) {
console.log(e);
return false;
@@ -177,6 +181,7 @@ export async function postRefresh(): Promise<boolean> {
localStorage.setItem("token", result.data.token);
return true;
} catch (e) {
__isLoggedIn = false;
return false;
}
}
@@ -205,6 +210,7 @@ export async function getUserMe(): Promise<UserMeResponse | false> {
}
return result.data;
} catch (e) {
__isLoggedIn = false;
return false;
}
}
+50
View File
@@ -453,6 +453,56 @@ label.option-card:hover {
max-width: 100vw;
width: 100%;
}
#error-modal {
max-width: 575px;
}
}
@media screen and (max-width: 480px) {
#error-modal {
max-width: 350px;
}
}
#error-modal {
position: fixed;
padding: 20px;
background: white;
border: 1px solid black;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
z-index: 9999;
width: 87%;
box-sizing: border-box;
}
#error-modal pre {
overflow-x: auto;
overflow-y: auto;
white-space: pre-wrap;
word-wrap: break-word;
}
#error-modal button.copy-btn {
padding: 8px 16px;
margin-top: 10px;
background: #4caf50;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
}
#error-modal button.close-btn {
color: white;
top: 0px;
right: 0px;
cursor: pointer;
background: red;
margin-right: 0px;
position: fixed;
width: 40px;
}
.start-game-button-container {
+3 -3
View File
@@ -14,13 +14,13 @@ import gatewayToTheAtlantic from "../../../resources/maps/GatewayToTheAtlanticTh
import halkidiki from "../../../resources/maps/HalkidikiThumb.webp";
import iceland from "../../../resources/maps/IcelandThumb.webp";
import japan from "../../../resources/maps/JapanThumb.webp";
import knownworld from "../../../resources/maps/KnownWorldThumb.webp";
import mars from "../../../resources/maps/MarsThumb.webp";
import mena from "../../../resources/maps/MenaThumb.webp";
import northAmerica from "../../../resources/maps/NorthAmericaThumb.webp";
import oceania from "../../../resources/maps/OceaniaThumb.webp";
import pangaea from "../../../resources/maps/PangaeaThumb.webp";
import southAmerica from "../../../resources/maps/SouthAmericaThumb.webp";
import worldmapgiant from "../../../resources/maps/WorldMapGiantThumb.webp";
import world from "../../../resources/maps/WorldMapThumb.webp";
import { GameMapType } from "../../core/game/Game";
@@ -29,6 +29,8 @@ export function getMapsImage(map: GameMapType): string {
switch (map) {
case GameMapType.World:
return world;
case GameMapType.WorldMapGiant:
return worldmapgiant;
case GameMapType.Oceania:
return oceania;
case GameMapType.Europe:
@@ -63,8 +65,6 @@ export function getMapsImage(map: GameMapType): string {
return japan;
case GameMapType.BetweenTwoSeas:
return betweenTwoSeas;
case GameMapType.KnownWorld:
return knownworld;
case GameMapType.FaroeIslands:
return faroeislands;
case GameMapType.DeglaciatedAntarctica: