mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-07-03 06:10:48 +00:00
Merge branch 'main' into patterned-territory
This commit is contained in:
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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>
|
||||
|
||||
@@ -435,7 +435,6 @@ export class SinglePlayerModal extends LitElement {
|
||||
gameID: gameID,
|
||||
players: [
|
||||
{
|
||||
playerID: generateID(),
|
||||
clientID,
|
||||
username: usernameInput.getCurrentUsername(),
|
||||
flag:
|
||||
|
||||
@@ -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:",
|
||||
|
||||
@@ -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>`
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
`;
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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)}
|
||||
${(
|
||||
|
||||
@@ -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) {}
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user