mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-07-03 22:00:57 +00:00
Merge branch 'main' into custom-flag
This commit is contained in:
@@ -0,0 +1,48 @@
|
||||
import { z } from "zod";
|
||||
import { base64urlToUuid } from "./Base64";
|
||||
|
||||
export const RefreshResponseSchema = z.object({
|
||||
token: z.string(),
|
||||
});
|
||||
export type RefreshResponse = z.infer<typeof RefreshResponseSchema>;
|
||||
|
||||
export const TokenPayloadSchema = z.object({
|
||||
jti: z.string(),
|
||||
sub: z
|
||||
.string()
|
||||
.refine(
|
||||
(val) => {
|
||||
const uuid = base64urlToUuid(val);
|
||||
return uuid != null;
|
||||
},
|
||||
{
|
||||
message: "Invalid base64-encoded UUID",
|
||||
},
|
||||
)
|
||||
.transform((val) => {
|
||||
const uuid = base64urlToUuid(val);
|
||||
if (!uuid) throw new Error("Invalid base64 UUID");
|
||||
return uuid;
|
||||
}),
|
||||
iat: z.number(),
|
||||
iss: z.string(),
|
||||
aud: z.string(),
|
||||
exp: z.number(),
|
||||
rol: z
|
||||
.string()
|
||||
.optional()
|
||||
.transform((val) => val.split(",")),
|
||||
});
|
||||
export type TokenPayload = z.infer<typeof TokenPayloadSchema>;
|
||||
|
||||
export const UserMeResponseSchema = z.object({
|
||||
user: z.object({
|
||||
id: z.string(),
|
||||
avatar: z.string(),
|
||||
username: z.string(),
|
||||
global_name: z.string(),
|
||||
discriminator: z.string(),
|
||||
locale: z.string(),
|
||||
}),
|
||||
});
|
||||
export type UserMeResponse = z.infer<typeof UserMeResponseSchema>;
|
||||
@@ -0,0 +1,37 @@
|
||||
import { base64url } from "jose";
|
||||
|
||||
/**
|
||||
* Converts a UUID string to a base64url-encoded binary representation.
|
||||
* @param uuid - The UUID string (e.g., '123e4567-e89b-12d3-a456-426614174000')
|
||||
* @returns base64url string (e.g., 'Ej5FZ+i7EtOkVkJmFBdAAA')
|
||||
*/
|
||||
export function uuidToBase64url(uuid: string): string {
|
||||
const hex = uuid.replace(/-/g, "");
|
||||
const bytes = new Uint8Array(16);
|
||||
|
||||
for (let i = 0; i < 16; i++) {
|
||||
bytes[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);
|
||||
}
|
||||
|
||||
return base64url.encode(bytes);
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a base64url-encoded binary UUID back to its canonical UUID string.
|
||||
* @param encoded - base64url string (e.g., 'Ej5FZ+i7EtOkVkJmFBdAAA')
|
||||
* @returns UUID string (e.g., '123e4567-e89b-12d3-a456-426614174000')
|
||||
*/
|
||||
export function base64urlToUuid(encoded: string): string {
|
||||
const bytes = base64url.decode(encoded);
|
||||
const hex = Array.from(bytes)
|
||||
.map((b) => b.toString(16).padStart(2, "0"))
|
||||
.join("");
|
||||
|
||||
return [
|
||||
hex.slice(0, 8),
|
||||
hex.slice(8, 12),
|
||||
hex.slice(12, 16),
|
||||
hex.slice(16, 20),
|
||||
hex.slice(20),
|
||||
].join("-");
|
||||
}
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
import { createGameRecord } from "../core/Util";
|
||||
import { ServerConfig } from "../core/configuration/Config";
|
||||
import { getConfig } from "../core/configuration/ConfigLoader";
|
||||
import { Team, UnitType } from "../core/game/Game";
|
||||
import { Cell, Team, UnitType } from "../core/game/Game";
|
||||
import { TileRef } from "../core/game/GameMap";
|
||||
import {
|
||||
ErrorUpdate,
|
||||
@@ -82,7 +82,7 @@ export function joinLobby(
|
||||
if (message.type == "start") {
|
||||
// Trigger prestart for singleplayer games
|
||||
onPrestart();
|
||||
consolex.log(`lobby: game started: ${JSON.stringify(message)}`);
|
||||
consolex.log(`lobby: game started: ${JSON.stringify(message, null, 2)}`);
|
||||
onJoin();
|
||||
// For multiplayer games, GameStartInfo is not known until game starts.
|
||||
lobbyConfig.gameStartInfo = message.gameStartInfo;
|
||||
@@ -239,6 +239,7 @@ export class ClientGameRunner {
|
||||
this.lobby.gameStartInfo.gameID,
|
||||
this.lobby.clientID,
|
||||
);
|
||||
console.error(gu.stack);
|
||||
this.stop(true);
|
||||
return;
|
||||
}
|
||||
@@ -275,7 +276,6 @@ export class ClientGameRunner {
|
||||
while (turn.turnNumber - 1 > this.turnsSeen) {
|
||||
this.worker.sendTurn({
|
||||
turnNumber: this.turnsSeen,
|
||||
gameID: turn.gameID,
|
||||
intents: [],
|
||||
});
|
||||
this.turnsSeen++;
|
||||
@@ -353,7 +353,13 @@ export class ClientGameRunner {
|
||||
}
|
||||
}
|
||||
this.myPlayer.actions(tile).then((actions) => {
|
||||
console.log(`got actions: ${JSON.stringify(actions)}`);
|
||||
const bu = actions.buildableUnits.find(
|
||||
(bu) => bu.type == UnitType.TransportShip,
|
||||
);
|
||||
if (bu == null) {
|
||||
console.warn(`no transport ship buildable units`);
|
||||
return;
|
||||
}
|
||||
if (actions.canAttack) {
|
||||
this.eventBus.emit(
|
||||
new SendAttackIntentEvent(
|
||||
@@ -362,17 +368,29 @@ export class ClientGameRunner {
|
||||
),
|
||||
);
|
||||
} else if (
|
||||
actions.canBoat !== false &&
|
||||
this.shouldBoat(tile, actions.canBoat) &&
|
||||
bu.canBuild !== false &&
|
||||
this.shouldBoat(tile, bu.canBuild) &&
|
||||
this.gameView.isLand(tile)
|
||||
) {
|
||||
this.eventBus.emit(
|
||||
new SendBoatAttackIntentEvent(
|
||||
this.gameView.owner(tile).id(),
|
||||
cell,
|
||||
this.myPlayer.troops() * this.renderer.uiState.attackRatio,
|
||||
),
|
||||
);
|
||||
this.myPlayer
|
||||
.bestTransportShipSpawn(this.gameView.ref(cell.x, cell.y))
|
||||
.then((spawn: number | false) => {
|
||||
let spawnCell = null;
|
||||
if (spawn !== false) {
|
||||
spawnCell = new Cell(
|
||||
this.gameView.x(spawn),
|
||||
this.gameView.y(spawn),
|
||||
);
|
||||
}
|
||||
this.eventBus.emit(
|
||||
new SendBoatAttackIntentEvent(
|
||||
this.gameView.owner(tile).id(),
|
||||
cell,
|
||||
this.myPlayer.troops() * this.renderer.uiState.attackRatio,
|
||||
spawnCell,
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
const owner = this.gameView.owner(tile);
|
||||
|
||||
@@ -6,8 +6,10 @@ import { getServerConfigFromClient } from "../core/configuration/ConfigLoader";
|
||||
import { consolex } from "../core/Consolex";
|
||||
import {
|
||||
Difficulty,
|
||||
Duos,
|
||||
GameMapType,
|
||||
GameMode,
|
||||
UnitType,
|
||||
mapCategories,
|
||||
} from "../core/game/Game";
|
||||
import { GameConfig, GameInfo } from "../core/Schemas";
|
||||
@@ -28,7 +30,7 @@ export class HostLobbyModal extends LitElement {
|
||||
@state() private selectedDifficulty: Difficulty = Difficulty.Medium;
|
||||
@state() private disableNPCs = false;
|
||||
@state() private gameMode: GameMode = GameMode.FFA;
|
||||
@state() private teamCount: number = 2;
|
||||
@state() private teamCount: number | typeof Duos = 2;
|
||||
@state() private disableNukes: boolean = false;
|
||||
@state() private bots: number = 400;
|
||||
@state() private infiniteGold: boolean = false;
|
||||
@@ -38,6 +40,7 @@ export class HostLobbyModal extends LitElement {
|
||||
@state() private copySuccess = false;
|
||||
@state() private players: string[] = [];
|
||||
@state() private useRandomMap: boolean = false;
|
||||
@state() private disabledUnits: string[] = [];
|
||||
|
||||
private playersInterval = null;
|
||||
// Add a new timer for debouncing bot changes
|
||||
@@ -194,7 +197,7 @@ export class HostLobbyModal extends LitElement {
|
||||
${translateText("host_modal.team_count")}
|
||||
</div>
|
||||
<div class="option-cards">
|
||||
${[2, 3, 4, 5, 6, 7].map(
|
||||
${[Duos, 2, 3, 4, 5, 6, 7].map(
|
||||
(o) => html`
|
||||
<div
|
||||
class="option-card ${this.teamCount === o
|
||||
@@ -301,21 +304,72 @@ export class HostLobbyModal extends LitElement {
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<label
|
||||
for="disable-nukes"
|
||||
class="option-card ${this.disableNukes ? "selected" : ""}"
|
||||
<hr style="width: 100%; border-top: 1px solid #444; margin: 16px 0;" />
|
||||
|
||||
<!-- Individual disables for structures/weapons -->
|
||||
<div
|
||||
style="margin: 8px 0 12px 0; font-weight: bold; color: #ccc; text-align: center;"
|
||||
>
|
||||
<div class="checkbox-icon"></div>
|
||||
<input
|
||||
type="checkbox"
|
||||
id="disable-nukes"
|
||||
@change=${this.handleDisableNukesChange}
|
||||
.checked=${this.disableNukes}
|
||||
/>
|
||||
<div class="option-card-title">
|
||||
${translateText("host_modal.disable_nukes")}
|
||||
${translateText("host_modal.enables_title")}
|
||||
</div>
|
||||
<div
|
||||
style="display: flex; flex-wrap: wrap; justify-content: center; gap: 12px;"
|
||||
>
|
||||
${[
|
||||
[UnitType.City, "unit_type.city"],
|
||||
[UnitType.DefensePost, "unit_type.defense_post"],
|
||||
[UnitType.Port, "unit_type.port"],
|
||||
[UnitType.Warship, "unit_type.warship"],
|
||||
[UnitType.MissileSilo, "unit_type.missile_silo"],
|
||||
[UnitType.SAMLauncher, "unit_type.sam_launcher"],
|
||||
[UnitType.AtomBomb, "unit_type.atom_bomb"],
|
||||
[UnitType.HydrogenBomb, "unit_type.hydrogen_bomb"],
|
||||
[UnitType.MIRV, "unit_type.mirv"],
|
||||
].map(
|
||||
([unitType, translationKey]) => html`
|
||||
<label
|
||||
class="option-card ${this.disabledUnits.includes(
|
||||
unitType,
|
||||
)
|
||||
? ""
|
||||
: "selected"}"
|
||||
style="width: 140px;"
|
||||
>
|
||||
<div class="checkbox-icon"></div>
|
||||
<input
|
||||
type="checkbox"
|
||||
@change=${(e: Event) => {
|
||||
const checked = (e.target as HTMLInputElement)
|
||||
.checked;
|
||||
const parsedUnitType =
|
||||
UnitType[unitType as keyof typeof UnitType];
|
||||
if (parsedUnitType) {
|
||||
if (checked) {
|
||||
this.disabledUnits = [
|
||||
...this.disabledUnits,
|
||||
parsedUnitType,
|
||||
];
|
||||
} else {
|
||||
this.disabledUnits = this.disabledUnits.filter(
|
||||
(u) => u !== parsedUnitType,
|
||||
);
|
||||
}
|
||||
this.putGameConfig();
|
||||
}
|
||||
}}
|
||||
.checked=${this.disabledUnits.includes(unitType)}
|
||||
/>
|
||||
<div
|
||||
class="option-card-title"
|
||||
style="text-align: center;"
|
||||
>
|
||||
${translateText(translationKey)}
|
||||
</div>
|
||||
</label>
|
||||
`,
|
||||
)}
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -465,8 +519,8 @@ export class HostLobbyModal extends LitElement {
|
||||
this.putGameConfig();
|
||||
}
|
||||
|
||||
private async handleTeamCountSelection(value: number) {
|
||||
this.teamCount = value;
|
||||
private async handleTeamCountSelection(value: number | typeof Duos) {
|
||||
this.teamCount = value === Duos ? Duos : Number(value);
|
||||
this.putGameConfig();
|
||||
}
|
||||
|
||||
@@ -490,6 +544,8 @@ export class HostLobbyModal extends LitElement {
|
||||
instantBuild: this.instantBuild,
|
||||
gameMode: this.gameMode,
|
||||
numPlayerTeams: this.teamCount,
|
||||
disabledUnits: this.disabledUnits,
|
||||
playerTeams: this.teamCount,
|
||||
} as GameConfig),
|
||||
},
|
||||
);
|
||||
|
||||
+56
-63
@@ -113,6 +113,17 @@ export class InputHandler {
|
||||
) {}
|
||||
|
||||
initialize() {
|
||||
const keybinds = {
|
||||
toggleView: "Space",
|
||||
centerCamera: "KeyC",
|
||||
moveUp: "KeyW",
|
||||
moveDown: "KeyS",
|
||||
moveLeft: "KeyA",
|
||||
moveRight: "KeyD",
|
||||
zoomOut: "KeyQ",
|
||||
zoomIn: "KeyE",
|
||||
...JSON.parse(localStorage.getItem("settings.keybinds") ?? "{}"),
|
||||
};
|
||||
this.canvas.addEventListener("pointerdown", (e) => this.onPointerDown(e));
|
||||
window.addEventListener("pointerup", (e) => this.onPointerUp(e));
|
||||
this.canvas.addEventListener(
|
||||
@@ -122,59 +133,65 @@ export class InputHandler {
|
||||
this.onShiftScroll(e);
|
||||
e.preventDefault();
|
||||
},
|
||||
{
|
||||
passive: false,
|
||||
},
|
||||
{ passive: false },
|
||||
);
|
||||
window.addEventListener("pointermove", this.onPointerMove.bind(this));
|
||||
this.canvas.addEventListener("contextmenu", (e: MouseEvent) => {
|
||||
this.onContextMenu(e);
|
||||
});
|
||||
this.canvas.addEventListener("contextmenu", (e) => this.onContextMenu(e));
|
||||
window.addEventListener("mousemove", (e) => {
|
||||
if (e.movementX == 0 && e.movementY == 0) {
|
||||
return;
|
||||
if (e.movementX || e.movementY) {
|
||||
this.eventBus.emit(new MouseMoveEvent(e.clientX, e.clientY));
|
||||
}
|
||||
this.eventBus.emit(new MouseMoveEvent(e.clientX, e.clientY));
|
||||
});
|
||||
this.pointers.clear();
|
||||
|
||||
// Initialize the combined movement interval
|
||||
this.moveInterval = setInterval(() => {
|
||||
let deltaX = 0;
|
||||
let deltaY = 0;
|
||||
|
||||
// Handle both WASD and arrow keys
|
||||
if (this.activeKeys.has("KeyW") || this.activeKeys.has("ArrowUp"))
|
||||
if (
|
||||
this.activeKeys.has(keybinds.moveUp) ||
|
||||
this.activeKeys.has("ArrowUp")
|
||||
)
|
||||
deltaY += this.PAN_SPEED;
|
||||
if (this.activeKeys.has("KeyS") || this.activeKeys.has("ArrowDown"))
|
||||
if (
|
||||
this.activeKeys.has(keybinds.moveDown) ||
|
||||
this.activeKeys.has("ArrowDown")
|
||||
)
|
||||
deltaY -= this.PAN_SPEED;
|
||||
if (this.activeKeys.has("KeyA") || this.activeKeys.has("ArrowLeft"))
|
||||
if (
|
||||
this.activeKeys.has(keybinds.moveLeft) ||
|
||||
this.activeKeys.has("ArrowLeft")
|
||||
)
|
||||
deltaX += this.PAN_SPEED;
|
||||
if (this.activeKeys.has("KeyD") || this.activeKeys.has("ArrowRight"))
|
||||
if (
|
||||
this.activeKeys.has(keybinds.moveRight) ||
|
||||
this.activeKeys.has("ArrowRight")
|
||||
)
|
||||
deltaX -= this.PAN_SPEED;
|
||||
|
||||
if (deltaX !== 0 || deltaY !== 0) {
|
||||
if (deltaX || deltaY) {
|
||||
this.eventBus.emit(new DragEvent(deltaX, deltaY));
|
||||
}
|
||||
|
||||
// Handle zooming
|
||||
const screenCenterX = window.innerWidth / 2;
|
||||
const screenCenterY = window.innerHeight / 2;
|
||||
const cx = window.innerWidth / 2;
|
||||
const cy = window.innerHeight / 2;
|
||||
|
||||
if (this.activeKeys.has("Minus") || this.activeKeys.has("KeyQ")) {
|
||||
this.eventBus.emit(
|
||||
new ZoomEvent(screenCenterX, screenCenterY, this.ZOOM_SPEED),
|
||||
);
|
||||
if (
|
||||
this.activeKeys.has(keybinds.zoomOut) ||
|
||||
this.activeKeys.has("Minus")
|
||||
) {
|
||||
this.eventBus.emit(new ZoomEvent(cx, cy, this.ZOOM_SPEED));
|
||||
}
|
||||
if (this.activeKeys.has("Equal") || this.activeKeys.has("KeyE")) {
|
||||
this.eventBus.emit(
|
||||
new ZoomEvent(screenCenterX, screenCenterY, -this.ZOOM_SPEED),
|
||||
);
|
||||
if (
|
||||
this.activeKeys.has(keybinds.zoomIn) ||
|
||||
this.activeKeys.has("Equal")
|
||||
) {
|
||||
this.eventBus.emit(new ZoomEvent(cx, cy, -this.ZOOM_SPEED));
|
||||
}
|
||||
}, 1);
|
||||
|
||||
window.addEventListener("keydown", (e) => {
|
||||
if (e.code === "Space") {
|
||||
if (e.code === keybinds.toggleView) {
|
||||
e.preventDefault();
|
||||
if (!this.alternateView) {
|
||||
this.alternateView = true;
|
||||
@@ -187,24 +204,23 @@ export class InputHandler {
|
||||
this.eventBus.emit(new CloseViewEvent());
|
||||
}
|
||||
|
||||
// Add all movement keys to activeKeys
|
||||
if (
|
||||
[
|
||||
"KeyW",
|
||||
"KeyA",
|
||||
"KeyS",
|
||||
"KeyD",
|
||||
keybinds.moveUp,
|
||||
keybinds.moveDown,
|
||||
keybinds.moveLeft,
|
||||
keybinds.moveRight,
|
||||
keybinds.zoomOut,
|
||||
keybinds.zoomIn,
|
||||
"ArrowUp",
|
||||
"ArrowLeft",
|
||||
"ArrowDown",
|
||||
"ArrowRight",
|
||||
"Minus",
|
||||
"Equal",
|
||||
"KeyE",
|
||||
"KeyQ",
|
||||
"Digit1",
|
||||
"Digit2",
|
||||
"KeyC",
|
||||
keybinds.centerCamera,
|
||||
"ControlLeft",
|
||||
"ControlRight",
|
||||
].includes(e.code)
|
||||
@@ -212,13 +228,13 @@ export class InputHandler {
|
||||
this.activeKeys.add(e.code);
|
||||
}
|
||||
});
|
||||
|
||||
window.addEventListener("keyup", (e) => {
|
||||
if (e.code === "Space") {
|
||||
if (e.code === keybinds.toggleView) {
|
||||
e.preventDefault();
|
||||
this.alternateView = false;
|
||||
this.eventBus.emit(new AlternateViewEvent(false));
|
||||
}
|
||||
|
||||
if (e.key.toLowerCase() === "r" && e.altKey && !e.ctrlKey) {
|
||||
e.preventDefault();
|
||||
this.eventBus.emit(new RefreshGraphicsEvent());
|
||||
@@ -234,35 +250,12 @@ export class InputHandler {
|
||||
this.eventBus.emit(new AttackRatioEvent(10));
|
||||
}
|
||||
|
||||
if (e.code === "KeyC") {
|
||||
if (e.code === keybinds.centerCamera) {
|
||||
e.preventDefault();
|
||||
this.eventBus.emit(new CenterCameraEvent());
|
||||
}
|
||||
|
||||
// Remove all movement keys from activeKeys
|
||||
if (
|
||||
[
|
||||
"KeyW",
|
||||
"KeyA",
|
||||
"KeyS",
|
||||
"KeyD",
|
||||
"ArrowUp",
|
||||
"ArrowLeft",
|
||||
"ArrowDown",
|
||||
"ArrowRight",
|
||||
"Minus",
|
||||
"Equal",
|
||||
"KeyE",
|
||||
"KeyQ",
|
||||
"Digit1",
|
||||
"Digit2",
|
||||
"KeyC",
|
||||
"ControlLeft",
|
||||
"ControlRight",
|
||||
].includes(e.code)
|
||||
) {
|
||||
this.activeKeys.delete(e.code);
|
||||
}
|
||||
this.activeKeys.delete(e.code);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -174,6 +174,7 @@ export class LangSelector extends LitElement {
|
||||
"username-input",
|
||||
"public-lobby",
|
||||
"flag-input-modal",
|
||||
"user-setting",
|
||||
"o-modal",
|
||||
"o-button",
|
||||
];
|
||||
|
||||
@@ -127,7 +127,6 @@ export class LocalServer {
|
||||
}
|
||||
const pastTurn: Turn = {
|
||||
turnNumber: this.turns.length,
|
||||
gameID: this.lobbyConfig.gameStartInfo.gameID,
|
||||
intents: this.intents,
|
||||
};
|
||||
this.turns.push(pastTurn);
|
||||
|
||||
@@ -20,6 +20,7 @@ import { JoinPrivateLobbyModal } from "./JoinPrivateLobbyModal";
|
||||
import "./LangSelector";
|
||||
import { LangSelector } from "./LangSelector";
|
||||
import { LanguageModal } from "./LanguageModal";
|
||||
import { NewsModal } from "./NewsModal";
|
||||
import "./PublicLobby";
|
||||
import { PublicLobby } from "./PublicLobby";
|
||||
import { SinglePlayerModal } from "./SinglePlayerModal";
|
||||
@@ -27,8 +28,12 @@ import { UserSettingModal } from "./UserSettingModal";
|
||||
import "./UsernameInput";
|
||||
import { UsernameInput } from "./UsernameInput";
|
||||
import { generateCryptoRandomUUID } from "./Utils";
|
||||
import "./components/NewsButton";
|
||||
import { NewsButton } from "./components/NewsButton";
|
||||
import "./components/baseComponents/Button";
|
||||
import { OButton } from "./components/baseComponents/Button";
|
||||
import "./components/baseComponents/Modal";
|
||||
import { discordLogin, getUserMe, isLoggedIn, logOut } from "./jwt";
|
||||
import "./styles.css";
|
||||
|
||||
export interface JoinLobbyEvent {
|
||||
@@ -56,6 +61,23 @@ class Client {
|
||||
constructor() {}
|
||||
|
||||
initialize(): void {
|
||||
const newsModal = document.querySelector("news-modal") as NewsModal;
|
||||
if (!newsModal) {
|
||||
consolex.warn("News modal element not found");
|
||||
} else {
|
||||
consolex.log("News modal element found");
|
||||
}
|
||||
newsModal instanceof NewsModal;
|
||||
const newsButton = document.querySelector("news-button") as NewsButton;
|
||||
if (!newsButton) {
|
||||
consolex.warn("News button element not found");
|
||||
} else {
|
||||
consolex.log("News button element found");
|
||||
}
|
||||
|
||||
// Comment out to show news button.
|
||||
newsButton.hidden = true;
|
||||
|
||||
const langSelector = document.querySelector(
|
||||
"lang-selector",
|
||||
) as LangSelector;
|
||||
@@ -81,6 +103,13 @@ class Client {
|
||||
consolex.warn("Dark mode button element not found");
|
||||
}
|
||||
|
||||
const loginDiscordButton = document.getElementById(
|
||||
"login-discord",
|
||||
) as OButton;
|
||||
const logoutDiscordButton = document.getElementById(
|
||||
"logout-discord",
|
||||
) as OButton;
|
||||
|
||||
this.usernameInput = document.querySelector(
|
||||
"username-input",
|
||||
) as UsernameInput;
|
||||
@@ -114,6 +143,12 @@ class Client {
|
||||
}
|
||||
});
|
||||
|
||||
// const ctModal = document.querySelector("chat-modal") as ChatModal;
|
||||
// ctModal instanceof ChatModal;
|
||||
// document.getElementById("chat-button").addEventListener("click", () => {
|
||||
// ctModal.open();
|
||||
// });
|
||||
|
||||
const hlpModal = document.querySelector("help-modal") as HelpModal;
|
||||
hlpModal instanceof HelpModal;
|
||||
document.getElementById("help-button").addEventListener("click", () => {
|
||||
@@ -127,6 +162,41 @@ class Client {
|
||||
document.getElementById("flag-input_").addEventListener("click", () => {
|
||||
flagInputModal.open();
|
||||
});
|
||||
|
||||
const claims = isLoggedIn();
|
||||
if (claims === 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
|
||||
loginDiscordButton.disable = true;
|
||||
loginDiscordButton.translationKey = "main.logged_in";
|
||||
logoutDiscordButton.hidden = false;
|
||||
logoutDiscordButton.addEventListener("click", () => {
|
||||
// Log out
|
||||
logOut();
|
||||
loginDiscordButton.disable = false;
|
||||
loginDiscordButton.translationKey = "main.login_discord";
|
||||
loginDiscordButton.addEventListener("click", discordLogin);
|
||||
logoutDiscordButton.hidden = true;
|
||||
});
|
||||
// Look up the discord user object.
|
||||
// TODO: Add caching
|
||||
getUserMe().then((userMeResponse) => {
|
||||
if (userMeResponse === false) {
|
||||
// Not logged in
|
||||
loginDiscordButton.disable = false;
|
||||
loginDiscordButton.translationKey = "main.login_discord";
|
||||
loginDiscordButton.addEventListener("click", discordLogin);
|
||||
logoutDiscordButton.hidden = true;
|
||||
return;
|
||||
}
|
||||
// TODO: Update the page for logged in user
|
||||
});
|
||||
}
|
||||
|
||||
const settingsModal = document.querySelector(
|
||||
"user-setting",
|
||||
@@ -310,6 +380,11 @@ function setFavicon(): void {
|
||||
|
||||
// WARNING: DO NOT EXPOSE THIS ID
|
||||
export function getPersistentIDFromCookie(): string {
|
||||
const claims = isLoggedIn();
|
||||
if (claims !== false && claims.sub) {
|
||||
return claims.sub;
|
||||
}
|
||||
|
||||
const COOKIE_NAME = "player_persistent_id";
|
||||
|
||||
// Try to get existing cookie
|
||||
|
||||
@@ -1,130 +1,108 @@
|
||||
export class MultiTabDetector {
|
||||
private focusChanges: number[] = [];
|
||||
private readonly maxFocusChanges: number = 10;
|
||||
private readonly timeWindow: number = 60_000;
|
||||
private readonly punishmentDelays: number[] = [
|
||||
2_000, 3_000, 5_000, 10_000, 30_000, 60_000,
|
||||
];
|
||||
private lastFocusChangeTime: number = 0;
|
||||
private isPunished: boolean = false;
|
||||
private isMonitoring: boolean = false;
|
||||
private startPenaltyCallback?: (duration: number) => void;
|
||||
private readonly tabId = `${Date.now()}-${Math.random()}`;
|
||||
private readonly lockKey = "multi-tab-lock";
|
||||
private readonly heartbeatIntervalMs = 1_000;
|
||||
private readonly staleThresholdMs = 3_000;
|
||||
|
||||
private numPunishmentsGiven = 0;
|
||||
private heartbeatTimer: number | null = null;
|
||||
private isPunished = false;
|
||||
private punishmentCount = 0;
|
||||
private startPenaltyCallback: (duration: number) => void = () => {};
|
||||
|
||||
constructor() {
|
||||
window.addEventListener("storage", this.onStorageEvent.bind(this));
|
||||
window.addEventListener("beforeunload", this.onBeforeUnload.bind(this));
|
||||
}
|
||||
|
||||
/**
|
||||
* Start monitoring for multi-tabbing behavior
|
||||
*
|
||||
* @param startPenalty Callback function when punishment starts
|
||||
*/
|
||||
public startMonitoring(startPenalty: (duration: number) => void): void {
|
||||
if (this.isMonitoring) return;
|
||||
|
||||
this.isMonitoring = true;
|
||||
this.startPenaltyCallback = startPenalty;
|
||||
|
||||
// Event listeners for window focus/blur
|
||||
window.addEventListener("blur", this.handleFocusChange.bind(this));
|
||||
window.addEventListener("focus", this.handleFocusChange.bind(this));
|
||||
|
||||
// Also track visibility changes for tab switching
|
||||
document.addEventListener(
|
||||
"visibilitychange",
|
||||
this.handleVisibilityChange.bind(this),
|
||||
this.writeLock();
|
||||
this.heartbeatTimer = window.setInterval(
|
||||
() => this.heartbeat(),
|
||||
this.heartbeatIntervalMs,
|
||||
);
|
||||
}
|
||||
|
||||
public stopMonitoring(): void {
|
||||
if (!this.isMonitoring) return;
|
||||
|
||||
this.isMonitoring = false;
|
||||
|
||||
// Remove event listeners
|
||||
window.removeEventListener("blur", this.handleFocusChange.bind(this));
|
||||
window.removeEventListener("focus", this.handleFocusChange.bind(this));
|
||||
document.removeEventListener(
|
||||
"visibilitychange",
|
||||
this.handleVisibilityChange.bind(this),
|
||||
);
|
||||
|
||||
// Clear data
|
||||
this.focusChanges = [];
|
||||
this.isPunished = false;
|
||||
}
|
||||
|
||||
private handleFocusChange(): void {
|
||||
const currentTime = Date.now();
|
||||
|
||||
this.recordFocusChange(currentTime);
|
||||
|
||||
// Check for multi-tabbing when focus is gained
|
||||
if (document.hasFocus() && !this.isPunished) {
|
||||
this.checkForMultiTabbing(currentTime);
|
||||
if (this.heartbeatTimer !== null) {
|
||||
clearInterval(this.heartbeatTimer);
|
||||
this.heartbeatTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
private handleVisibilityChange(): void {
|
||||
const currentTime = Date.now();
|
||||
|
||||
// Record and check regardless of current focus state
|
||||
this.recordFocusChange(currentTime);
|
||||
|
||||
// Only check when tab becomes visible
|
||||
if (document.visibilityState === "visible" && !this.isPunished) {
|
||||
this.checkForMultiTabbing(currentTime);
|
||||
const lock = this.readLock();
|
||||
if (lock?.owner === this.tabId) {
|
||||
localStorage.removeItem(this.lockKey);
|
||||
}
|
||||
window.removeEventListener("storage", this.onStorageEvent.bind(this));
|
||||
window.removeEventListener("beforeunload", this.onBeforeUnload.bind(this));
|
||||
}
|
||||
|
||||
private recordFocusChange(timestamp: number): void {
|
||||
if (Math.abs(this.lastFocusChangeTime - timestamp) < 100) {
|
||||
// Don't count multiple triggers at same time
|
||||
private heartbeat(): void {
|
||||
const now = Date.now();
|
||||
const lock = this.readLock();
|
||||
|
||||
if (
|
||||
!lock ||
|
||||
lock.owner === this.tabId ||
|
||||
now - lock.timestamp > this.staleThresholdMs
|
||||
) {
|
||||
this.writeLock();
|
||||
this.isPunished = false;
|
||||
return;
|
||||
}
|
||||
this.focusChanges.push(timestamp);
|
||||
console.log(`pushing focus change at ${timestamp}`);
|
||||
this.lastFocusChangeTime = timestamp;
|
||||
|
||||
// Keep only recent changes
|
||||
if (this.focusChanges.length > this.maxFocusChanges) {
|
||||
this.focusChanges.shift();
|
||||
if (!this.isPunished) {
|
||||
this.applyPunishment();
|
||||
}
|
||||
}
|
||||
|
||||
private checkForMultiTabbing(currentTime: number): void {
|
||||
// Only if we have enough data points
|
||||
if (this.focusChanges.length >= this.maxFocusChanges) {
|
||||
const oldestChange = this.focusChanges[0];
|
||||
const timeSpan = currentTime - oldestChange;
|
||||
|
||||
// If changes happened within detection window
|
||||
if (timeSpan <= this.timeWindow) {
|
||||
private onStorageEvent(e: StorageEvent): void {
|
||||
if (e.key === this.lockKey && e.newValue) {
|
||||
let other: { owner: string; timestamp: number };
|
||||
try {
|
||||
other = JSON.parse(e.newValue);
|
||||
} catch (e) {
|
||||
console.error("Failed to parse lock", e);
|
||||
return;
|
||||
}
|
||||
if (other.owner !== this.tabId && !this.isPunished) {
|
||||
this.applyPunishment();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private onBeforeUnload(): void {
|
||||
const lock = this.readLock();
|
||||
if (lock?.owner === this.tabId) {
|
||||
localStorage.removeItem(this.lockKey);
|
||||
}
|
||||
}
|
||||
|
||||
private applyPunishment(): void {
|
||||
// Prevent multiple punishments
|
||||
if (this.isPunished) return;
|
||||
this.isPunished = true;
|
||||
|
||||
let punishmentDelay = 0;
|
||||
if (this.numPunishmentsGiven >= this.punishmentDelays.length) {
|
||||
punishmentDelay = this.punishmentDelays[this.punishmentDelays.length - 1];
|
||||
} else {
|
||||
punishmentDelay = this.punishmentDelays[this.numPunishmentsGiven];
|
||||
}
|
||||
|
||||
this.numPunishmentsGiven++;
|
||||
|
||||
// Call the start penalty callback
|
||||
if (this.startPenaltyCallback) {
|
||||
this.startPenaltyCallback(punishmentDelay);
|
||||
}
|
||||
|
||||
// Remove penalty after delay
|
||||
this.punishmentCount++;
|
||||
const delay = 10_000;
|
||||
this.startPenaltyCallback(delay);
|
||||
setTimeout(() => {
|
||||
this.isPunished = false;
|
||||
}, punishmentDelay);
|
||||
}, delay);
|
||||
}
|
||||
|
||||
private writeLock(): void {
|
||||
localStorage.setItem(
|
||||
this.lockKey,
|
||||
JSON.stringify({ owner: this.tabId, timestamp: Date.now() }),
|
||||
);
|
||||
}
|
||||
|
||||
private readLock(): { owner: string; timestamp: number } | null {
|
||||
const raw = localStorage.getItem(this.lockKey);
|
||||
if (!raw) return null;
|
||||
try {
|
||||
return JSON.parse(raw);
|
||||
} catch (e) {
|
||||
console.error("Failed to parse lock", raw, e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import { LitElement, css, html } from "lit";
|
||||
import { customElement, query } from "lit/decorators.js";
|
||||
import { translateText } from "../client/Utils";
|
||||
import "./components/baseComponents/Button";
|
||||
import "./components/baseComponents/Modal";
|
||||
|
||||
@customElement("news-modal")
|
||||
export class NewsModal extends LitElement {
|
||||
@query("o-modal") private modalEl!: HTMLElement & {
|
||||
open: () => void;
|
||||
close: () => void;
|
||||
};
|
||||
|
||||
static styles = css`
|
||||
.news-container {
|
||||
max-height: 60vh;
|
||||
overflow-y: auto;
|
||||
padding: 1rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.news-content {
|
||||
color: #ddd;
|
||||
line-height: 1.5;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border-radius: 8px;
|
||||
padding: 1rem;
|
||||
}
|
||||
`;
|
||||
|
||||
render() {
|
||||
return html`
|
||||
<o-modal title=${translateText("news.title")}>
|
||||
<div class="options-layout">
|
||||
<div class="options-section">
|
||||
<div class="news-container">
|
||||
<div class="news-content">INSERT NEWS HERE</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<o-button
|
||||
title=${translateText("common.close")}
|
||||
@click=${this.close}
|
||||
blockDesktop
|
||||
></o-button>
|
||||
</o-modal>
|
||||
`;
|
||||
}
|
||||
|
||||
public open() {
|
||||
this.requestUpdate();
|
||||
this.modalEl?.open();
|
||||
}
|
||||
|
||||
private close() {
|
||||
this.modalEl?.close();
|
||||
}
|
||||
|
||||
createRenderRoot() {
|
||||
return this; // light DOM
|
||||
}
|
||||
}
|
||||
@@ -94,6 +94,11 @@ export class PublicLobby extends LitElement {
|
||||
const playersRemainingBeforeMax =
|
||||
lobby.gameConfig.maxPlayers - lobby.numClients;
|
||||
|
||||
const teamCount =
|
||||
lobby.gameConfig.gameMode === GameMode.Team
|
||||
? lobby.gameConfig.playerTeams || 0
|
||||
: null;
|
||||
|
||||
return html`
|
||||
<button
|
||||
@click=${() => this.lobbyClicked(lobby)}
|
||||
@@ -127,7 +132,7 @@ export class PublicLobby extends LitElement {
|
||||
</div>
|
||||
<div class="text-md font-medium text-blue-100">
|
||||
${lobby.gameConfig.gameMode == GameMode.Team
|
||||
? translateText("game_mode.teams")
|
||||
? translateText("public_lobby.teams", { num: teamCount })
|
||||
: translateText("game_mode.ffa")}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -5,9 +5,11 @@ import { translateText } from "../client/Utils";
|
||||
import { consolex } from "../core/Consolex";
|
||||
import {
|
||||
Difficulty,
|
||||
Duos,
|
||||
GameMapType,
|
||||
GameMode,
|
||||
GameType,
|
||||
UnitType,
|
||||
mapCategories,
|
||||
} from "../core/game/Game";
|
||||
import { generateID } from "../core/Util";
|
||||
@@ -36,7 +38,9 @@ export class SinglePlayerModal extends LitElement {
|
||||
@state() private instantBuild: boolean = false;
|
||||
@state() private useRandomMap: boolean = false;
|
||||
@state() private gameMode: GameMode = GameMode.FFA;
|
||||
@state() private teamCount: number = 2;
|
||||
@state() private teamCount: number | typeof Duos = 2;
|
||||
|
||||
@state() private disabledUnits: string[] = [];
|
||||
|
||||
render() {
|
||||
return html`
|
||||
@@ -165,7 +169,7 @@ export class SinglePlayerModal extends LitElement {
|
||||
${translateText("host_modal.team_count")}
|
||||
</div>
|
||||
<div class="option-cards">
|
||||
${[2, 3, 4, 5, 6, 7].map(
|
||||
${["Duos", 2, 3, 4, 5, 6, 7].map(
|
||||
(o) => html`
|
||||
<div
|
||||
class="option-card ${this.teamCount === o
|
||||
@@ -268,22 +272,61 @@ export class SinglePlayerModal extends LitElement {
|
||||
${translateText("single_modal.infinite_troops")}
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<label
|
||||
for="singleplayer-modal-disable-nukes"
|
||||
class="option-card ${this.disableNukes ? "selected" : ""}"
|
||||
>
|
||||
<div class="checkbox-icon"></div>
|
||||
<input
|
||||
type="checkbox"
|
||||
id="singleplayer-modal-disable-nukes"
|
||||
@change=${this.handleDisableNukesChange}
|
||||
.checked=${this.disableNukes}
|
||||
/>
|
||||
<div class="option-card-title">
|
||||
${translateText("single_modal.disable_nukes")}
|
||||
</div>
|
||||
</label>
|
||||
<hr
|
||||
style="width: 100%; border-top: 1px solid #444; margin: 16px 0;"
|
||||
/>
|
||||
<div
|
||||
style="margin: 8px 0 12px 0; font-weight: bold; color: #ccc; text-align: center;"
|
||||
>
|
||||
${translateText("single_modal.enables_title")}
|
||||
</div>
|
||||
<div
|
||||
style="display: flex; flex-wrap: wrap; justify-content: center; gap: 12px;"
|
||||
>
|
||||
${[
|
||||
[UnitType.City, "unit_type.city"],
|
||||
[UnitType.DefensePost, "unit_type.defense_post"],
|
||||
[UnitType.Port, "unit_type.port"],
|
||||
[UnitType.Warship, "unit_type.warship"],
|
||||
[UnitType.MissileSilo, "unit_type.missile_silo"],
|
||||
[UnitType.SAMLauncher, "unit_type.sam_launcher"],
|
||||
[UnitType.AtomBomb, "unit_type.atom_bomb"],
|
||||
[UnitType.HydrogenBomb, "unit_type.hydrogen_bomb"],
|
||||
[UnitType.MIRV, "unit_type.mirv"],
|
||||
].map(
|
||||
([unitType, translationKey]) => html`
|
||||
<label
|
||||
class="option-card ${this.disabledUnits.includes(unitType)
|
||||
? ""
|
||||
: "selected"}"
|
||||
style="width: 140px;"
|
||||
>
|
||||
<div class="checkbox-icon"></div>
|
||||
<input
|
||||
type="checkbox"
|
||||
@change=${(e: Event) => {
|
||||
const checked = (e.target as HTMLInputElement).checked;
|
||||
if (checked) {
|
||||
this.disabledUnits = [
|
||||
...this.disabledUnits,
|
||||
unitType,
|
||||
];
|
||||
} else {
|
||||
this.disabledUnits = this.disabledUnits.filter(
|
||||
(u) => u !== unitType,
|
||||
);
|
||||
}
|
||||
}}
|
||||
.checked=${this.disabledUnits.includes(unitType)}
|
||||
/>
|
||||
<div class="option-card-title" style="text-align: center;">
|
||||
${translateText(translationKey)}
|
||||
</div>
|
||||
</label>
|
||||
`,
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -355,8 +398,8 @@ export class SinglePlayerModal extends LitElement {
|
||||
this.gameMode = value;
|
||||
}
|
||||
|
||||
private handleTeamCountSelection(value: number) {
|
||||
this.teamCount = value;
|
||||
private handleTeamCountSelection(value: number | string) {
|
||||
this.teamCount = value === "Duos" ? Duos : Number(value);
|
||||
}
|
||||
|
||||
private getRandomMap(): GameMapType {
|
||||
@@ -410,7 +453,7 @@ export class SinglePlayerModal extends LitElement {
|
||||
gameMap: this.selectedMap,
|
||||
gameType: GameType.Singleplayer,
|
||||
gameMode: this.gameMode,
|
||||
numPlayerTeams: this.teamCount,
|
||||
playerTeams: this.teamCount,
|
||||
difficulty: this.selectedDifficulty,
|
||||
disableNPCs: this.disableNPCs,
|
||||
disableNukes: this.disableNukes,
|
||||
@@ -418,6 +461,7 @@ export class SinglePlayerModal extends LitElement {
|
||||
infiniteGold: this.infiniteGold,
|
||||
infiniteTroops: this.infiniteTroops,
|
||||
instantBuild: this.instantBuild,
|
||||
disabledUnits: this.disabledUnits,
|
||||
},
|
||||
},
|
||||
} as JoinLobbyEvent,
|
||||
|
||||
+61
-58
@@ -13,13 +13,13 @@ import {
|
||||
import { PlayerView } from "../core/game/GameView";
|
||||
import {
|
||||
AllPlayersStats,
|
||||
ClientHashMessage,
|
||||
ClientID,
|
||||
ClientIntentMessageSchema,
|
||||
ClientJoinMessageSchema,
|
||||
ClientLogMessageSchema,
|
||||
ClientMessageSchema,
|
||||
ClientPingMessageSchema,
|
||||
ClientSendWinnerSchema,
|
||||
ClientIntentMessage,
|
||||
ClientJoinMessage,
|
||||
ClientLogMessage,
|
||||
ClientPingMessage,
|
||||
ClientSendWinnerMessage,
|
||||
Intent,
|
||||
ServerMessage,
|
||||
ServerMessageSchema,
|
||||
@@ -68,8 +68,9 @@ export class SendAttackIntentEvent implements GameEvent {
|
||||
export class SendBoatAttackIntentEvent implements GameEvent {
|
||||
constructor(
|
||||
public readonly targetID: PlayerID,
|
||||
public readonly cell: Cell,
|
||||
public readonly dst: Cell,
|
||||
public readonly troops: number,
|
||||
public readonly src: Cell | null = null,
|
||||
) {}
|
||||
}
|
||||
|
||||
@@ -87,7 +88,7 @@ export class SendTargetPlayerIntentEvent implements GameEvent {
|
||||
export class SendEmojiIntentEvent implements GameEvent {
|
||||
constructor(
|
||||
public readonly recipient: PlayerView | typeof AllPlayers,
|
||||
public readonly emoji: string,
|
||||
public readonly emoji: number,
|
||||
) {}
|
||||
}
|
||||
|
||||
@@ -107,6 +108,15 @@ export class SendDonateTroopsIntentEvent implements GameEvent {
|
||||
) {}
|
||||
}
|
||||
|
||||
export class SendQuickChatEvent implements GameEvent {
|
||||
constructor(
|
||||
public readonly sender: PlayerView,
|
||||
public readonly recipient: PlayerView,
|
||||
public readonly quickChatKey: string,
|
||||
public readonly variables: { [key: string]: string },
|
||||
) {}
|
||||
}
|
||||
|
||||
export class SendEmbargoIntentEvent implements GameEvent {
|
||||
constructor(
|
||||
public readonly sender: PlayerView,
|
||||
@@ -195,6 +205,7 @@ export class Transport {
|
||||
this.eventBus.on(SendDonateTroopsIntentEvent, (e) =>
|
||||
this.onSendDonateTroopIntent(e),
|
||||
);
|
||||
this.eventBus.on(SendQuickChatEvent, (e) => this.onSendQuickChatIntent(e));
|
||||
this.eventBus.on(SendEmbargoIntentEvent, (e) =>
|
||||
this.onSendEmbargoIntent(e),
|
||||
);
|
||||
@@ -221,14 +232,9 @@ export class Transport {
|
||||
this.pingInterval = window.setInterval(() => {
|
||||
if (this.socket != null && this.socket.readyState === WebSocket.OPEN) {
|
||||
this.sendMsg(
|
||||
JSON.stringify(
|
||||
ClientPingMessageSchema.parse({
|
||||
type: "ping",
|
||||
clientID: this.lobbyConfig.clientID,
|
||||
persistentID: this.lobbyConfig.persistentID,
|
||||
gameID: this.lobbyConfig.gameID,
|
||||
}),
|
||||
),
|
||||
JSON.stringify({
|
||||
type: "ping",
|
||||
} satisfies ClientPingMessage),
|
||||
);
|
||||
}
|
||||
}, 5 * 1000);
|
||||
@@ -314,32 +320,25 @@ export class Transport {
|
||||
|
||||
private onSendLogEvent(event: SendLogEvent) {
|
||||
this.sendMsg(
|
||||
JSON.stringify(
|
||||
ClientLogMessageSchema.parse({
|
||||
type: "log",
|
||||
gameID: this.lobbyConfig.gameID,
|
||||
clientID: this.lobbyConfig.clientID,
|
||||
persistentID: this.lobbyConfig.persistentID,
|
||||
log: event.log,
|
||||
severity: event.severity,
|
||||
}),
|
||||
),
|
||||
JSON.stringify({
|
||||
type: "log",
|
||||
log: event.log,
|
||||
severity: event.severity,
|
||||
} satisfies ClientLogMessage),
|
||||
);
|
||||
}
|
||||
|
||||
joinGame(numTurns: number) {
|
||||
this.sendMsg(
|
||||
JSON.stringify(
|
||||
ClientJoinMessageSchema.parse({
|
||||
type: "join",
|
||||
gameID: this.lobbyConfig.gameID,
|
||||
clientID: this.lobbyConfig.clientID,
|
||||
lastTurn: numTurns,
|
||||
persistentID: this.lobbyConfig.persistentID,
|
||||
username: this.lobbyConfig.playerName,
|
||||
flag: this.lobbyConfig.flag,
|
||||
}),
|
||||
),
|
||||
JSON.stringify({
|
||||
type: "join",
|
||||
gameID: this.lobbyConfig.gameID,
|
||||
clientID: this.lobbyConfig.clientID,
|
||||
lastTurn: numTurns,
|
||||
persistentID: this.lobbyConfig.persistentID,
|
||||
username: this.lobbyConfig.playerName,
|
||||
flag: this.lobbyConfig.flag,
|
||||
} satisfies ClientJoinMessage),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -414,8 +413,10 @@ export class Transport {
|
||||
clientID: this.lobbyConfig.clientID,
|
||||
targetID: event.targetID,
|
||||
troops: event.troops,
|
||||
x: event.cell.x,
|
||||
y: event.cell.y,
|
||||
dstX: event.dst.x,
|
||||
dstY: event.dst.y,
|
||||
srcX: event.src?.x,
|
||||
srcY: event.src?.y,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -455,6 +456,16 @@ export class Transport {
|
||||
});
|
||||
}
|
||||
|
||||
private onSendQuickChatIntent(event: SendQuickChatEvent) {
|
||||
this.sendIntent({
|
||||
type: "quick_chat",
|
||||
clientID: this.lobbyConfig.clientID,
|
||||
recipient: event.recipient.id(),
|
||||
quickChatKey: event.quickChatKey,
|
||||
variables: event.variables,
|
||||
});
|
||||
}
|
||||
|
||||
private onSendEmbargoIntent(event: SendEmbargoIntentEvent) {
|
||||
this.sendIntent({
|
||||
type: "embargo",
|
||||
@@ -496,15 +507,12 @@ export class Transport {
|
||||
|
||||
private onSendWinnerEvent(event: SendWinnerEvent) {
|
||||
if (this.isLocal || this.socket.readyState === WebSocket.OPEN) {
|
||||
const msg = ClientSendWinnerSchema.parse({
|
||||
const msg = {
|
||||
type: "winner",
|
||||
clientID: this.lobbyConfig.clientID,
|
||||
persistentID: this.lobbyConfig.persistentID,
|
||||
gameID: this.lobbyConfig.gameID,
|
||||
winner: event.winner,
|
||||
allPlayersStats: event.allPlayersStats,
|
||||
winnerType: event.winnerType,
|
||||
});
|
||||
} satisfies ClientSendWinnerMessage;
|
||||
this.sendMsg(JSON.stringify(msg));
|
||||
} else {
|
||||
console.log(
|
||||
@@ -517,15 +525,13 @@ export class Transport {
|
||||
|
||||
private onSendHashEvent(event: SendHashEvent) {
|
||||
if (this.isLocal || this.socket.readyState === WebSocket.OPEN) {
|
||||
const msg = ClientMessageSchema.parse({
|
||||
type: "hash",
|
||||
clientID: this.lobbyConfig.clientID,
|
||||
persistentID: this.lobbyConfig.persistentID,
|
||||
gameID: this.lobbyConfig.gameID,
|
||||
turnNumber: event.tick,
|
||||
hash: event.hash,
|
||||
});
|
||||
this.sendMsg(JSON.stringify(msg));
|
||||
this.sendMsg(
|
||||
JSON.stringify({
|
||||
type: "hash",
|
||||
turnNumber: event.tick,
|
||||
hash: event.hash,
|
||||
} satisfies ClientHashMessage),
|
||||
);
|
||||
} else {
|
||||
console.log(
|
||||
"WebSocket is not open. Current state:",
|
||||
@@ -554,13 +560,10 @@ export class Transport {
|
||||
|
||||
private sendIntent(intent: Intent) {
|
||||
if (this.isLocal || this.socket.readyState === WebSocket.OPEN) {
|
||||
const msg = ClientIntentMessageSchema.parse({
|
||||
const msg = {
|
||||
type: "intent",
|
||||
clientID: this.lobbyConfig.clientID,
|
||||
persistentID: this.lobbyConfig.persistentID,
|
||||
gameID: this.lobbyConfig.gameID,
|
||||
intent: intent,
|
||||
});
|
||||
} satisfies ClientIntentMessage;
|
||||
this.sendMsg(JSON.stringify(msg));
|
||||
} else {
|
||||
console.log(
|
||||
|
||||
+271
-86
@@ -1,6 +1,9 @@
|
||||
import { LitElement, html } from "lit";
|
||||
import { customElement, query, state } from "lit/decorators.js";
|
||||
import { translateText } from "../client/Utils";
|
||||
import { UserSettings } from "../core/game/UserSettings";
|
||||
import "./components/baseComponents/setting/SettingKeybind";
|
||||
import { SettingKeybind } from "./components/baseComponents/setting/SettingKeybind";
|
||||
import "./components/baseComponents/setting/SettingNumber";
|
||||
import "./components/baseComponents/setting/SettingSlider";
|
||||
import "./components/baseComponents/setting/SettingToggle";
|
||||
@@ -9,7 +12,8 @@ import "./components/baseComponents/setting/SettingToggle";
|
||||
export class UserSettingModal extends LitElement {
|
||||
private userSettings: UserSettings = new UserSettings();
|
||||
|
||||
@state() private darkMode: boolean = this.userSettings.darkMode();
|
||||
@state() private settingsMode: "basic" | "keybinds" = "basic";
|
||||
@state() private keybinds: Record<string, string> = {};
|
||||
|
||||
@state() private keySequence: string[] = [];
|
||||
@state() private showEasterEggSettings = false;
|
||||
@@ -17,6 +21,15 @@ export class UserSettingModal extends LitElement {
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
window.addEventListener("keydown", this.handleKeyDown);
|
||||
|
||||
const savedKeybinds = localStorage.getItem("settings.keybinds");
|
||||
if (savedKeybinds) {
|
||||
try {
|
||||
this.keybinds = JSON.parse(savedKeybinds);
|
||||
} catch (e) {
|
||||
console.warn("Invalid keybinds JSON:", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@query("o-modal") private modalEl!: HTMLElement & {
|
||||
@@ -89,6 +102,15 @@ export class UserSettingModal extends LitElement {
|
||||
console.log("🤡 Emojis:", enabled ? "ON" : "OFF");
|
||||
}
|
||||
|
||||
private toggleAnonymousNames(e: CustomEvent<{ checked: boolean }>) {
|
||||
const enabled = e.detail?.checked;
|
||||
if (typeof enabled !== "boolean") return;
|
||||
|
||||
this.userSettings.set("settings.anonymousNames", enabled);
|
||||
|
||||
console.log("🙈 Anonymous Names:", enabled ? "ON" : "OFF");
|
||||
}
|
||||
|
||||
private toggleLeftClickOpensMenu(e: CustomEvent<{ checked: boolean }>) {
|
||||
const enabled = e.detail?.checked;
|
||||
if (typeof enabled !== "boolean") return;
|
||||
@@ -119,96 +141,63 @@ export class UserSettingModal extends LitElement {
|
||||
}
|
||||
}
|
||||
|
||||
private handleKeybindChange(
|
||||
e: CustomEvent<{ action: string; value: string }>,
|
||||
) {
|
||||
const { action, value } = e.detail;
|
||||
const prevValue = this.keybinds[action] ?? "";
|
||||
|
||||
const values = Object.entries(this.keybinds)
|
||||
.filter(([k]) => k !== action)
|
||||
.map(([, v]) => v);
|
||||
if (values.includes(value) && value !== "Null") {
|
||||
const popup = document.createElement("div");
|
||||
popup.className = "setting-popup";
|
||||
popup.textContent = `The key "${value}" is already assigned to another action.`;
|
||||
document.body.appendChild(popup);
|
||||
const element = this.renderRoot.querySelector(
|
||||
`setting-keybind[action="${action}"]`,
|
||||
) as SettingKeybind;
|
||||
if (element) {
|
||||
element.value = prevValue;
|
||||
element.requestUpdate();
|
||||
}
|
||||
return;
|
||||
}
|
||||
this.keybinds = { ...this.keybinds, [action]: value };
|
||||
localStorage.setItem("settings.keybinds", JSON.stringify(this.keybinds));
|
||||
}
|
||||
|
||||
render() {
|
||||
return html`
|
||||
<o-modal title="User Settings">
|
||||
<o-modal title="${translateText("user_setting.title")}">
|
||||
<div class="modal-overlay">
|
||||
<div class="modal-content user-setting-modal">
|
||||
<div class="flex mb-4 w-full justify-center">
|
||||
<button
|
||||
class="w-1/2 text-center px-3 py-1 rounded-l
|
||||
${this.settingsMode === "basic"
|
||||
? "bg-white/10 text-white"
|
||||
: "bg-transparent text-gray-400"}"
|
||||
@click=${() => (this.settingsMode = "basic")}
|
||||
>
|
||||
${translateText("user_setting.tab_basic")}
|
||||
</button>
|
||||
<button
|
||||
class="w-1/2 text-center px-3 py-1 rounded-r
|
||||
${this.settingsMode === "keybinds"
|
||||
? "bg-white/10 text-white"
|
||||
: "bg-transparent text-gray-400"}"
|
||||
@click=${() => (this.settingsMode = "keybinds")}
|
||||
>
|
||||
${translateText("user_setting.tab_keybinds")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="settings-list">
|
||||
<setting-toggle
|
||||
label="🌙 Dark Mode"
|
||||
description="Toggle the site’s appearance between light and dark themes"
|
||||
id="dark-mode-toggle"
|
||||
.checked=${this.userSettings.darkMode()}
|
||||
@change=${(e: CustomEvent<{ checked: boolean }>) =>
|
||||
this.toggleDarkMode(e)}
|
||||
></setting-toggle>
|
||||
|
||||
<setting-toggle
|
||||
label="😊 Emojis"
|
||||
description="Toggle whether emojis are shown in game"
|
||||
id="emoji-toggle"
|
||||
.checked=${this.userSettings.emojis()}
|
||||
@change=${this.toggleEmojis}
|
||||
></setting-toggle>
|
||||
|
||||
<setting-toggle
|
||||
label="🖱️ Left Click to Open Menu"
|
||||
description="When ON, left-click opens menu and sword button attacks. When OFF, right-click attacks directly."
|
||||
id="left-click-toggle"
|
||||
.checked=${this.userSettings.leftClickOpensMenu()}
|
||||
@change=${this.toggleLeftClickOpensMenu}
|
||||
></setting-toggle>
|
||||
|
||||
<setting-slider
|
||||
label="⚔️ Attack Ratio"
|
||||
description="What percentage of your troops to send in an attack (1–100%)"
|
||||
min="1"
|
||||
max="100"
|
||||
.value=${Number(
|
||||
localStorage.getItem("settings.attackRatio") ?? "0.2",
|
||||
) * 100}
|
||||
@change=${this.sliderAttackRatio}
|
||||
></setting-slider>
|
||||
|
||||
<setting-slider
|
||||
label="🪖🛠️ Troops and Workers Ratio"
|
||||
description="Adjust the balance between troops (for combat) and workers (for gold production) (1–100%)"
|
||||
min="1"
|
||||
max="100"
|
||||
.value=${Number(
|
||||
localStorage.getItem("settings.troopRatio") ?? "0.95",
|
||||
) * 100}
|
||||
@change=${this.sliderTroopRatio}
|
||||
></setting-slider>
|
||||
|
||||
${this.showEasterEggSettings
|
||||
? html`
|
||||
<setting-slider
|
||||
label="Writing Speed Multiplier"
|
||||
description="Adjust how fast you pretend to code (x1–x100)"
|
||||
min="0"
|
||||
max="100"
|
||||
value="40"
|
||||
easter="true"
|
||||
@change=${(e: CustomEvent) => {
|
||||
const value = e.detail?.value;
|
||||
if (typeof value !== "undefined") {
|
||||
console.log("Changed:", value);
|
||||
} else {
|
||||
console.warn("Slider event missing detail.value", e);
|
||||
}
|
||||
}}
|
||||
></setting-slider>
|
||||
|
||||
<setting-number
|
||||
label="Bug Count"
|
||||
description="How many bugs you're okay with (0–1000, emotionally)"
|
||||
value="100"
|
||||
min="0"
|
||||
max="1000"
|
||||
easter="true"
|
||||
@change=${(e: CustomEvent) => {
|
||||
const value = e.detail?.value;
|
||||
if (typeof value !== "undefined") {
|
||||
console.log("Changed:", value);
|
||||
} else {
|
||||
console.warn("Slider event missing detail.value", e);
|
||||
}
|
||||
}}
|
||||
></setting-number>
|
||||
`
|
||||
: null}
|
||||
${this.settingsMode === "basic"
|
||||
? this.renderBasicSettings()
|
||||
: this.renderKeybindSettings()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -216,7 +205,203 @@ export class UserSettingModal extends LitElement {
|
||||
`;
|
||||
}
|
||||
|
||||
private renderBasicSettings() {
|
||||
return html`
|
||||
<!-- 🌙 Dark Mode -->
|
||||
<setting-toggle
|
||||
label="${translateText("user_setting.dark_mode_label")}"
|
||||
description="${translateText("user_setting.dark_mode_desc")}"
|
||||
id="dark-mode-toggle"
|
||||
.checked=${this.userSettings.darkMode()}
|
||||
@change=${(e: CustomEvent<{ checked: boolean }>) =>
|
||||
this.toggleDarkMode(e)}
|
||||
></setting-toggle>
|
||||
|
||||
<!-- 😊 Emojis -->
|
||||
<setting-toggle
|
||||
label="${translateText("user_setting.emojis_label")}"
|
||||
description="${translateText("user_setting.emojis_desc")}"
|
||||
id="emoji-toggle"
|
||||
.checked=${this.userSettings.emojis()}
|
||||
@change=${this.toggleEmojis}
|
||||
></setting-toggle>
|
||||
|
||||
<!-- 🖱️ Left Click Menu -->
|
||||
<setting-toggle
|
||||
label="${translateText("user_setting.left_click_label")}"
|
||||
description="${translateText("user_setting.left_click_desc")}"
|
||||
id="left-click-toggle"
|
||||
.checked=${this.userSettings.leftClickOpensMenu()}
|
||||
@change=${this.toggleLeftClickOpensMenu}
|
||||
></setting-toggle>
|
||||
|
||||
<!-- 🙈 Anonymous Names -->
|
||||
<setting-toggle
|
||||
label="${translateText("user_setting.anonymous_names_label")}"
|
||||
description="${translateText("user_setting.anonymous_names_desc")}"
|
||||
id="anonymous-names-toggle"
|
||||
.checked=${this.userSettings.anonymousNames()}
|
||||
@change=${this.toggleAnonymousNames}
|
||||
></setting-toggle>
|
||||
|
||||
<!-- ⚔️ Attack Ratio -->
|
||||
<setting-slider
|
||||
label="${translateText("user_setting.attack_ratio_label")}"
|
||||
description="${translateText("user_setting.attack_ratio_desc")}"
|
||||
min="1"
|
||||
max="100"
|
||||
.value=${Number(localStorage.getItem("settings.attackRatio") ?? "0.2") *
|
||||
100}
|
||||
@change=${this.sliderAttackRatio}
|
||||
></setting-slider>
|
||||
|
||||
<!-- 🪖🛠️ Troop Ratio -->
|
||||
<setting-slider
|
||||
label="${translateText("user_setting.troop_ratio_label")}"
|
||||
description="${translateText("user_setting.troop_ratio_desc")}"
|
||||
min="1"
|
||||
max="100"
|
||||
.value=${Number(localStorage.getItem("settings.troopRatio") ?? "0.95") *
|
||||
100}
|
||||
@change=${this.sliderTroopRatio}
|
||||
></setting-slider>
|
||||
|
||||
${this.showEasterEggSettings
|
||||
? html`
|
||||
<setting-slider
|
||||
label="${translateText(
|
||||
"user_setting.easter_writing_speed_label",
|
||||
)}"
|
||||
description="${translateText(
|
||||
"user_setting.easter_writing_speed_desc",
|
||||
)}"
|
||||
min="0"
|
||||
max="100"
|
||||
value="40"
|
||||
easter="true"
|
||||
@change=${(e: CustomEvent) => {
|
||||
const value = e.detail?.value;
|
||||
if (typeof value !== "undefined") {
|
||||
console.log("Changed:", value);
|
||||
} else {
|
||||
console.warn("Slider event missing detail.value", e);
|
||||
}
|
||||
}}
|
||||
></setting-slider>
|
||||
|
||||
<setting-number
|
||||
label="${translateText("user_setting.easter_bug_count_label")}"
|
||||
description="${translateText(
|
||||
"user_setting.easter_bug_count_desc",
|
||||
)}"
|
||||
value="100"
|
||||
min="0"
|
||||
max="1000"
|
||||
easter="true"
|
||||
@change=${(e: CustomEvent) => {
|
||||
const value = e.detail?.value;
|
||||
if (typeof value !== "undefined") {
|
||||
console.log("Changed:", value);
|
||||
} else {
|
||||
console.warn("Slider event missing detail.value", e);
|
||||
}
|
||||
}}
|
||||
></setting-number>
|
||||
`
|
||||
: null}
|
||||
`;
|
||||
}
|
||||
|
||||
private renderKeybindSettings() {
|
||||
return html`
|
||||
<div class="text-center text-white text-base font-semibold mt-5 mb-2">
|
||||
${translateText("user_setting.view_options")}
|
||||
</div>
|
||||
|
||||
<setting-keybind
|
||||
action="toggleView"
|
||||
label=${translateText("user_setting.toggle_view")}
|
||||
description=${translateText("user_setting.toggle_view_desc")}
|
||||
defaultKey="Space"
|
||||
.value=${this.keybinds["toggleView"] ?? ""}
|
||||
@change=${this.handleKeybindChange}
|
||||
></setting-keybind>
|
||||
|
||||
<div class="text-center text-white text-base font-semibold mt-5 mb-2">
|
||||
${translateText("user_setting.zoom_controls")}
|
||||
</div>
|
||||
|
||||
<setting-keybind
|
||||
action="zoomOut"
|
||||
label=${translateText("user_setting.zoom_out")}
|
||||
description=${translateText("user_setting.zoom_out_desc")}
|
||||
defaultKey="KeyQ"
|
||||
.value=${this.keybinds["zoomOut"] ?? ""}
|
||||
@change=${this.handleKeybindChange}
|
||||
></setting-keybind>
|
||||
|
||||
<setting-keybind
|
||||
action="zoomIn"
|
||||
label=${translateText("user_setting.zoom_in")}
|
||||
description=${translateText("user_setting.zoom_in_desc")}
|
||||
defaultKey="KeyE"
|
||||
.value=${this.keybinds["zoomIn"] ?? ""}
|
||||
@change=${this.handleKeybindChange}
|
||||
></setting-keybind>
|
||||
|
||||
<div class="text-center text-white text-base font-semibold mt-5 mb-2">
|
||||
${translateText("user_setting.camera_movement")}
|
||||
</div>
|
||||
|
||||
<setting-keybind
|
||||
action="centerCamera"
|
||||
label=${translateText("user_setting.center_camera")}
|
||||
description=${translateText("user_setting.center_camera_desc")}
|
||||
defaultKey="KeyC"
|
||||
.value=${this.keybinds["centerCamera"] ?? ""}
|
||||
@change=${this.handleKeybindChange}
|
||||
></setting-keybind>
|
||||
|
||||
<setting-keybind
|
||||
action="moveUp"
|
||||
label=${translateText("user_setting.move_up")}
|
||||
description=${translateText("user_setting.move_up_desc")}
|
||||
defaultKey="KeyW"
|
||||
.value=${this.keybinds["moveUp"] ?? ""}
|
||||
@change=${this.handleKeybindChange}
|
||||
></setting-keybind>
|
||||
|
||||
<setting-keybind
|
||||
action="moveLeft"
|
||||
label=${translateText("user_setting.move_left")}
|
||||
description=${translateText("user_setting.move_left_desc")}
|
||||
defaultKey="KeyA"
|
||||
.value=${this.keybinds["moveLeft"] ?? ""}
|
||||
@change=${this.handleKeybindChange}
|
||||
></setting-keybind>
|
||||
|
||||
<setting-keybind
|
||||
action="moveDown"
|
||||
label=${translateText("user_setting.move_down")}
|
||||
description=${translateText("user_setting.move_down_desc")}
|
||||
defaultKey="KeyS"
|
||||
.value=${this.keybinds["moveDown"] ?? ""}
|
||||
@change=${this.handleKeybindChange}
|
||||
></setting-keybind>
|
||||
|
||||
<setting-keybind
|
||||
action="moveRight"
|
||||
label=${translateText("user_setting.move_right")}
|
||||
description=${translateText("user_setting.move_right_desc")}
|
||||
defaultKey="KeyD"
|
||||
.value=${this.keybinds["moveRight"] ?? ""}
|
||||
@change=${this.handleKeybindChange}
|
||||
></setting-keybind>
|
||||
`;
|
||||
}
|
||||
|
||||
public open() {
|
||||
this.requestUpdate();
|
||||
this.modalEl?.open();
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import { getMapsImage } from "../utilities/Maps";
|
||||
export const MapDescription: Record<keyof typeof GameMapType, string> = {
|
||||
World: "World",
|
||||
Europe: "Europe",
|
||||
EuropeClassic: "Europe Classic",
|
||||
Mena: "MENA",
|
||||
NorthAmerica: "North America",
|
||||
Oceania: "Oceania",
|
||||
@@ -24,6 +25,9 @@ export const MapDescription: Record<keyof typeof GameMapType, string> = {
|
||||
BetweenTwoSeas: "Between Two Seas",
|
||||
KnownWorld: "Known World",
|
||||
FaroeIslands: "Faroe Islands",
|
||||
DeglaciatedAntarctica: "Deglaciated Antarctica",
|
||||
FalklandIslands: "Falkland Islands",
|
||||
Baikal: "Baikal",
|
||||
};
|
||||
|
||||
@customElement("map-display")
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import { LitElement, css, html } from "lit";
|
||||
import { customElement, property } from "lit/decorators.js";
|
||||
import megaphone from "../../../resources/images/Megaphone.svg";
|
||||
import { NewsModal } from "../NewsModal";
|
||||
import { translateText } from "../Utils";
|
||||
|
||||
@customElement("news-button")
|
||||
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) {
|
||||
newsModal.open();
|
||||
}
|
||||
}
|
||||
|
||||
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")} />
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
createRenderRoot() {
|
||||
return this;
|
||||
}
|
||||
}
|
||||
@@ -12,7 +12,7 @@ export class OModal extends LitElement {
|
||||
.c-modal {
|
||||
position: fixed;
|
||||
padding: 1rem;
|
||||
z-index: 1000;
|
||||
z-index: 9999;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
import { LitElement, html } from "lit";
|
||||
import { customElement, property } from "lit/decorators.js";
|
||||
import { translateText } from "../../../../client/Utils";
|
||||
|
||||
@customElement("setting-keybind")
|
||||
export class SettingKeybind extends LitElement {
|
||||
@property() label = "Setting";
|
||||
@property() description = "";
|
||||
@property({ type: String, reflect: true }) action = "";
|
||||
@property({ type: String }) defaultKey = "";
|
||||
@property({ type: String }) value = "";
|
||||
@property({ type: Boolean }) easter = false;
|
||||
|
||||
createRenderRoot() {
|
||||
return this;
|
||||
}
|
||||
|
||||
private listening = false;
|
||||
|
||||
render() {
|
||||
return html`
|
||||
<div class="setting-item column${this.easter ? " easter-egg" : ""}">
|
||||
<div class="setting-label-group">
|
||||
<label class="setting-label block mb-1">${this.label}</label>
|
||||
|
||||
<div class="setting-keybind-box">
|
||||
<div class="setting-keybind-description">${this.description}</div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<span
|
||||
class="setting-key"
|
||||
tabindex="0"
|
||||
@keydown=${this.handleKeydown}
|
||||
@click=${this.startListening}
|
||||
>
|
||||
${this.displayKey(this.value || this.defaultKey)}
|
||||
</span>
|
||||
|
||||
<button
|
||||
class="text-xs text-gray-400 hover:text-white border border-gray-500 px-2 py-0.5 rounded transition"
|
||||
@click=${this.resetToDefault}
|
||||
>
|
||||
${translateText("user_setting.reset")}
|
||||
</button>
|
||||
<button
|
||||
class="text-xs text-gray-400 hover:text-white border border-gray-500 px-2 py-0.5 rounded transition"
|
||||
@click=${this.unbindKey}
|
||||
>
|
||||
${translateText("user_setting.unbind")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private displayKey(key: string): string {
|
||||
if (key === " ") return "Space";
|
||||
if (key.startsWith("Key") && key.length === 4) {
|
||||
return key.slice(3);
|
||||
}
|
||||
return key.length
|
||||
? key.charAt(0).toUpperCase() + key.slice(1)
|
||||
: "Press a key";
|
||||
}
|
||||
|
||||
private startListening() {
|
||||
this.listening = true;
|
||||
this.requestUpdate();
|
||||
}
|
||||
|
||||
private handleKeydown(e: KeyboardEvent) {
|
||||
if (!this.listening) return;
|
||||
e.preventDefault();
|
||||
|
||||
const code = e.code;
|
||||
|
||||
this.value = code;
|
||||
|
||||
this.dispatchEvent(
|
||||
new CustomEvent("change", {
|
||||
detail: { action: this.action, value: code },
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
}),
|
||||
);
|
||||
|
||||
this.listening = false;
|
||||
this.requestUpdate();
|
||||
}
|
||||
|
||||
private resetToDefault() {
|
||||
this.value = this.defaultKey;
|
||||
this.dispatchEvent(
|
||||
new CustomEvent("change", {
|
||||
detail: { action: this.action, value: this.defaultKey },
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
private unbindKey() {
|
||||
this.value = "";
|
||||
this.dispatchEvent(
|
||||
new CustomEvent("change", {
|
||||
detail: { action: this.action, value: "Null" },
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
}),
|
||||
);
|
||||
this.requestUpdate();
|
||||
}
|
||||
}
|
||||
+1031
-555
File diff suppressed because it is too large
Load Diff
@@ -7,6 +7,8 @@ import { RefreshGraphicsEvent as RedrawGraphicsEvent } from "../InputHandler";
|
||||
import { TransformHandler } from "./TransformHandler";
|
||||
import { UIState } from "./UIState";
|
||||
import { BuildMenu } from "./layers/BuildMenu";
|
||||
import { ChatDisplay } from "./layers/ChatDisplay";
|
||||
import { ChatModal } from "./layers/ChatModal";
|
||||
import { ControlPanel } from "./layers/ControlPanel";
|
||||
import { EmojiTable } from "./layers/EmojiTable";
|
||||
import { EventsDisplay } from "./layers/EventsDisplay";
|
||||
@@ -87,6 +89,14 @@ export function createRenderer(
|
||||
eventsDisplay.game = game;
|
||||
eventsDisplay.clientID = clientID;
|
||||
|
||||
const chatDisplay = document.querySelector("chat-display") as ChatDisplay;
|
||||
if (!(chatDisplay instanceof ChatDisplay)) {
|
||||
consolex.error("chat display not found");
|
||||
}
|
||||
chatDisplay.eventBus = eventBus;
|
||||
chatDisplay.game = game;
|
||||
chatDisplay.clientID = clientID;
|
||||
|
||||
const playerInfo = document.querySelector(
|
||||
"player-info-overlay",
|
||||
) as PlayerInfoOverlay;
|
||||
@@ -126,6 +136,13 @@ export function createRenderer(
|
||||
playerPanel.eventBus = eventBus;
|
||||
playerPanel.emojiTable = emojiTable;
|
||||
|
||||
const chatModal = document.querySelector("chat-modal") as ChatModal;
|
||||
if (!(chatModal instanceof ChatModal)) {
|
||||
console.error("chat modal not found");
|
||||
}
|
||||
chatModal.g = game;
|
||||
chatModal.eventBus = eventBus;
|
||||
|
||||
const multiTabModal = document.querySelector(
|
||||
"multi-tab-modal",
|
||||
) as MultiTabModal;
|
||||
@@ -142,6 +159,7 @@ export function createRenderer(
|
||||
new UILayer(game, eventBus, clientID, transformHandler),
|
||||
new NameLayer(game, transformHandler, clientID),
|
||||
eventsDisplay,
|
||||
chatDisplay,
|
||||
buildMenu,
|
||||
new RadialMenu(
|
||||
eventBus,
|
||||
|
||||
@@ -82,10 +82,8 @@ export const getColoredSprite = (
|
||||
const territoryColor = customTerritoryColor ?? theme.territoryColor(owner);
|
||||
const borderColor = customBorderColor ?? theme.borderColor(owner);
|
||||
const spawnHighlightColor = theme.spawnHighlightColor();
|
||||
const colorKey = customTerritoryColor
|
||||
? customTerritoryColor.toRgbString()
|
||||
: "";
|
||||
const key = owner.id() + unit.type() + colorKey;
|
||||
const colorKey = territoryColor.toRgbString() + borderColor.toRgbString();
|
||||
const key = unit.type() + colorKey;
|
||||
|
||||
if (coloredSpriteCache.has(key)) {
|
||||
return coloredSpriteCache.get(key)!;
|
||||
|
||||
@@ -301,7 +301,7 @@ export class BuildMenu extends LitElement implements Layer {
|
||||
if (!unit) {
|
||||
return false;
|
||||
}
|
||||
return unit[0].canBuild;
|
||||
return unit[0].canBuild !== false;
|
||||
}
|
||||
|
||||
private cost(item: BuildItemDisplay): number {
|
||||
@@ -409,21 +409,9 @@ export class BuildMenu extends LitElement implements Layer {
|
||||
}
|
||||
|
||||
private getBuildableUnits(): BuildItemDisplay[][] {
|
||||
if (this.game?.config()?.disableNukes()) {
|
||||
return buildTable.map((row) =>
|
||||
row.filter(
|
||||
(item) =>
|
||||
![
|
||||
UnitType.AtomBomb,
|
||||
UnitType.MIRV,
|
||||
UnitType.HydrogenBomb,
|
||||
UnitType.MissileSilo,
|
||||
UnitType.SAMLauncher,
|
||||
].includes(item.unitType),
|
||||
),
|
||||
);
|
||||
}
|
||||
return buildTable;
|
||||
return buildTable.map((row) =>
|
||||
row.filter((item) => !this.game?.config()?.isUnitDisabled(item.unitType)),
|
||||
);
|
||||
}
|
||||
|
||||
get isVisible() {
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
import { html, LitElement } from "lit";
|
||||
import { customElement, state } from "lit/decorators.js";
|
||||
import { DirectiveResult } from "lit/directive.js";
|
||||
import { unsafeHTML, UnsafeHTMLDirective } from "lit/directives/unsafe-html.js";
|
||||
import { EventBus } from "../../../core/EventBus";
|
||||
import { MessageType } from "../../../core/game/Game";
|
||||
import {
|
||||
DisplayMessageUpdate,
|
||||
GameUpdateType,
|
||||
} from "../../../core/game/GameUpdates";
|
||||
import { GameView } from "../../../core/game/GameView";
|
||||
import { ClientID } from "../../../core/Schemas";
|
||||
import { onlyImages } from "../../../core/Util";
|
||||
import { Layer } from "./Layer";
|
||||
|
||||
interface ChatEvent {
|
||||
description: string;
|
||||
unsafeDescription?: boolean;
|
||||
createdAt: number;
|
||||
highlight?: boolean;
|
||||
}
|
||||
|
||||
@customElement("chat-display")
|
||||
export class ChatDisplay extends LitElement implements Layer {
|
||||
public eventBus: EventBus;
|
||||
public game: GameView;
|
||||
public clientID: ClientID;
|
||||
|
||||
private active: boolean = false;
|
||||
|
||||
private updateMap = new Map([
|
||||
[GameUpdateType.DisplayEvent, (u) => this.onDisplayMessageEvent(u)],
|
||||
]);
|
||||
|
||||
@state() private _hidden: boolean = false;
|
||||
@state() private newEvents: number = 0;
|
||||
@state() private chatEvents: ChatEvent[] = [];
|
||||
|
||||
private toggleHidden() {
|
||||
this._hidden = !this._hidden;
|
||||
if (this._hidden) {
|
||||
this.newEvents = 0;
|
||||
}
|
||||
this.requestUpdate();
|
||||
}
|
||||
|
||||
private addEvent(event: ChatEvent) {
|
||||
this.chatEvents = [...this.chatEvents, event];
|
||||
if (this._hidden) {
|
||||
this.newEvents++;
|
||||
}
|
||||
this.requestUpdate();
|
||||
}
|
||||
|
||||
private removeEvent(index: number) {
|
||||
this.chatEvents = [
|
||||
...this.chatEvents.slice(0, index),
|
||||
...this.chatEvents.slice(index + 1),
|
||||
];
|
||||
}
|
||||
|
||||
onDisplayMessageEvent(event: DisplayMessageUpdate) {
|
||||
if (event.messageType !== MessageType.CHAT) return;
|
||||
const myPlayer = this.game.playerByClientID(this.clientID);
|
||||
if (
|
||||
event.playerID != null &&
|
||||
(!myPlayer || myPlayer.smallID() !== event.playerID)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.addEvent({
|
||||
description: event.message,
|
||||
createdAt: this.game.ticks(),
|
||||
highlight: true,
|
||||
unsafeDescription: true,
|
||||
});
|
||||
}
|
||||
|
||||
init() {}
|
||||
|
||||
tick() {
|
||||
// this.active = true;
|
||||
const updates = this.game.updatesSinceLastTick();
|
||||
const messages = updates[GameUpdateType.DisplayEvent] as
|
||||
| DisplayMessageUpdate[]
|
||||
| undefined;
|
||||
|
||||
if (messages) {
|
||||
for (const msg of messages) {
|
||||
if (msg.messageType === MessageType.CHAT) {
|
||||
const myPlayer = this.game.playerByClientID(this.clientID);
|
||||
if (
|
||||
msg.playerID != null &&
|
||||
(!myPlayer || myPlayer.smallID() !== msg.playerID)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
this.chatEvents = [
|
||||
...this.chatEvents,
|
||||
{
|
||||
description: msg.message,
|
||||
unsafeDescription: true,
|
||||
createdAt: this.game.ticks(),
|
||||
},
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (this.chatEvents.length > 100) {
|
||||
this.chatEvents = this.chatEvents.slice(-100);
|
||||
}
|
||||
|
||||
this.requestUpdate();
|
||||
}
|
||||
|
||||
private getChatContent(
|
||||
chat: ChatEvent,
|
||||
): string | DirectiveResult<typeof UnsafeHTMLDirective> {
|
||||
return chat.unsafeDescription
|
||||
? unsafeHTML(onlyImages(chat.description))
|
||||
: chat.description;
|
||||
}
|
||||
|
||||
render() {
|
||||
if (!this.active) {
|
||||
return html``;
|
||||
}
|
||||
return html`
|
||||
<div
|
||||
class="${this._hidden
|
||||
? "w-fit px-[10px] py-[5px]"
|
||||
: ""} rounded-md bg-black bg-opacity-60 relative max-h-[30vh] flex flex-col-reverse overflow-y-auto w-full lg:bottom-2.5 lg:right-2.5 z-50 lg:max-w-[30vw] lg:w-full lg:w-auto"
|
||||
style="pointer-events: auto"
|
||||
>
|
||||
<div>
|
||||
<div class="w-full bg-black/80 sticky top-0 px-[10px]">
|
||||
<button
|
||||
class="text-white cursor-pointer pointer-events-auto ${this
|
||||
._hidden
|
||||
? "hidden"
|
||||
: ""}"
|
||||
@click=${this.toggleHidden}
|
||||
>
|
||||
Hide
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
class="text-white cursor-pointer pointer-events-auto ${this._hidden
|
||||
? ""
|
||||
: "hidden"}"
|
||||
@click=${this.toggleHidden}
|
||||
>
|
||||
Chat
|
||||
<span
|
||||
class="${this.newEvents
|
||||
? ""
|
||||
: "hidden"} inline-block px-2 bg-red-500 rounded-sm"
|
||||
>${this.newEvents}</span
|
||||
>
|
||||
</button>
|
||||
|
||||
<table
|
||||
class="w-full border-collapse text-white shadow-lg lg:text-xl text-xs ${this
|
||||
._hidden
|
||||
? "hidden"
|
||||
: ""}"
|
||||
style="pointer-events: auto;"
|
||||
>
|
||||
<tbody>
|
||||
${this.chatEvents.map(
|
||||
(chat) => html`
|
||||
<tr class="border-b border-opacity-0">
|
||||
<td class="lg:p-3 p-1 text-left">
|
||||
${this.getChatContent(chat)}
|
||||
</td>
|
||||
</tr>
|
||||
`,
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
createRenderRoot() {
|
||||
return this;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,306 @@
|
||||
import { LitElement, html } from "lit";
|
||||
import { customElement, query } from "lit/decorators.js";
|
||||
|
||||
import { PlayerType } from "../../../core/game/Game";
|
||||
import { GameView, PlayerView } from "../../../core/game/GameView";
|
||||
|
||||
import quickChatData from "../../../../resources/QuickChat.json";
|
||||
import { EventBus } from "../../../core/EventBus";
|
||||
import { SendQuickChatEvent } from "../../Transport";
|
||||
import { translateText } from "../../Utils";
|
||||
|
||||
type QuickChatPhrase = {
|
||||
key: string;
|
||||
requiresPlayer: boolean;
|
||||
};
|
||||
|
||||
type QuickChatPhrases = Record<string, QuickChatPhrase[]>;
|
||||
|
||||
const quickChatPhrases: QuickChatPhrases = quickChatData;
|
||||
|
||||
@customElement("chat-modal")
|
||||
export class ChatModal extends LitElement {
|
||||
@query("o-modal") private modalEl!: HTMLElement & {
|
||||
open: () => void;
|
||||
close: () => void;
|
||||
};
|
||||
|
||||
createRenderRoot() {
|
||||
return this;
|
||||
}
|
||||
|
||||
private players: string[] = [];
|
||||
|
||||
private playerSearchQuery: string = "";
|
||||
private previewText: string | null = null;
|
||||
private requiresPlayerSelection: boolean = false;
|
||||
private selectedCategory: string | null = null;
|
||||
private selectedPhraseText: string | null = null;
|
||||
private selectedPlayer: string | null = null;
|
||||
private selectedPhraseTemplate: string | null = null;
|
||||
private selectedQuickChatKey: string | null = null;
|
||||
|
||||
private recipient: PlayerView;
|
||||
private sender: PlayerView;
|
||||
public eventBus: EventBus;
|
||||
|
||||
public g: GameView;
|
||||
|
||||
quickChatPhrases: Record<
|
||||
string,
|
||||
Array<{ text: string; requiresPlayer: boolean }>
|
||||
> = {
|
||||
help: [{ text: "Please give me troops!", requiresPlayer: false }],
|
||||
attack: [{ text: "Attack [P1]!", requiresPlayer: true }],
|
||||
defend: [{ text: "Defend [P1]!", requiresPlayer: true }],
|
||||
greet: [{ text: "Hello!", requiresPlayer: false }],
|
||||
misc: [{ text: "Let's go!", requiresPlayer: false }],
|
||||
};
|
||||
|
||||
private categories = [
|
||||
{ id: "help" },
|
||||
{ id: "attack" },
|
||||
{ id: "defend" },
|
||||
{ id: "greet" },
|
||||
{ id: "misc" },
|
||||
{ id: "warnings" },
|
||||
];
|
||||
|
||||
private getPhrasesForCategory(categoryId: string) {
|
||||
return quickChatPhrases[categoryId] ?? [];
|
||||
}
|
||||
|
||||
render() {
|
||||
const sortedPlayers = [...this.players].sort((a, b) => a.localeCompare(b));
|
||||
|
||||
const filteredPlayers = sortedPlayers.filter((player) =>
|
||||
player.toLowerCase().includes(this.playerSearchQuery),
|
||||
);
|
||||
|
||||
const otherPlayers = sortedPlayers.filter(
|
||||
(player) => !player.toLowerCase().includes(this.playerSearchQuery),
|
||||
);
|
||||
|
||||
const displayPlayers = [...filteredPlayers, ...otherPlayers];
|
||||
return html`
|
||||
<o-modal title="${translateText("chat.title")}">
|
||||
<div class="chat-columns">
|
||||
<div class="chat-column">
|
||||
<div class="column-title">${translateText("chat.category")}</div>
|
||||
${this.categories.map(
|
||||
(category) => html`
|
||||
<button
|
||||
class="chat-option-button ${this.selectedCategory ===
|
||||
category.id
|
||||
? "selected"
|
||||
: ""}"
|
||||
@click=${() => this.selectCategory(category.id)}
|
||||
>
|
||||
${translateText(`chat.cat.${category.id}`)}
|
||||
</button>
|
||||
`,
|
||||
)}
|
||||
</div>
|
||||
|
||||
${this.selectedCategory
|
||||
? html`
|
||||
<div class="chat-column">
|
||||
<div class="column-title">
|
||||
${translateText("chat.phrase")}
|
||||
</div>
|
||||
<div class="phrase-scroll-area">
|
||||
${this.getPhrasesForCategory(this.selectedCategory).map(
|
||||
(phrase) => html`
|
||||
<button
|
||||
class="chat-option-button ${this
|
||||
.selectedPhraseText ===
|
||||
translateText(
|
||||
`chat.${this.selectedCategory}.${phrase.key}`,
|
||||
)
|
||||
? "selected"
|
||||
: ""}"
|
||||
@click=${() => this.selectPhrase(phrase)}
|
||||
>
|
||||
${this.renderPhrasePreview(phrase)}
|
||||
</button>
|
||||
`,
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
: null}
|
||||
${this.requiresPlayerSelection || this.selectedPlayer
|
||||
? html`
|
||||
<div class="chat-column">
|
||||
<div class="column-title">
|
||||
${translateText("chat.player")}
|
||||
</div>
|
||||
|
||||
<input
|
||||
class="player-search-input"
|
||||
type="text"
|
||||
placeholder="${translateText("chat.search")}"
|
||||
.value=${this.playerSearchQuery}
|
||||
@input=${this.onPlayerSearchInput}
|
||||
/>
|
||||
|
||||
<div class="player-scroll-area">
|
||||
${this.getSortedFilteredPlayers().map(
|
||||
(player) => html`
|
||||
<button
|
||||
class="chat-option-button ${this.selectedPlayer ===
|
||||
player
|
||||
? "selected"
|
||||
: ""}"
|
||||
@click=${() => this.selectPlayer(player)}
|
||||
>
|
||||
${player}
|
||||
</button>
|
||||
`,
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
: null}
|
||||
</div>
|
||||
|
||||
<div class="chat-preview">
|
||||
${this.previewText
|
||||
? translateText(this.previewText)
|
||||
: translateText("chat.build")}
|
||||
</div>
|
||||
<div class="chat-send">
|
||||
<button
|
||||
class="chat-send-button"
|
||||
@click=${this.sendChatMessage}
|
||||
?disabled=${!this.previewText}
|
||||
>
|
||||
${translateText("chat.send")}
|
||||
</button>
|
||||
</div>
|
||||
</o-modal>
|
||||
`;
|
||||
}
|
||||
|
||||
private selectCategory(categoryId: string) {
|
||||
this.selectedCategory = categoryId;
|
||||
this.selectedPhraseText = null;
|
||||
this.previewText = null;
|
||||
this.requiresPlayerSelection = false;
|
||||
this.selectedPlayer = null;
|
||||
this.requestUpdate();
|
||||
}
|
||||
|
||||
private selectPhrase(phrase: QuickChatPhrase) {
|
||||
this.selectedQuickChatKey = this.getFullQuickChatKey(
|
||||
this.selectedCategory!,
|
||||
phrase.key,
|
||||
);
|
||||
this.selectedPhraseTemplate = translateText(
|
||||
`chat.${this.selectedCategory}.${phrase.key}`,
|
||||
);
|
||||
this.selectedPhraseText = translateText(
|
||||
`chat.${this.selectedCategory}.${phrase.key}`,
|
||||
);
|
||||
this.previewText = `chat.${this.selectedCategory}.${phrase.key}`;
|
||||
this.requiresPlayerSelection = phrase.requiresPlayer;
|
||||
this.selectedPlayer = null;
|
||||
this.requestUpdate();
|
||||
}
|
||||
|
||||
private renderPhrasePreview(phrase: { key: string }) {
|
||||
return translateText(`chat.${this.selectedCategory}.${phrase.key}`);
|
||||
}
|
||||
|
||||
private selectPlayer(player: string) {
|
||||
if (this.previewText) {
|
||||
this.previewText = this.selectedPhraseTemplate.replace("[P1]", player);
|
||||
this.selectedPlayer = player;
|
||||
this.requiresPlayerSelection = false;
|
||||
this.requestUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
private sendChatMessage() {
|
||||
console.log("Sent message:", this.previewText);
|
||||
console.log("Sender:", this.sender);
|
||||
console.log("Recipient:", this.recipient);
|
||||
console.log("Key:", this.selectedQuickChatKey);
|
||||
|
||||
if (this.sender && this.recipient && this.selectedQuickChatKey) {
|
||||
const variables = this.selectedPlayer ? { P1: this.selectedPlayer } : {};
|
||||
|
||||
this.eventBus.emit(
|
||||
new SendQuickChatEvent(
|
||||
this.sender,
|
||||
this.recipient,
|
||||
this.selectedQuickChatKey,
|
||||
variables,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
this.previewText = null;
|
||||
this.selectedCategory = null;
|
||||
this.requiresPlayerSelection = false;
|
||||
this.close();
|
||||
|
||||
this.requestUpdate();
|
||||
}
|
||||
|
||||
private onPlayerSearchInput(e: Event) {
|
||||
const target = e.target as HTMLInputElement;
|
||||
this.playerSearchQuery = target.value.toLowerCase();
|
||||
this.requestUpdate();
|
||||
}
|
||||
|
||||
private getSortedFilteredPlayers(): string[] {
|
||||
const sorted = [...this.players].sort((a, b) => a.localeCompare(b));
|
||||
const filtered = sorted.filter((p) =>
|
||||
p.toLowerCase().includes(this.playerSearchQuery),
|
||||
);
|
||||
const others = sorted.filter(
|
||||
(p) => !p.toLowerCase().includes(this.playerSearchQuery),
|
||||
);
|
||||
return [...filtered, ...others];
|
||||
}
|
||||
|
||||
private getFullQuickChatKey(category: string, phraseKey: string): string {
|
||||
return `${category}.${phraseKey}`;
|
||||
}
|
||||
|
||||
public open(sender?: PlayerView, recipient?: PlayerView) {
|
||||
if (sender && recipient) {
|
||||
console.log("Sent message:", recipient);
|
||||
console.log("Sent message:", sender);
|
||||
const alivePlayerNames = this.g
|
||||
.players()
|
||||
.filter((p) => p.isAlive() && !(p.data.playerType === PlayerType.Bot))
|
||||
.map((p) => p.data.name);
|
||||
|
||||
console.log("Alive player names:", alivePlayerNames);
|
||||
this.players = alivePlayerNames;
|
||||
this.recipient = recipient;
|
||||
this.sender = sender;
|
||||
}
|
||||
this.requestUpdate();
|
||||
this.modalEl?.open();
|
||||
}
|
||||
|
||||
public close() {
|
||||
this.selectedCategory = null;
|
||||
this.selectedPhraseText = null;
|
||||
this.previewText = null;
|
||||
this.requiresPlayerSelection = false;
|
||||
this.selectedPlayer = null;
|
||||
this.modalEl?.close();
|
||||
}
|
||||
|
||||
public setRecipient(value: PlayerView) {
|
||||
this.recipient = value;
|
||||
}
|
||||
|
||||
public setSender(value: PlayerView) {
|
||||
this.sender = value;
|
||||
}
|
||||
}
|
||||
@@ -4,24 +4,11 @@ import { EventBus } from "../../../core/EventBus";
|
||||
import { AllPlayers } from "../../../core/game/Game";
|
||||
import { GameView, PlayerView } from "../../../core/game/GameView";
|
||||
import { TerraNulliusImpl } from "../../../core/game/TerraNulliusImpl";
|
||||
import { emojiTable, flattenedEmojiTable } from "../../../core/Util";
|
||||
import { ShowEmojiMenuEvent } from "../../InputHandler";
|
||||
import { SendEmojiIntentEvent } from "../../Transport";
|
||||
import { TransformHandler } from "../TransformHandler";
|
||||
|
||||
const emojiTable: string[][] = [
|
||||
["😀", "😊", "🥰", "😇", "😎"],
|
||||
["😞", "🥺", "😭", "😱", "😡"],
|
||||
["😈", "🤡", "🖕", "🥱", "🤦♂️"],
|
||||
["👋", "👏", "🤌", "💪", "🫡"],
|
||||
["👍", "👎", "❓", "🐔", "🐀"],
|
||||
["🤝", "🆘", "🕊️", "🏳️", "⏳"],
|
||||
["🔥", "💥", "💀", "☢️", "⚠️"],
|
||||
["↖️", "⬆️", "↗️", "👑", "🥇"],
|
||||
["⬅️", "🎯", "➡️", "🥈", "🥉"],
|
||||
["↙️", "⬇️", "↘️", "❤️", "💔"],
|
||||
["💰", "⚓", "⛵", "🏡", "🛡️"],
|
||||
];
|
||||
|
||||
@customElement("emoji-table")
|
||||
export class EmojiTable extends LitElement {
|
||||
public eventBus: EventBus;
|
||||
@@ -130,7 +117,12 @@ export class EmojiTable extends LitElement {
|
||||
targetPlayer == this.game.myPlayer()
|
||||
? AllPlayers
|
||||
: (targetPlayer as PlayerView);
|
||||
this.eventBus.emit(new SendEmojiIntentEvent(recipient, emoji));
|
||||
this.eventBus.emit(
|
||||
new SendEmojiIntentEvent(
|
||||
recipient,
|
||||
flattenedEmojiTable.indexOf(emoji),
|
||||
),
|
||||
);
|
||||
this.hideTable();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
AllianceRequestUpdate,
|
||||
AttackUpdate,
|
||||
BrokeAllianceUpdate,
|
||||
DisplayChatMessageUpdate,
|
||||
DisplayMessageUpdate,
|
||||
EmojiUpdate,
|
||||
GameUpdateType,
|
||||
@@ -33,6 +34,8 @@ import { onlyImages } from "../../../core/Util";
|
||||
import { renderTroops } from "../../Utils";
|
||||
import { GoToPlayerEvent, GoToUnitEvent } from "./Leaderboard";
|
||||
|
||||
import { translateText } from "../../Utils";
|
||||
|
||||
interface Event {
|
||||
description: string;
|
||||
unsafeDescription?: boolean;
|
||||
@@ -77,6 +80,7 @@ export class EventsDisplay extends LitElement implements Layer {
|
||||
|
||||
private updateMap = new Map([
|
||||
[GameUpdateType.DisplayEvent, (u) => this.onDisplayMessageEvent(u)],
|
||||
[GameUpdateType.DisplayChatEvent, (u) => this.onDisplayChatEvent(u)],
|
||||
[GameUpdateType.AllianceRequest, (u) => this.onAllianceRequestEvent(u)],
|
||||
[
|
||||
GameUpdateType.AllianceRequestReply,
|
||||
@@ -187,6 +191,34 @@ export class EventsDisplay extends LitElement implements Layer {
|
||||
});
|
||||
}
|
||||
|
||||
onDisplayChatEvent(event: DisplayChatMessageUpdate) {
|
||||
const myPlayer = this.game.playerByClientID(this.clientID);
|
||||
if (
|
||||
event.playerID === null ||
|
||||
!myPlayer ||
|
||||
myPlayer.smallID() !== event.playerID
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const baseMessage = translateText(`chat.${event.category}.${event.key}`);
|
||||
const translatedMessage = baseMessage.replace(
|
||||
/\[([^\]]+)\]/g,
|
||||
(_, key) => event.variables?.[key] || `[${key}]`,
|
||||
);
|
||||
|
||||
this.addEvent({
|
||||
description: translateText(event.isFrom ? "chat.from" : "chat.to", {
|
||||
user: event.recipient,
|
||||
msg: translatedMessage,
|
||||
}),
|
||||
createdAt: this.game.ticks(),
|
||||
highlight: true,
|
||||
type: MessageType.CHAT,
|
||||
unsafeDescription: false,
|
||||
});
|
||||
}
|
||||
|
||||
onAllianceRequestEvent(update: AllianceRequestUpdate) {
|
||||
const myPlayer = this.game.playerByClientID(this.clientID);
|
||||
if (!myPlayer || update.recipientID !== myPlayer.smallID()) {
|
||||
@@ -271,10 +303,19 @@ export class EventsDisplay extends LitElement implements Layer {
|
||||
const malusPercent = Math.round(
|
||||
(1 - this.game.config().traitorDefenseDebuff()) * 100,
|
||||
);
|
||||
const traitorDurationRaw =
|
||||
Number(this.game.config().traitorDuration) / 10;
|
||||
const traitorDurationSeconds = Math.floor(traitorDurationRaw);
|
||||
|
||||
const durationText =
|
||||
traitorDurationSeconds === 1
|
||||
? "1 second"
|
||||
: `${traitorDurationSeconds} seconds`;
|
||||
|
||||
this.addEvent({
|
||||
description:
|
||||
`You broke your alliance with ${betrayed.name()}, making you a TRAITOR ` +
|
||||
`(${malusPercent}% defense debuff)`,
|
||||
`(${malusPercent}% defense debuff for ${durationText})`,
|
||||
type: MessageType.ERROR,
|
||||
highlight: true,
|
||||
createdAt: this.game.ticks(),
|
||||
@@ -386,6 +427,8 @@ export class EventsDisplay extends LitElement implements Layer {
|
||||
return "text-green-300";
|
||||
case MessageType.INFO:
|
||||
return "text-gray-200";
|
||||
case MessageType.CHAT:
|
||||
return "text-gray-200";
|
||||
case MessageType.WARN:
|
||||
return "text-yellow-300";
|
||||
case MessageType.ERROR:
|
||||
|
||||
@@ -141,7 +141,7 @@ export class Leaderboard extends LitElement implements Layer {
|
||||
position: fixed;
|
||||
top: 10px;
|
||||
left: 10px;
|
||||
z-index: 9999;
|
||||
z-index: 9998;
|
||||
background-color: rgb(31 41 55 / 0.7);
|
||||
padding: 10px;
|
||||
padding-top: 0px;
|
||||
@@ -192,7 +192,7 @@ export class Leaderboard extends LitElement implements Layer {
|
||||
position: fixed;
|
||||
left: 10px;
|
||||
top: 10px;
|
||||
z-index: 9999;
|
||||
z-index: 9998;
|
||||
background-color: rgb(31 41 55 / 0.7);
|
||||
color: white;
|
||||
border: none;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { LitElement, html } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators.js";
|
||||
import { GameEnv } from "../../../core/configuration/Config";
|
||||
import { GameType } from "../../../core/game/Game";
|
||||
import { GameView } from "../../../core/game/GameView";
|
||||
import { MultiTabDetector } from "../../MultiTabDetector";
|
||||
@@ -15,6 +16,9 @@ export class MultiTabModal extends LitElement implements Layer {
|
||||
@property({ type: Number }) duration: number = 5000;
|
||||
@state() private countdown: number = 5;
|
||||
@state() private isVisible: boolean = false;
|
||||
@state() private fakeIp: string = "";
|
||||
@state() private deviceFingerprint: string = "";
|
||||
@state() private reported: boolean = true;
|
||||
|
||||
private intervalId?: number;
|
||||
|
||||
@@ -26,7 +30,8 @@ export class MultiTabModal extends LitElement implements Layer {
|
||||
tick() {
|
||||
if (
|
||||
this.game.inSpawnPhase() ||
|
||||
this.game.config().gameConfig().gameType == GameType.Singleplayer
|
||||
this.game.config().gameConfig().gameType == GameType.Singleplayer ||
|
||||
this.game.config().serverConfig().env() == GameEnv.Dev
|
||||
) {
|
||||
return;
|
||||
}
|
||||
@@ -38,6 +43,26 @@ export class MultiTabModal extends LitElement implements Layer {
|
||||
}
|
||||
}
|
||||
|
||||
init() {
|
||||
this.fakeIp = this.generateFakeIp();
|
||||
this.deviceFingerprint = this.generateDeviceFingerprint();
|
||||
this.reported = true;
|
||||
}
|
||||
|
||||
// Generate fake IP in format xxx.xxx.xxx.xxx
|
||||
private generateFakeIp(): string {
|
||||
return Array.from({ length: 4 }, () =>
|
||||
Math.floor(Math.random() * 255),
|
||||
).join(".");
|
||||
}
|
||||
|
||||
// Generate fake device fingerprint (32 character hex)
|
||||
private generateDeviceFingerprint(): string {
|
||||
return Array.from({ length: 32 }, () =>
|
||||
Math.floor(Math.random() * 16).toString(16),
|
||||
).join("");
|
||||
}
|
||||
|
||||
// Show the modal with penalty information
|
||||
public show(duration: number): void {
|
||||
if (!this.game.myPlayer()?.isAlive()) {
|
||||
@@ -98,14 +123,44 @@ export class MultiTabModal extends LitElement implements Layer {
|
||||
<div
|
||||
class="relative p-6 bg-white dark:bg-gray-800 rounded-xl shadow-xl max-w-md w-full m-4 transition-all transform"
|
||||
>
|
||||
<h2 class="text-2xl font-bold mb-4 text-red-600 dark:text-red-400">
|
||||
${translateText("multi_tab.warning")}
|
||||
</h2>
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h2 class="text-2xl font-bold text-red-600 dark:text-red-400">
|
||||
${translateText("multi_tab.warning")}
|
||||
</h2>
|
||||
<div
|
||||
class="px-2 py-1 bg-red-600 text-white text-xs font-bold rounded-full animate-pulse"
|
||||
>
|
||||
RECORDING
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="mb-4 text-gray-800 dark:text-gray-200">
|
||||
${translateText("multi_tab.detected")}
|
||||
</p>
|
||||
|
||||
<div
|
||||
class="mb-4 p-3 bg-gray-100 dark:bg-gray-900 rounded-md text-sm font-mono"
|
||||
>
|
||||
<div class="flex justify-between mb-1">
|
||||
<span class="text-gray-500 dark:text-gray-400">IP:</span>
|
||||
<span class="text-red-600 dark:text-red-400">${this.fakeIp}</span>
|
||||
</div>
|
||||
<div class="flex justify-between mb-1">
|
||||
<span class="text-gray-500 dark:text-gray-400"
|
||||
>Device Fingerprint:</span
|
||||
>
|
||||
<span class="text-red-600 dark:text-red-400"
|
||||
>${this.deviceFingerprint}</span
|
||||
>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-gray-500 dark:text-gray-400">Reported:</span>
|
||||
<span class="text-red-600 dark:text-red-400"
|
||||
>${this.reported ? "TRUE" : "FALSE"}</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="mb-4 text-gray-800 dark:text-gray-200">
|
||||
${translateText("multi_tab.please_wait")}
|
||||
<span class="font-bold text-xl">${this.countdown}</span>
|
||||
@@ -124,6 +179,10 @@ export class MultiTabModal extends LitElement implements Layer {
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">
|
||||
${translateText("multi_tab.explanation")}
|
||||
</p>
|
||||
|
||||
<p class="mt-3 text-xs text-red-500 font-semibold">
|
||||
Repeated violations may result in permanent account suspension.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
@@ -4,6 +4,7 @@ import crownIcon from "../../../../resources/images/CrownIcon.svg";
|
||||
import embargoIcon from "../../../../resources/images/EmbargoIcon.svg";
|
||||
import nukeRedIcon from "../../../../resources/images/NukeIconRed.svg";
|
||||
import nukeWhiteIcon from "../../../../resources/images/NukeIconWhite.svg";
|
||||
import shieldIcon from "../../../../resources/images/ShieldIconBlack.svg";
|
||||
import targetIcon from "../../../../resources/images/TargetIcon.svg";
|
||||
import traitorIcon from "../../../../resources/images/TraitorIcon.svg";
|
||||
import { PseudoRandom } from "../../../core/PseudoRandom";
|
||||
@@ -45,6 +46,7 @@ export class NameLayer implements Layer {
|
||||
private embargoIconImage: HTMLImageElement;
|
||||
private nukeWhiteIconImage: HTMLImageElement;
|
||||
private nukeRedIconImage: HTMLImageElement;
|
||||
private shieldIconImage: HTMLImageElement;
|
||||
private container: HTMLDivElement;
|
||||
private myPlayer: PlayerView | null = null;
|
||||
private firstPlace: PlayerView | null = null;
|
||||
@@ -71,6 +73,8 @@ export class NameLayer implements Layer {
|
||||
this.nukeWhiteIconImage.src = nukeWhiteIcon;
|
||||
this.nukeRedIconImage = new Image();
|
||||
this.nukeRedIconImage.src = nukeRedIcon;
|
||||
this.shieldIconImage = new Image();
|
||||
this.shieldIconImage.src = shieldIcon;
|
||||
}
|
||||
|
||||
resizeCanvas() {
|
||||
@@ -209,6 +213,7 @@ export class NameLayer implements Layer {
|
||||
nameDiv.style.alignItems = "center";
|
||||
|
||||
const nameSpan = document.createElement("span");
|
||||
nameSpan.className = "player-name-span";
|
||||
nameSpan.innerHTML = player.name();
|
||||
nameDiv.appendChild(nameSpan);
|
||||
element.appendChild(nameDiv);
|
||||
@@ -223,6 +228,21 @@ export class NameLayer implements Layer {
|
||||
troopsDiv.style.marginTop = "-5%";
|
||||
element.appendChild(troopsDiv);
|
||||
|
||||
// TODO: enable this for new meta.
|
||||
|
||||
// const shieldDiv = document.createElement("div");
|
||||
// shieldDiv.classList.add("player-shield");
|
||||
// shieldDiv.style.zIndex = "3";
|
||||
// shieldDiv.style.marginTop = "-5%";
|
||||
// shieldDiv.style.display = "flex";
|
||||
// shieldDiv.style.alignItems = "center";
|
||||
// shieldDiv.style.gap = "0px";
|
||||
// shieldDiv.innerHTML = `
|
||||
// <img src="${this.shieldIconImage.src}" style="width: 16px; height: 16px;" />
|
||||
// <span style="color: black; font-size: 10px; margin-top: -2px;">0</span>
|
||||
// `;
|
||||
// element.appendChild(shieldDiv);
|
||||
|
||||
// Start off invisible so it doesn't flash at 0,0
|
||||
element.style.display = "none";
|
||||
|
||||
@@ -276,6 +296,10 @@ export class NameLayer implements Layer {
|
||||
nameDiv.style.fontSize = `${render.fontSize}px`;
|
||||
nameDiv.style.lineHeight = `${render.fontSize}px`;
|
||||
nameDiv.style.color = render.fontColor;
|
||||
const span = nameDiv.querySelector(".player-name-span");
|
||||
if (span) {
|
||||
span.innerHTML = render.player.name();
|
||||
}
|
||||
if (flagDiv) {
|
||||
flagDiv.style.height = `${render.fontSize}px`;
|
||||
}
|
||||
@@ -283,6 +307,26 @@ export class NameLayer implements Layer {
|
||||
troopsDiv.style.color = render.fontColor;
|
||||
troopsDiv.textContent = renderTroops(render.player.troops());
|
||||
|
||||
// TODO: enable this for new meta.
|
||||
|
||||
// const density = renderNumber(
|
||||
// render.player.troops() / render.player.numTilesOwned(),
|
||||
// );
|
||||
// const shieldDiv = render.element.querySelector(
|
||||
// ".player-shield",
|
||||
// ) as HTMLDivElement;
|
||||
// const shieldImg = shieldDiv.querySelector("img");
|
||||
// const shieldNumber = shieldDiv.querySelector("span");
|
||||
// if (shieldImg) {
|
||||
// shieldImg.style.width = `${render.fontSize * 0.8}px`;
|
||||
// shieldImg.style.height = `${render.fontSize * 0.8}px`;
|
||||
// }
|
||||
// if (shieldNumber) {
|
||||
// shieldNumber.style.fontSize = `${render.fontSize * 0.6}px`;
|
||||
// shieldNumber.style.marginTop = `${-render.fontSize * 0.1}px`;
|
||||
// shieldNumber.textContent = density;
|
||||
// }
|
||||
|
||||
// Handle icons
|
||||
const iconsDiv = render.element.querySelector(
|
||||
".player-icons",
|
||||
|
||||
@@ -106,6 +106,10 @@ export class OptionsMenu extends LitElement implements Layer {
|
||||
this.eventBus.emit(new RefreshGraphicsEvent());
|
||||
}
|
||||
|
||||
private onToggleRandomNameModeButtonClick() {
|
||||
this.userSettings.toggleRandomName();
|
||||
}
|
||||
|
||||
private onToggleFocusLockedButtonClick() {
|
||||
this.userSettings.toggleFocusLocked();
|
||||
this.requestUpdate();
|
||||
@@ -196,6 +200,12 @@ export class OptionsMenu extends LitElement implements Layer {
|
||||
title: "Dark Mode",
|
||||
children: "🌙: " + (this.userSettings.darkMode() ? "On" : "Off"),
|
||||
})}
|
||||
${button({
|
||||
onClick: this.onToggleRandomNameModeButtonClick,
|
||||
title: "Random name mode",
|
||||
children:
|
||||
"🥷: " + (this.userSettings.anonymousNames() ? "On" : "Off"),
|
||||
})}
|
||||
${button({
|
||||
onClick: this.onToggleLeftClickOpensMenu,
|
||||
title: "Left click",
|
||||
|
||||
@@ -218,6 +218,9 @@ export class PlayerInfoOverlay extends LitElement implements Layer {
|
||||
: ""}
|
||||
${player.name()}
|
||||
</div>
|
||||
${player.team() != null
|
||||
? html`<div class="text-sm opacity-80">Team: ${player.team()}</div>`
|
||||
: ""}
|
||||
<div class="text-sm opacity-80">Type: ${playerType}</div>
|
||||
${player.troops() >= 1
|
||||
? html`<div class="text-sm opacity-80" translate="no">
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { LitElement, html } from "lit";
|
||||
import { customElement, state } from "lit/decorators.js";
|
||||
import allianceIcon from "../../../../resources/images/AllianceIconWhite.svg";
|
||||
import chatIcon from "../../../../resources/images/ChatIconWhite.svg";
|
||||
import donateGoldIcon from "../../../../resources/images/DonateGoldIconWhite.svg";
|
||||
import donateTroopIcon from "../../../../resources/images/DonateTroopIconWhite.svg";
|
||||
import emojiIcon from "../../../../resources/images/EmojiIconWhite.svg";
|
||||
@@ -15,6 +16,7 @@ import {
|
||||
} from "../../../core/game/Game";
|
||||
import { TileRef } from "../../../core/game/GameMap";
|
||||
import { GameView, PlayerView } from "../../../core/game/GameView";
|
||||
import { flattenedEmojiTable } from "../../../core/Util";
|
||||
import { MouseUpEvent } from "../../InputHandler";
|
||||
import {
|
||||
SendAllianceRequestIntentEvent,
|
||||
@@ -26,6 +28,7 @@ import {
|
||||
SendTargetPlayerIntentEvent,
|
||||
} from "../../Transport";
|
||||
import { renderNumber, renderTroops } from "../../Utils";
|
||||
import { ChatModal } from "./ChatModal";
|
||||
import { EmojiTable } from "./EmojiTable";
|
||||
import { Layer } from "./Layer";
|
||||
|
||||
@@ -122,15 +125,27 @@ export class PlayerPanel extends LitElement implements Layer {
|
||||
e.stopPropagation();
|
||||
this.emojiTable.showTable((emoji: string) => {
|
||||
if (myPlayer == other) {
|
||||
this.eventBus.emit(new SendEmojiIntentEvent(AllPlayers, emoji));
|
||||
this.eventBus.emit(
|
||||
new SendEmojiIntentEvent(
|
||||
AllPlayers,
|
||||
flattenedEmojiTable.indexOf(emoji),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
this.eventBus.emit(new SendEmojiIntentEvent(other, emoji));
|
||||
this.eventBus.emit(
|
||||
new SendEmojiIntentEvent(other, flattenedEmojiTable.indexOf(emoji)),
|
||||
);
|
||||
}
|
||||
this.emojiTable.hideTable();
|
||||
this.hide();
|
||||
});
|
||||
}
|
||||
|
||||
private handleChat(e: Event, sender: PlayerView, other: PlayerView) {
|
||||
this.ctModal.open(sender, other);
|
||||
this.hide();
|
||||
}
|
||||
|
||||
private handleTargetClick(e: Event, other: PlayerView) {
|
||||
e.stopPropagation();
|
||||
this.eventBus.emit(new SendTargetPlayerIntentEvent(other.id()));
|
||||
@@ -141,8 +156,12 @@ export class PlayerPanel extends LitElement implements Layer {
|
||||
return this;
|
||||
}
|
||||
|
||||
private ctModal;
|
||||
|
||||
init() {
|
||||
this.eventBus.on(MouseUpEvent, (e: MouseEvent) => this.hide());
|
||||
|
||||
this.ctModal = document.querySelector("chat-modal") as ChatModal;
|
||||
}
|
||||
|
||||
async tick() {
|
||||
@@ -184,7 +203,9 @@ export class PlayerPanel extends LitElement implements Layer {
|
||||
|
||||
let other = this.g.owner(this.tile);
|
||||
if (!other.isPlayer()) {
|
||||
throw new Error("Tile is not owned by a player");
|
||||
this.hide();
|
||||
console.warn("Tile is not owned by a player");
|
||||
return;
|
||||
}
|
||||
other = other as PlayerView;
|
||||
|
||||
@@ -285,6 +306,14 @@ export class PlayerPanel extends LitElement implements Layer {
|
||||
|
||||
<!-- Action buttons -->
|
||||
<div class="flex justify-center gap-2">
|
||||
<button
|
||||
@click=${(e) => this.handleChat(e, myPlayer, other)}
|
||||
class="w-10 h-10 flex items-center justify-center
|
||||
bg-opacity-50 bg-gray-700 hover:bg-opacity-70
|
||||
text-white rounded-lg transition-colors"
|
||||
>
|
||||
<img src=${chatIcon} alt="Target" class="w-6 h-6" />
|
||||
</button>
|
||||
${canTarget
|
||||
? html`<button
|
||||
@click=${(e) => this.handleTargetClick(e, other)}
|
||||
|
||||
@@ -8,7 +8,12 @@ import swordIcon from "../../../../resources/images/SwordIconWhite.svg";
|
||||
import traitorIcon from "../../../../resources/images/TraitorIconWhite.svg";
|
||||
import { consolex } from "../../../core/Consolex";
|
||||
import { EventBus } from "../../../core/EventBus";
|
||||
import { Cell, PlayerActions, TerraNullius } from "../../../core/game/Game";
|
||||
import {
|
||||
Cell,
|
||||
PlayerActions,
|
||||
TerraNullius,
|
||||
UnitType,
|
||||
} from "../../../core/game/Game";
|
||||
import { TileRef } from "../../../core/game/GameMap";
|
||||
import { GameView, PlayerView } from "../../../core/game/GameView";
|
||||
import { ClientID } from "../../../core/Schemas";
|
||||
@@ -345,9 +350,12 @@ export class RadialMenu implements Layer {
|
||||
actions: PlayerActions,
|
||||
tile: TileRef,
|
||||
) {
|
||||
this.activateMenuElement(Slot.Build, "#ebe250", buildIcon, () => {
|
||||
this.buildMenu.showMenu(tile);
|
||||
});
|
||||
if (!this.g.inSpawnPhase()) {
|
||||
this.activateMenuElement(Slot.Build, "#ebe250", buildIcon, () => {
|
||||
this.buildMenu.showMenu(tile);
|
||||
});
|
||||
}
|
||||
|
||||
if (this.g.hasOwner(tile)) {
|
||||
this.activateMenuElement(Slot.Info, "#64748B", infoIcon, () => {
|
||||
this.playerPanel.show(actions, tile);
|
||||
@@ -374,15 +382,28 @@ export class RadialMenu implements Layer {
|
||||
);
|
||||
});
|
||||
}
|
||||
if (actions.canBoat) {
|
||||
if (
|
||||
actions.buildableUnits.find((bu) => bu.type == UnitType.TransportShip)
|
||||
?.canBuild
|
||||
) {
|
||||
this.activateMenuElement(Slot.Boat, "#3f6ab1", boatIcon, () => {
|
||||
this.eventBus.emit(
|
||||
new SendBoatAttackIntentEvent(
|
||||
this.g.owner(tile).id(),
|
||||
this.clickedCell,
|
||||
this.uiState.attackRatio * myPlayer.troops(),
|
||||
),
|
||||
);
|
||||
// BestTransportShipSpawn is an expensive operation, so
|
||||
// we calculate it here and send the spawn tile to other clients.
|
||||
myPlayer.bestTransportShipSpawn(tile).then((spawn) => {
|
||||
let spawnTile: Cell | null = null;
|
||||
if (spawn !== false) {
|
||||
spawnTile = new Cell(this.g.x(spawn), this.g.y(spawn));
|
||||
}
|
||||
|
||||
this.eventBus.emit(
|
||||
new SendBoatAttackIntentEvent(
|
||||
this.g.owner(tile).id(),
|
||||
this.clickedCell,
|
||||
this.uiState.attackRatio * myPlayer.troops(),
|
||||
spawnTile,
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
if (actions.canAttack) {
|
||||
|
||||
@@ -119,6 +119,14 @@ export class TerritoryLayer implements Layer {
|
||||
if (!centerTile) {
|
||||
continue;
|
||||
}
|
||||
let color = this.theme.spawnHighlightColor();
|
||||
if (
|
||||
this.game.myPlayer() != null &&
|
||||
this.game.myPlayer() != human &&
|
||||
this.game.myPlayer().isFriendly(human)
|
||||
) {
|
||||
color = this.theme.selfColor();
|
||||
}
|
||||
for (const tile of this.game.bfs(
|
||||
centerTile,
|
||||
euclDistFN(centerTile, 9, true),
|
||||
@@ -126,7 +134,7 @@ export class TerritoryLayer implements Layer {
|
||||
if (!this.game.hasOwner(tile)) {
|
||||
this.paintHighlightCell(
|
||||
new Cell(this.game.x(tile), this.game.y(tile)),
|
||||
this.theme.spawnHighlightColor(),
|
||||
color,
|
||||
255,
|
||||
);
|
||||
}
|
||||
@@ -259,15 +267,13 @@ export class TerritoryLayer implements Layer {
|
||||
)
|
||||
.filter((u) => u.unit.owner() == owner).length > 0
|
||||
) {
|
||||
const useDefendedBorderColor = playerIsFocused
|
||||
? this.theme.focusedDefendedBorderColor()
|
||||
: this.theme.defendedBorderColor(owner);
|
||||
this.paintCell(
|
||||
this.game.x(tile),
|
||||
this.game.y(tile),
|
||||
useDefendedBorderColor,
|
||||
255,
|
||||
);
|
||||
const borderColors = this.theme.defendedBorderColors(owner);
|
||||
const x = this.game.x(tile);
|
||||
const y = this.game.y(tile);
|
||||
const lightTile =
|
||||
(x % 2 == 0 && y % 2 == 0) || (y % 2 == 1 && x % 2 == 1);
|
||||
const borderColor = lightTile ? borderColors.light : borderColors.dark;
|
||||
this.paintCell(x, y, borderColor, 255);
|
||||
} else {
|
||||
const useBorderColor = playerIsFocused
|
||||
? this.theme.focusedBorderColor()
|
||||
|
||||
@@ -3,11 +3,7 @@ import { EventBus } from "../../../core/EventBus";
|
||||
import { ClientID } from "../../../core/Schemas";
|
||||
import { Theme } from "../../../core/configuration/Config";
|
||||
import { UnitType } from "../../../core/game/Game";
|
||||
import {
|
||||
euclDistFN,
|
||||
manhattanDistFN,
|
||||
TileRef,
|
||||
} from "../../../core/game/GameMap";
|
||||
import { TileRef } from "../../../core/game/GameMap";
|
||||
import { GameUpdateType } from "../../../core/game/GameUpdates";
|
||||
import { GameView, PlayerView, UnitView } from "../../../core/game/GameView";
|
||||
import {
|
||||
@@ -19,7 +15,11 @@ import { MoveWarshipIntentEvent } from "../../Transport";
|
||||
import { TransformHandler } from "../TransformHandler";
|
||||
import { Layer } from "./Layer";
|
||||
|
||||
import { getColoredSprite, loadAllSprites } from "../SpriteLoader";
|
||||
import {
|
||||
getColoredSprite,
|
||||
isSpriteReady,
|
||||
loadAllSprites,
|
||||
} from "../SpriteLoader";
|
||||
|
||||
enum Relationship {
|
||||
Self,
|
||||
@@ -69,9 +69,8 @@ export class UnitLayer implements Layer {
|
||||
if (this.myPlayer == null) {
|
||||
this.myPlayer = this.game.playerByClientID(this.clientID);
|
||||
}
|
||||
this.game.updatesSinceLastTick()?.[GameUpdateType.Unit]?.forEach((unit) => {
|
||||
this.onUnitEvent(this.game.unit(unit.id));
|
||||
});
|
||||
|
||||
this.updateUnitsSprites();
|
||||
}
|
||||
|
||||
init() {
|
||||
@@ -198,11 +197,9 @@ export class UnitLayer implements Layer {
|
||||
this.canvas.height = this.game.height();
|
||||
this.transportShipTrailCanvas.width = this.game.width();
|
||||
this.transportShipTrailCanvas.height = this.game.height();
|
||||
this.game
|
||||
?.updatesSinceLastTick()
|
||||
?.[GameUpdateType.Unit]?.forEach((unit) => {
|
||||
this.onUnitEvent(this.game.unit(unit.id));
|
||||
});
|
||||
|
||||
this.updateUnitsSprites();
|
||||
|
||||
this.boatToTrail.forEach((trail, unit) => {
|
||||
for (const t of trail) {
|
||||
this.paintCell(
|
||||
@@ -217,6 +214,34 @@ export class UnitLayer implements Layer {
|
||||
});
|
||||
}
|
||||
|
||||
private updateUnitsSprites() {
|
||||
const unitsToUpdate = this.game
|
||||
.updatesSinceLastTick()
|
||||
?.[GameUpdateType.Unit]?.map((unit) => this.game.unit(unit.id));
|
||||
unitsToUpdate
|
||||
?.filter((UnitView) => isSpriteReady(UnitView.type()))
|
||||
.forEach((unitView) => {
|
||||
this.clearUnitCells(unitView);
|
||||
});
|
||||
unitsToUpdate?.forEach((unitView) => {
|
||||
this.onUnitEvent(unitView);
|
||||
});
|
||||
}
|
||||
|
||||
private clearUnitCells(unit: UnitView) {
|
||||
const sprite = getColoredSprite(unit, this.theme);
|
||||
const clearsize = sprite.width + 1;
|
||||
|
||||
const lastX = this.game.x(unit.lastTile());
|
||||
const lastY = this.game.y(unit.lastTile());
|
||||
this.context.clearRect(
|
||||
lastX - clearsize / 2,
|
||||
lastY - clearsize / 2,
|
||||
clearsize,
|
||||
clearsize,
|
||||
);
|
||||
}
|
||||
|
||||
private relationship(unit: UnitView): Relationship {
|
||||
if (this.myPlayer == null) {
|
||||
return Relationship.Enemy;
|
||||
@@ -264,22 +289,10 @@ export class UnitLayer implements Layer {
|
||||
}
|
||||
|
||||
private handleWarShipEvent(unit: UnitView) {
|
||||
const rel = this.relationship(unit);
|
||||
|
||||
// Clear previous area
|
||||
for (const t of this.game.bfs(
|
||||
unit.lastTile(),
|
||||
euclDistFN(unit.lastTile(), 6, false),
|
||||
)) {
|
||||
this.clearCell(this.game.x(t), this.game.y(t));
|
||||
}
|
||||
|
||||
if (unit.isActive()) {
|
||||
if (unit.warshipTargetId()) {
|
||||
this.drawSprite(unit, colord({ r: 200, b: 0, g: 0 }));
|
||||
} else {
|
||||
this.drawSprite(unit);
|
||||
}
|
||||
if (unit.warshipTargetId()) {
|
||||
this.drawSprite(unit, colord({ r: 200, b: 0, g: 0 }));
|
||||
} else {
|
||||
this.drawSprite(unit);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -317,46 +330,11 @@ export class UnitLayer implements Layer {
|
||||
|
||||
// interception missle from SAM
|
||||
private handleMissileEvent(unit: UnitView) {
|
||||
const rel = this.relationship(unit);
|
||||
const range = 2;
|
||||
|
||||
for (const t of this.game.bfs(
|
||||
unit.lastTile(),
|
||||
euclDistFN(unit.lastTile(), range, false),
|
||||
)) {
|
||||
this.clearCell(this.game.x(t), this.game.y(t));
|
||||
}
|
||||
|
||||
if (unit.isActive()) {
|
||||
this.drawSprite(unit);
|
||||
}
|
||||
this.drawSprite(unit);
|
||||
}
|
||||
|
||||
private handleNuke(unit: UnitView) {
|
||||
let range = 0;
|
||||
|
||||
switch (unit.type()) {
|
||||
case UnitType.AtomBomb:
|
||||
range = 4;
|
||||
break;
|
||||
case UnitType.HydrogenBomb:
|
||||
range = 6;
|
||||
break;
|
||||
case UnitType.MIRV:
|
||||
range = 9;
|
||||
break;
|
||||
}
|
||||
|
||||
for (const t of this.game.bfs(
|
||||
unit.lastTile(),
|
||||
euclDistFN(unit.lastTile(), range, false),
|
||||
)) {
|
||||
this.clearCell(this.game.x(t), this.game.y(t));
|
||||
}
|
||||
|
||||
if (unit.isActive()) {
|
||||
this.drawSprite(unit);
|
||||
}
|
||||
this.drawSprite(unit);
|
||||
}
|
||||
|
||||
private handleMIRVWarhead(unit: UnitView) {
|
||||
@@ -377,17 +355,7 @@ export class UnitLayer implements Layer {
|
||||
}
|
||||
|
||||
private handleTradeShipEvent(unit: UnitView) {
|
||||
// Clear previous area
|
||||
for (const t of this.game.bfs(
|
||||
unit.lastTile(),
|
||||
euclDistFN(unit.lastTile(), 3, false),
|
||||
)) {
|
||||
this.clearCell(this.game.x(t), this.game.y(t));
|
||||
}
|
||||
|
||||
if (unit.isActive()) {
|
||||
this.drawSprite(unit);
|
||||
}
|
||||
this.drawSprite(unit);
|
||||
}
|
||||
|
||||
private handleBoatEvent(unit: UnitView) {
|
||||
@@ -399,29 +367,21 @@ export class UnitLayer implements Layer {
|
||||
const trail = this.boatToTrail.get(unit);
|
||||
trail.push(unit.lastTile());
|
||||
|
||||
// Clear previous area
|
||||
for (const t of this.game.bfs(
|
||||
unit.lastTile(),
|
||||
manhattanDistFN(unit.lastTile(), 4),
|
||||
)) {
|
||||
this.clearCell(this.game.x(t), this.game.y(t));
|
||||
// Paint trail
|
||||
for (const t of trail.slice(-1)) {
|
||||
this.paintCell(
|
||||
this.game.x(t),
|
||||
this.game.y(t),
|
||||
rel,
|
||||
this.theme.territoryColor(unit.owner()),
|
||||
150,
|
||||
this.transportShipTrailContext,
|
||||
);
|
||||
}
|
||||
|
||||
if (unit.isActive()) {
|
||||
// Paint trail
|
||||
for (const t of trail.slice(-1)) {
|
||||
this.paintCell(
|
||||
this.game.x(t),
|
||||
this.game.y(t),
|
||||
rel,
|
||||
this.theme.territoryColor(unit.owner()),
|
||||
150,
|
||||
this.transportShipTrailContext,
|
||||
);
|
||||
}
|
||||
this.drawSprite(unit);
|
||||
|
||||
this.drawSprite(unit);
|
||||
} else {
|
||||
if (!unit.isActive()) {
|
||||
for (const t of trail) {
|
||||
this.clearCell(
|
||||
this.game.x(t),
|
||||
@@ -492,7 +452,18 @@ export class UnitLayer implements Layer {
|
||||
let alternateViewColor = null;
|
||||
|
||||
if (this.alternateView) {
|
||||
const rel = this.relationship(unit);
|
||||
let rel = this.relationship(unit);
|
||||
if (unit.type() == UnitType.TradeShip && unit.dstPortId() != null) {
|
||||
const target = this.game.unit(unit.dstPortId())?.owner();
|
||||
const myPlayer = this.game.myPlayer();
|
||||
if (myPlayer != null && target != null) {
|
||||
if (myPlayer == target) {
|
||||
rel = Relationship.Self;
|
||||
} else if (myPlayer.isFriendly(target)) {
|
||||
rel = Relationship.Ally;
|
||||
}
|
||||
}
|
||||
}
|
||||
switch (rel) {
|
||||
case Relationship.Self:
|
||||
alternateViewColor = this.theme.selfColor();
|
||||
@@ -513,12 +484,14 @@ export class UnitLayer implements Layer {
|
||||
alternateViewColor,
|
||||
);
|
||||
|
||||
this.context.drawImage(
|
||||
sprite,
|
||||
Math.round(x - sprite.width / 2),
|
||||
Math.round(y - sprite.height / 2),
|
||||
sprite.width,
|
||||
sprite.width,
|
||||
);
|
||||
if (unit.isActive()) {
|
||||
this.context.drawImage(
|
||||
sprite,
|
||||
Math.round(x - sprite.width / 2),
|
||||
Math.round(y - sprite.height / 2),
|
||||
sprite.width,
|
||||
sprite.width,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,38 +1,24 @@
|
||||
import { LitElement, css, html } from "lit";
|
||||
import { customElement, state } from "lit/decorators.js";
|
||||
import mastersIcon from "../../../../resources/images/MastersIcon.png";
|
||||
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 { PseudoRandom } from "../../../core/PseudoRandom";
|
||||
import { simpleHash } from "../../../core/Util";
|
||||
import { SendWinnerEvent } from "../../Transport";
|
||||
import { Layer } from "./Layer";
|
||||
|
||||
// Add this at the top of your file
|
||||
declare global {
|
||||
interface Window {
|
||||
adsbygoogle: unknown[];
|
||||
}
|
||||
}
|
||||
|
||||
// Add this at the top of your file
|
||||
declare let adsbygoogle: unknown[];
|
||||
|
||||
@customElement("win-modal")
|
||||
export class WinModal extends LitElement implements Layer {
|
||||
public game: GameView;
|
||||
public eventBus: EventBus;
|
||||
|
||||
private rand: PseudoRandom;
|
||||
|
||||
private hasShownDeathModal = false;
|
||||
|
||||
@state()
|
||||
isVisible = false;
|
||||
|
||||
private _title: string;
|
||||
private won: boolean;
|
||||
|
||||
// Override to prevent shadow DOM creation
|
||||
createRenderRoot() {
|
||||
@@ -53,7 +39,7 @@ export class WinModal extends LitElement implements Layer {
|
||||
box-shadow: 0 0 20px rgba(0, 0, 0, 0.5);
|
||||
backdrop-filter: blur(5px);
|
||||
color: white;
|
||||
width: 300px;
|
||||
width: 350px;
|
||||
transition:
|
||||
opacity 0.3s ease-in-out,
|
||||
visibility 0.3s ease-in-out;
|
||||
@@ -77,7 +63,7 @@ export class WinModal extends LitElement implements Layer {
|
||||
|
||||
.win-modal h2 {
|
||||
margin: 0 0 15px 0;
|
||||
font-size: 24px;
|
||||
font-size: 26px;
|
||||
text-align: center;
|
||||
color: white;
|
||||
}
|
||||
@@ -127,7 +113,7 @@ export class WinModal extends LitElement implements Layer {
|
||||
}
|
||||
|
||||
.win-modal h2 {
|
||||
font-size: 20px;
|
||||
font-size: 26px;
|
||||
}
|
||||
|
||||
.win-modal button {
|
||||
@@ -160,7 +146,41 @@ export class WinModal extends LitElement implements Layer {
|
||||
|
||||
innerHtml() {
|
||||
return html`
|
||||
<div style="text-align: center; margin: 15px 0; line-height: 1.5;"></div>
|
||||
<div
|
||||
style="
|
||||
text-align: center;
|
||||
margin: 10px 0;
|
||||
line-height: 1.5;
|
||||
background-image: url(${mastersIcon});
|
||||
background-size: 100px;
|
||||
background-position: center;
|
||||
background-repeat: no-repeat;
|
||||
background-blend-mode: overlay;
|
||||
position: relative;
|
||||
"
|
||||
>
|
||||
<div
|
||||
style="
|
||||
margin: 10px 0;
|
||||
padding: 14px;
|
||||
background: rgba(0, 0, 0, 0.76);
|
||||
border-radius: 5px;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
font-size: 22px;
|
||||
"
|
||||
>
|
||||
Watch the best compete in the
|
||||
<br />
|
||||
<a
|
||||
href="https://openfrontmaster.com/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style="color: #00bfff; font-weight: bold; text-decoration: underline;"
|
||||
>OpenFront Masters</a
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -179,9 +199,7 @@ export class WinModal extends LitElement implements Layer {
|
||||
window.location.href = "/";
|
||||
}
|
||||
|
||||
init() {
|
||||
this.rand = new PseudoRandom(simpleHash(this.game.myClientID()));
|
||||
}
|
||||
init() {}
|
||||
|
||||
tick() {
|
||||
const myPlayer = this.game.myPlayer();
|
||||
@@ -194,7 +212,6 @@ export class WinModal extends LitElement implements Layer {
|
||||
) {
|
||||
this.hasShownDeathModal = true;
|
||||
this._title = "You died";
|
||||
this.won = false;
|
||||
this.show();
|
||||
}
|
||||
this.game.updatesSinceLastTick()[GameUpdateType.Win].forEach((wu) => {
|
||||
@@ -204,10 +221,8 @@ export class WinModal extends LitElement implements Layer {
|
||||
);
|
||||
if (wu.winner == this.game.myPlayer()?.team()) {
|
||||
this._title = "Your team won!";
|
||||
this.won = true;
|
||||
} else {
|
||||
this._title = `${wu.winner} team has won!`;
|
||||
this.won = false;
|
||||
}
|
||||
this.show();
|
||||
} else {
|
||||
@@ -219,10 +234,8 @@ export class WinModal extends LitElement implements Layer {
|
||||
);
|
||||
if (winner == this.game.myPlayer()) {
|
||||
this._title = "You Won!";
|
||||
this.won = true;
|
||||
} else {
|
||||
this._title = `${winner.name()} has won!`;
|
||||
this.won = false;
|
||||
}
|
||||
this.show();
|
||||
}
|
||||
|
||||
+39
-4
@@ -203,17 +203,34 @@
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
<div class="l-header__highlightText">v21.2</div>
|
||||
<div class="l-header__highlightText">v22.0</div>
|
||||
</div>
|
||||
</header>
|
||||
<div class="bg-image"></div>
|
||||
|
||||
<dark-mode-button></dark-mode-button>
|
||||
<!-- Main container with responsive padding -->
|
||||
<main class="flex justify-center flex-grow">
|
||||
<div class="container pt-12">
|
||||
<o-button
|
||||
id="login-discord"
|
||||
title="Initializing..."
|
||||
disable="true"
|
||||
block
|
||||
></o-button>
|
||||
|
||||
<o-button
|
||||
id="logout-discord"
|
||||
title="Log out"
|
||||
translationKey="main.log_out"
|
||||
visible="false"
|
||||
block
|
||||
></o-button>
|
||||
|
||||
<div class="container__row">
|
||||
<flag-input class="w-[20%] md:w-[15%]"></flag-input>
|
||||
<username-input class="w-full"></username-input>
|
||||
<news-button class="mt-3"></news-button>
|
||||
</div>
|
||||
<div></div>
|
||||
<div>
|
||||
@@ -234,6 +251,12 @@
|
||||
block
|
||||
secondary
|
||||
></o-button>
|
||||
<!-- <o-button
|
||||
id="chat-button"
|
||||
title="Chat Test"
|
||||
block
|
||||
secondary
|
||||
></o-button> -->
|
||||
</div>
|
||||
|
||||
<o-button
|
||||
@@ -291,6 +314,7 @@
|
||||
class="w-full sm:w-2/3 sm:fixed sm:right-0 sm:bottom-0 sm:flex justify-end"
|
||||
style="pointer-events: none"
|
||||
>
|
||||
<chat-display></chat-display>
|
||||
<events-display></events-display>
|
||||
</div>
|
||||
<div class="w-full sm:w-1/3 md:max-w-72" style="pointer-events: auto">
|
||||
@@ -318,18 +342,27 @@
|
||||
>
|
||||
Wiki
|
||||
</a>
|
||||
<a target="_blank" href="https://discord.gg/openfront" class="t-link">
|
||||
<a
|
||||
target="_blank"
|
||||
href="https://discord.gg/jRpxXvG42t"
|
||||
class="t-link"
|
||||
>
|
||||
<span data-i18n="main.join_discord"> Join the Discord! </span>
|
||||
</a>
|
||||
</div>
|
||||
<div class="l-footer__col t-text-white">
|
||||
© 2025
|
||||
<a
|
||||
href="https://github.com/openfrontio/OpenFrontIO"
|
||||
class="t-link"
|
||||
target="_blank"
|
||||
>
|
||||
OpenFront.io
|
||||
©2025 OpenFront™
|
||||
</a>
|
||||
<a href="/privacy-policy.html" class="t-link" target="_blank">
|
||||
Privacy Policy
|
||||
</a>
|
||||
<a href="/terms-of-service.html" class="t-link" target="_blank">
|
||||
Terms of Service
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
@@ -347,9 +380,11 @@
|
||||
<player-panel></player-panel>
|
||||
<help-modal></help-modal>
|
||||
<dark-mode-button></dark-mode-button>
|
||||
<chat-modal></chat-modal>
|
||||
<user-setting></user-setting>
|
||||
<multi-tab-modal></multi-tab-modal>
|
||||
<flag-input-modal></flag-input-modal>
|
||||
<news-modal></news-modal>
|
||||
<div
|
||||
id="language-modal"
|
||||
class="fixed inset-0 bg-black bg-opacity-50 z-50 hidden flex justify-center items-center"
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
import { decodeJwt } from "jose";
|
||||
import {
|
||||
RefreshResponseSchema,
|
||||
TokenPayload,
|
||||
TokenPayloadSchema,
|
||||
UserMeResponse,
|
||||
UserMeResponseSchema,
|
||||
} from "./ApiSchemas";
|
||||
|
||||
function getAudience() {
|
||||
const { hostname } = new URL(window.location.href);
|
||||
const domainname = hostname.split(".").slice(-2).join(".");
|
||||
return domainname;
|
||||
}
|
||||
|
||||
function getApiBase() {
|
||||
const domainname = getAudience();
|
||||
return domainname === "localhost"
|
||||
? (localStorage.getItem("apiHost") ?? "http://localhost:8787")
|
||||
: `https://api.${domainname}`;
|
||||
}
|
||||
|
||||
function getToken(): string | null {
|
||||
const { hash } = window.location;
|
||||
if (hash.startsWith("#")) {
|
||||
const params = new URLSearchParams(hash.slice(1));
|
||||
const token = params.get("token");
|
||||
if (token) {
|
||||
localStorage.setItem("token", token);
|
||||
}
|
||||
// Clean the URL
|
||||
history.replaceState(
|
||||
null,
|
||||
"",
|
||||
window.location.pathname + window.location.search,
|
||||
);
|
||||
}
|
||||
return localStorage.getItem("token");
|
||||
}
|
||||
|
||||
export function discordLogin() {
|
||||
window.location.href = `${getApiBase()}/login/discord?redirect_uri=${window.location.href}`;
|
||||
}
|
||||
|
||||
export async function logOut(allSessions: boolean = false) {
|
||||
const token = localStorage.getItem("token");
|
||||
if (token === null) return;
|
||||
localStorage.removeItem("token");
|
||||
__isLoggedIn = false;
|
||||
|
||||
const response = await fetch(
|
||||
getApiBase() + allSessions ? "/revoke" : "/logout",
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
authorization: `Bearer ${token}`,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
if (response.ok === false) {
|
||||
console.error("Logout failed", response);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
let __isLoggedIn: TokenPayload | false | undefined = undefined;
|
||||
export function isLoggedIn(): TokenPayload | false {
|
||||
if (__isLoggedIn === undefined) {
|
||||
__isLoggedIn = _isLoggedIn();
|
||||
}
|
||||
return __isLoggedIn;
|
||||
}
|
||||
export function _isLoggedIn(): TokenPayload | false {
|
||||
try {
|
||||
const token = getToken();
|
||||
if (!token) {
|
||||
// console.log("No token found");
|
||||
return false;
|
||||
}
|
||||
|
||||
// Verify the JWT (requires browser support)
|
||||
// const jwks = createRemoteJWKSet(
|
||||
// new URL(getApiBase() + "/.well-known/jwks.json"),
|
||||
// );
|
||||
// const { payload, protectedHeader } = await jwtVerify(token, jwks, {
|
||||
// issuer: getApiBase(),
|
||||
// audience: getAudience(),
|
||||
// });
|
||||
|
||||
// Decode the JWT
|
||||
const payload = decodeJwt(token);
|
||||
const { iss, aud, exp, iat } = payload;
|
||||
|
||||
if (iss !== getApiBase()) {
|
||||
// JWT was not issued by the correct server
|
||||
console.error(
|
||||
'unexpected "iss" claim value',
|
||||
// JSON.stringify(payload, null, 2),
|
||||
);
|
||||
logOut();
|
||||
return false;
|
||||
}
|
||||
if (aud !== getAudience()) {
|
||||
// JWT was not issued for this website
|
||||
console.error(
|
||||
'unexpected "aud" claim value',
|
||||
// JSON.stringify(payload, null, 2),
|
||||
);
|
||||
logOut();
|
||||
return false;
|
||||
}
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
if (exp !== undefined && now >= exp) {
|
||||
// JWT expired
|
||||
console.error(
|
||||
'after "exp" claim value',
|
||||
// JSON.stringify(payload, null, 2),
|
||||
);
|
||||
logOut();
|
||||
return false;
|
||||
}
|
||||
const refreshAge: number = 6 * 3600; // 6 hours
|
||||
if (iat !== undefined && now >= iat + refreshAge) {
|
||||
console.log("Refreshing access token...");
|
||||
postRefresh().then((success) => {
|
||||
if (success) {
|
||||
console.log("Refreshed access token successfully.");
|
||||
} else {
|
||||
console.error("Failed to refresh access token.");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const result = TokenPayloadSchema.safeParse(payload);
|
||||
if (!result.success) {
|
||||
// Invalid response
|
||||
console.error(
|
||||
"Invalid payload",
|
||||
// JSON.stringify(payload),
|
||||
JSON.stringify(result.error),
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
return result.data;
|
||||
} catch (e) {
|
||||
console.log(e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function postRefresh(): Promise<boolean> {
|
||||
try {
|
||||
const token = getToken();
|
||||
if (!token) return false;
|
||||
|
||||
// Refresh the JWT
|
||||
const response = await fetch(getApiBase() + "/refresh", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
authorization: `Bearer ${token}`,
|
||||
},
|
||||
});
|
||||
if (response.status !== 200) return false;
|
||||
const body = await response.json();
|
||||
const result = RefreshResponseSchema.safeParse(body);
|
||||
if (!result.success) {
|
||||
console.error(
|
||||
"Invalid response",
|
||||
JSON.stringify(body),
|
||||
JSON.stringify(result.error),
|
||||
);
|
||||
return false;
|
||||
}
|
||||
localStorage.setItem("token", result.data.token);
|
||||
return true;
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getUserMe(): Promise<UserMeResponse | false> {
|
||||
try {
|
||||
const token = getToken();
|
||||
if (!token) return false;
|
||||
|
||||
// Get the user object
|
||||
const response = await fetch(getApiBase() + "/users/@me", {
|
||||
headers: {
|
||||
authorization: `Bearer ${token}`,
|
||||
},
|
||||
});
|
||||
if (response.status !== 200) return false;
|
||||
const body = await response.json();
|
||||
const result = UserMeResponseSchema.safeParse(body);
|
||||
if (!result.success) {
|
||||
console.error(
|
||||
"Invalid response",
|
||||
JSON.stringify(body),
|
||||
JSON.stringify(result.error),
|
||||
);
|
||||
return false;
|
||||
}
|
||||
return result.data;
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@
|
||||
@import url("./styles/layout/container.css");
|
||||
@import url("./styles/components/button.css");
|
||||
@import url("./styles/components/modal.css");
|
||||
@import url("./styles/modal/chat.css");
|
||||
@import url("./styles/components/setting.css");
|
||||
@import url("./styles/components/controls.css");
|
||||
* {
|
||||
@@ -195,6 +196,11 @@ label.option-card:hover {
|
||||
margin: 8px 0px 0px 0px;
|
||||
}
|
||||
|
||||
#lobbyIdInput {
|
||||
font-family: monospace;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.lobby-id-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -78,3 +78,14 @@
|
||||
font-size: 14px;
|
||||
color: #ccc;
|
||||
}
|
||||
|
||||
.setting-input.keybind:hover .key,
|
||||
.setting-input.keybind:focus .key {
|
||||
background-color: #333;
|
||||
box-shadow: 0 2px 0 #222;
|
||||
}
|
||||
|
||||
.setting-input.keybind.listening .key {
|
||||
background-color: #1d4ed8; /* blue-700 */
|
||||
box-shadow: 0 2px 0 #0f172a; /* darker blue */
|
||||
}
|
||||
|
||||
@@ -22,6 +22,10 @@
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.setting-item.column {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
@keyframes rainbow-background {
|
||||
0% {
|
||||
background-position: 0% 50%;
|
||||
@@ -64,6 +68,20 @@
|
||||
z-index: 9999;
|
||||
}
|
||||
|
||||
.setting-popup {
|
||||
position: fixed;
|
||||
top: 40px;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%) scale(0.9);
|
||||
padding: 16px 24px;
|
||||
background: rgba(0, 0, 0, 0.8);
|
||||
color: #fff;
|
||||
font-size: 20px;
|
||||
border-radius: 12px;
|
||||
animation: fadePop_2 10s ease-out forwards;
|
||||
z-index: 9999;
|
||||
}
|
||||
|
||||
@keyframes fadePop {
|
||||
0% {
|
||||
opacity: 0;
|
||||
@@ -82,6 +100,25 @@
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fadePop_2 {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translate(-50%, -50%) scale(0.6);
|
||||
}
|
||||
5% {
|
||||
opacity: 1;
|
||||
transform: translate(-50%, -50%) scale(1.05);
|
||||
}
|
||||
95% {
|
||||
opacity: 1;
|
||||
transform: translate(-50%, -50%) scale(1.05);
|
||||
}
|
||||
100% {
|
||||
opacity: 0;
|
||||
transform: translate(-50%, -50%) scale(0.9);
|
||||
}
|
||||
}
|
||||
|
||||
.setting-item:hover {
|
||||
background: #2a2a2a;
|
||||
}
|
||||
@@ -158,17 +195,14 @@
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.setting-input.slider::-moz-range-thumb {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 50%;
|
||||
background: #fff;
|
||||
border: 2px solid #2196f3;
|
||||
cursor: pointer;
|
||||
.setting-input.slider::-moz-range-track {
|
||||
background-color: #444;
|
||||
height: 10px;
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
.setting-input.slider::-moz-range-track {
|
||||
background: linear-gradient(to right, #2196f3 50%, #444 50%);
|
||||
.setting-input.slider::-moz-range-progress {
|
||||
background-color: #2196f3;
|
||||
height: 10px;
|
||||
border-radius: 5px;
|
||||
}
|
||||
@@ -255,3 +289,38 @@
|
||||
white-space: normal;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.setting-keybind-box {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.setting-keybind-description {
|
||||
flex: 1;
|
||||
font-size: 0.75rem;
|
||||
color: #e5e5e5;
|
||||
word-break: break-word;
|
||||
overflow-wrap: break-word;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.setting-key {
|
||||
background-color: black;
|
||||
color: white;
|
||||
font-weight: 600;
|
||||
padding: 4px 12px;
|
||||
border-radius: 6px;
|
||||
font-family: monospace;
|
||||
font-size: 0.875rem;
|
||||
box-shadow: 0 2px 0 #444;
|
||||
white-space: nowrap;
|
||||
user-select: none;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.setting-key:focus {
|
||||
outline: 2px solid #3b82f6;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
/* .w. */
|
||||
|
||||
.chat-columns {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
padding: 12px;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.chat-column {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
min-width: 120px;
|
||||
}
|
||||
|
||||
.column-title {
|
||||
font-weight: bold;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.chat-option-button {
|
||||
background: #333;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 8px 12px;
|
||||
border-radius: 4px;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.chat-option-button.selected {
|
||||
background-color: #66c;
|
||||
}
|
||||
|
||||
.chat-preview {
|
||||
margin: 10px 12px;
|
||||
padding: 10px;
|
||||
background: #222;
|
||||
color: white;
|
||||
border-radius: 6px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.chat-send {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
padding: 0 12px 12px;
|
||||
}
|
||||
|
||||
.chat-send-button {
|
||||
background: #4caf50;
|
||||
color: white;
|
||||
padding: 8px 16px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.chat-column {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
min-width: 140px;
|
||||
}
|
||||
|
||||
.player-search-input {
|
||||
padding: 6px 8px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid #666;
|
||||
font-size: 14px;
|
||||
outline: none;
|
||||
|
||||
background-color: #fff;
|
||||
color: #000;
|
||||
}
|
||||
|
||||
.player-scroll-area {
|
||||
max-height: 240px;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
padding-right: 4px;
|
||||
}
|
||||
|
||||
.phrase-scroll-area {
|
||||
max-height: 280px;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
padding-right: 4px;
|
||||
}
|
||||
@@ -1,10 +1,14 @@
|
||||
import africa from "../../../resources/maps/AfricaThumb.webp";
|
||||
import asia from "../../../resources/maps/AsiaThumb.webp";
|
||||
import australia from "../../../resources/maps/AustraliaThumb.webp";
|
||||
import baikal from "../../../resources/maps/BaikalThumb.webp";
|
||||
import betweenTwoSeas from "../../../resources/maps/BetweenTwoSeasThumb.webp";
|
||||
import blackSea from "../../../resources/maps/BlackSeaThumb.webp";
|
||||
import britannia from "../../../resources/maps/BritanniaThumb.webp";
|
||||
import deglaciatedAntarctica from "../../../resources/maps/DeglaciatedAntarcticaThumb.webp";
|
||||
import europeClassic from "../../../resources/maps/EuropeClassicThumb.webp";
|
||||
import europe from "../../../resources/maps/EuropeThumb.webp";
|
||||
import falklandislands from "../../../resources/maps/FalklandIslandsThumb.webp";
|
||||
import faroeislands from "../../../resources/maps/FaroeIslandsThumb.webp";
|
||||
import gatewayToTheAtlantic from "../../../resources/maps/GatewayToTheAtlanticThumb.webp";
|
||||
import iceland from "../../../resources/maps/IcelandThumb.webp";
|
||||
@@ -28,6 +32,8 @@ export function getMapsImage(map: GameMapType): string {
|
||||
return oceania;
|
||||
case GameMapType.Europe:
|
||||
return europe;
|
||||
case GameMapType.EuropeClassic:
|
||||
return europeClassic;
|
||||
case GameMapType.Mena:
|
||||
return mena;
|
||||
case GameMapType.NorthAmerica:
|
||||
@@ -60,6 +66,12 @@ export function getMapsImage(map: GameMapType): string {
|
||||
return knownworld;
|
||||
case GameMapType.FaroeIslands:
|
||||
return faroeislands;
|
||||
case GameMapType.DeglaciatedAntarctica:
|
||||
return deglaciatedAntarctica;
|
||||
case GameMapType.FalklandIslands:
|
||||
return falklandislands;
|
||||
case GameMapType.Baikal:
|
||||
return baikal;
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user