Merge branch 'main' of github.com:openfrontio/OpenFrontIO

This commit is contained in:
Evan
2025-03-02 18:53:50 -08:00
31 changed files with 2930 additions and 2710 deletions
+148 -129
View File
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+145 -128
View File
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1122 -1154
View File
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+379 -393
View File
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+10 -2
View File
@@ -232,7 +232,15 @@ export class HelpModal extends LitElement {
</tr>
<tr>
<td>Space</td>
<td>Alternate view</td>
<td>Alternate view (terrain/countries)</td>
</tr>
<tr>
<td>Shift + left click</td>
<td>Attack (when left click is set to open menu)</td>
</tr>
<tr>
<td>Ctrl + left click</td>
<td>Open build menu</td>
</tr>
<tr>
<td>C</td>
@@ -306,7 +314,7 @@ export class HelpModal extends LitElement {
<li class="mb-4">Pause/Unpause the game - Only available in single player mode.</li>
<li class="mb-4">Timer - Time passed since the start of the game.</li>
<li class="mb-4">Exit button.</li>
<li class="mb-4">Settings - Open the settings menu. Inside you can toggle the Alternate View, Dark Mode, and Emojis.</li>
<li class="mb-4">Settings - Open the settings menu. Inside you can toggle the Alternate View, Dark Mode, Emojis and action on left click.</li>
</ul>
</div>
</div>
+8 -3
View File
@@ -1,4 +1,5 @@
import { EventBus, GameEvent } from "../core/EventBus";
import { UserSettings } from "../core/game/UserSettings";
export class MouseUpEvent implements GameEvent {
constructor(
@@ -87,6 +88,8 @@ export class InputHandler {
private readonly PAN_SPEED = 5;
private readonly ZOOM_SPEED = 10;
private userSettings: UserSettings = new UserSettings();
constructor(
private canvas: HTMLCanvasElement,
private eventBus: EventBus,
@@ -276,10 +279,12 @@ export class InputHandler {
if (dist < 10) {
if (event.pointerType == "touch") {
event.preventDefault();
console.log("firing context menu event");
this.eventBus.emit(new ContextMenuEvent(event.clientX, event.clientY));
} else {
}
if (!this.userSettings.leftClickOpensMenu() || event.shiftKey) {
this.eventBus.emit(new MouseUpEvent(event.x, event.y));
} else {
this.eventBus.emit(new ContextMenuEvent(event.clientX, event.clientY));
}
}
}
+16 -3
View File
@@ -16,7 +16,7 @@ const button = ({
children,
}) => html`
<button
class="flex items-center justify-center p-1
class="flex items-center justify-center p-1
bg-opacity-70 bg-gray-700 text-opacity-90 text-white
border-none rounded cursor-pointer
hover:bg-opacity-60 hover:bg-gray-600
@@ -96,6 +96,10 @@ export class OptionsMenu extends LitElement implements Layer {
this.eventBus.emit(new RefreshGraphicsEvent());
}
private onToggleLeftClickOpensMenu() {
this.userSettings.toggleLeftClickOpenMenu();
}
init() {
console.log("init called from OptionsMenu");
this.showPauseButton =
@@ -137,8 +141,8 @@ export class OptionsMenu extends LitElement implements Layer {
children: this.isPaused ? "▶️" : "⏸",
})}
<div
class="w-14 h-8 lg:w-20 lg:h-10 flex items-center justify-center
bg-opacity-50 bg-gray-700 text-opacity-90 text-white
class="w-14 h-8 lg:w-20 lg:h-10 flex items-center justify-center
bg-opacity-50 bg-gray-700 text-opacity-90 text-white
rounded text-sm lg:text-xl"
>
${this.timer}
@@ -177,6 +181,15 @@ export class OptionsMenu extends LitElement implements Layer {
title: "Dark Mode",
children: "🌙: " + (this.userSettings.darkMode() ? "On" : "Off"),
})}
${button({
onClick: this.onToggleLeftClickOpensMenu,
title: "Left click",
children:
"🖱️: " +
(this.userSettings.leftClickOpensMenu()
? "Opens menu"
: "Attack"),
})}
</div>
</div>
`;
+46 -12
View File
@@ -4,7 +4,12 @@ import { EventBus } from "../../../core/EventBus";
import { GameView, PlayerView } from "../../../core/game/GameView";
import { Layer } from "./Layer";
import { MouseUpEvent } from "../../InputHandler";
import { AllPlayers, Player, PlayerActions } from "../../../core/game/Game";
import {
AllPlayers,
Player,
PlayerActions,
UnitType,
} from "../../../core/game/Game";
import { TileRef } from "../../../core/game/GameMap";
import { renderNumber, renderTroops } from "../../Utils";
import targetIcon from "../../../../resources/images/TargetIconWhite.svg";
@@ -128,6 +133,24 @@ export class PlayerPanel extends LitElement implements Layer {
this.requestUpdate();
}
getTotalNukesSent(): number {
const stats = this.actions.interaction?.stats;
if (!stats) {
return 0;
}
let sum = 0;
const nukes = stats.sentNukes[this.g.myPlayer().id()];
if (!nukes) {
return 0;
}
for (const nukeType in nukes) {
if (nukeType != UnitType.MIRVWarhead) {
sum += nukes[nukeType];
}
}
return sum;
}
render() {
if (!this.isVisible) {
return html``;
@@ -165,7 +188,7 @@ export class PlayerPanel extends LitElement implements Layer {
<!-- Close button -->
<button
@click=${this.handleClose}
class="absolute -top-2 -right-2 w-6 h-6 flex items-center justify-center
class="absolute -top-2 -right-2 w-6 h-6 flex items-center justify-center
bg-red-500 hover:bg-red-600 text-white rounded-full
text-sm font-bold transition-colors"
>
@@ -177,7 +200,7 @@ export class PlayerPanel extends LitElement implements Layer {
<div class="flex items-center gap-1 lg:gap-2">
<div
class="px-4 h-8 lg:h-10 flex items-center justify-center
bg-opacity-50 bg-gray-700 text-opacity-90 text-white
bg-opacity-50 bg-gray-700 text-opacity-90 text-white
rounded text-sm lg:text-xl w-full"
>
${other?.name()}
@@ -212,6 +235,7 @@ export class PlayerPanel extends LitElement implements Layer {
</div>
</div>
<!-- Embargo -->
<div class="flex flex-col gap-1">
<div class="text-white text-opacity-80 text-sm px-2">
Embargo against you
@@ -221,12 +245,22 @@ export class PlayerPanel extends LitElement implements Layer {
</div>
</div>
<!-- Stats -->
<div class="flex flex-col gap-1">
<div class="text-white text-opacity-80 text-sm px-2">
Nukes sent by them to you
</div>
<div class="bg-opacity-50 bg-gray-700 rounded p-2 text-white">
${this.getTotalNukesSent()}
</div>
</div>
<!-- Action buttons -->
<div class="flex justify-center gap-2">
${canTarget
? html`<button
@click=${(e) => this.handleTargetClick(e, other)}
class="w-10 h-10 flex items-center justify-center
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"
>
@@ -237,8 +271,8 @@ export class PlayerPanel extends LitElement implements Layer {
? html`<button
@click=${(e) =>
this.handleBreakAllianceClick(e, myPlayer, other)}
class="w-10 h-10 flex items-center justify-center
bg-opacity-50 bg-gray-700 hover:bg-opacity-70
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
@@ -252,8 +286,8 @@ export class PlayerPanel extends LitElement implements Layer {
? html`<button
@click=${(e) =>
this.handleAllianceClick(e, myPlayer, other)}
class="w-10 h-10 flex items-center justify-center
bg-opacity-50 bg-gray-700 hover:bg-opacity-70
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=${allianceIcon} alt="Alliance" class="w-6 h-6" />
@@ -262,8 +296,8 @@ export class PlayerPanel extends LitElement implements Layer {
${canDonate
? html`<button
@click=${(e) => this.handleDonateClick(e, myPlayer, other)}
class="w-10 h-10 flex items-center justify-center
bg-opacity-50 bg-gray-700 hover:bg-opacity-70
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=${donateIcon} alt="Donate" class="w-6 h-6" />
@@ -272,8 +306,8 @@ export class PlayerPanel extends LitElement implements Layer {
${canSendEmoji
? html`<button
@click=${(e) => this.handleEmojiClick(e, myPlayer, other)}
class="w-10 h-10 flex items-center justify-center
bg-opacity-50 bg-gray-700 hover:bg-opacity-70
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=${emojiIcon} alt="Emoji" class="w-6 h-6" />
+1
View File
@@ -168,6 +168,7 @@ export class GameRunner {
canBreakAlliance: player.isAlliedWith(other),
canDonate: player.canDonate(other),
canEmbargo: !player.hasEmbargoAgainst(other),
stats: this.game.stats().getPlayerStats(other.id()),
};
}
+8
View File
@@ -55,6 +55,14 @@ export class MirvExecution implements Execution {
this.pathFinder = PathFinder.Mini(mg, 10_000, true);
this.player = mg.player(this.senderID);
this.targetPlayer = this.mg.owner(this.dst);
this.mg
.stats()
.increaseNukeCount(
this.player.id(),
this.targetPlayer.id(),
UnitType.MIRV,
);
}
tick(ticks: number): void {
+11 -4
View File
@@ -8,6 +8,7 @@ import {
UnitType,
TerraNullius,
MessageType,
NukeType,
} from "../game/Game";
import { PseudoRandom } from "../PseudoRandom";
import { consolex } from "../Consolex";
@@ -22,10 +23,7 @@ export class NukeExecution implements Execution {
private random: PseudoRandom;
constructor(
private type:
| UnitType.AtomBomb
| UnitType.HydrogenBomb
| UnitType.MIRVWarhead,
private type: NukeType,
private senderID: PlayerID,
private dst: TileRef,
private src?: TileRef,
@@ -74,6 +72,14 @@ export class NukeExecution implements Execution {
target.id(),
);
}
this.mg
.stats()
.increaseNukeCount(
this.senderID,
target.id(),
this.nuke.type() as NukeType,
);
}
}
if (this.waitTicks > 0) {
@@ -157,6 +163,7 @@ export class NukeExecution implements Execution {
const prev = attacked.get(mp);
attacked.set(mp, prev + 1);
}
if (this.mg.isLand(tile)) {
this.mg.setFallout(tile, true);
}
+9
View File
@@ -9,6 +9,7 @@ import {
PlayerUpdate,
UnitUpdate,
} from "./GameUpdates";
import { PlayerStats, Stats } from "./Stats";
export type PlayerID = string;
export type Tick = number;
@@ -79,6 +80,11 @@ export enum UnitType {
MIRVWarhead = "MIRV Warhead",
Construction = "Construction",
}
export type NukeType =
| UnitType.AtomBomb
| UnitType.HydrogenBomb
| UnitType.MIRVWarhead
| UnitType.MIRV;
export enum Relation {
Hostile = 0,
@@ -379,6 +385,8 @@ export interface Game extends GameMap {
nations(): Nation[];
numTilesWithFallout(): number;
// Optional as it's not initialized before the end of spawn phase
stats(): Stats;
}
export interface PlayerActions {
@@ -408,6 +416,7 @@ export interface PlayerInteraction {
canTarget: boolean;
canDonate: boolean;
canEmbargo: boolean;
stats: PlayerStats;
}
export interface EmojiMessage {
+8 -1
View File
@@ -30,7 +30,8 @@ import { UnitImpl } from "./UnitImpl";
import { consolex } from "../Consolex";
import { GameMap, GameMapImpl, TileRef, TileUpdate } from "./GameMap";
import { DefenseGrid } from "./DefensePostGrid";
import { simpleHash } from "../Util";
import { StatsImpl } from "./StatsImpl";
import { Stats } from "./Stats";
export function createGame(
gameMap: GameMap,
@@ -67,6 +68,9 @@ export class GameImpl implements Game {
private updates: GameUpdates = createGameUpdatesMap();
private defenseGrid: DefenseGrid;
// Not initialized until the game has finished spawning
private _stats: StatsImpl = new StatsImpl();
constructor(
private _map: GameMap,
private miniGameMap: GameMap,
@@ -639,6 +643,9 @@ export class GameImpl implements Game {
numTilesWithFallout(): number {
return this._map.numTilesWithFallout();
}
stats(): Stats {
return this._stats;
}
}
// Or a more dynamic approach that will catch new enum values:
+15
View File
@@ -0,0 +1,15 @@
import { NukeType, PlayerID } from "./Game";
export interface PlayerStats {
sentNukes: {
// target
[key: PlayerID]: {
[key in NukeType]: number;
};
};
}
export interface Stats {
increaseNukeCount(sender: PlayerID, target: PlayerID, type: NukeType): void;
getPlayerStats(player: PlayerID): PlayerStats;
}
+34
View File
@@ -0,0 +1,34 @@
import { NukeType, Player, PlayerID, UnitType } from "./Game";
import { PlayerStats, Stats } from "./Stats";
interface StatsInternalData {
// player
[key: PlayerID]: PlayerStats;
}
export class StatsImpl implements Stats {
data: StatsInternalData = {};
_createUserData(sender: PlayerID, target: PlayerID): void {
if (!this.data[sender]) {
this.data[sender] = { sentNukes: {} };
}
if (!this.data[sender].sentNukes[target]) {
this.data[sender].sentNukes[target] = {
[UnitType.MIRV]: 0,
[UnitType.MIRVWarhead]: 0,
[UnitType.AtomBomb]: 0,
[UnitType.HydrogenBomb]: 0,
};
}
}
increaseNukeCount(sender: PlayerID, target: PlayerID, type: NukeType): void {
this._createUserData(sender, target);
this.data[sender].sentNukes[target][type]++;
}
getPlayerStats(player: PlayerID): PlayerStats {
return this.data[player];
}
}
+8
View File
@@ -20,6 +20,14 @@ export class UserSettings {
return this.get("settings.darkMode", false);
}
leftClickOpensMenu() {
return this.get("settings.leftClickOpensMenu", false);
}
toggleLeftClickOpenMenu() {
this.set("settings.leftClickOpensMenu", !this.leftClickOpensMenu());
}
toggleEmojis() {
this.set("settings.emojis", !this.emojis());
}
+96 -14
View File
@@ -7,7 +7,18 @@ import { fileURLToPath } from "url";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const mapName = "Africa";
const maps = [
"Africa",
"Asia",
"WorldMap",
"BlackSea",
"Europe",
"Mars",
"Mena",
"Oceania",
"NorthAmerica",
];
const min_island_size = 30;
interface Coord {
x: number;
@@ -26,7 +37,7 @@ class Terrain {
constructor(public type: TerrainType) {}
}
export async function loadTerrainMap(): Promise<void> {
async function loadTerrainMap(mapName: string): Promise<void> {
const imagePath = path.resolve(
__dirname,
"..",
@@ -39,8 +50,8 @@ export async function loadTerrainMap(): Promise<void> {
const readStream = createReadStream(imagePath);
const img = await decodePNGFromStream(readStream);
console.log("Image loaded successfully");
console.log("Image dimensions:", img.width, "x", img.height);
console.log(`${mapName}: Image loaded successfully`);
console.log(`${mapName}: `, "Image dimensions:", img.width, "x", img.height);
const terrain: Terrain[][] = Array(img.width)
.fill(null)
@@ -67,7 +78,8 @@ export async function loadTerrainMap(): Promise<void> {
}
}
removeSmallLakes(terrain);
removeSmallIslands(terrain);
removeSmallLakes(mapName, terrain);
const shorelineWaters = processShore(terrain);
processDistToLand(shorelineWaters, terrain);
processOcean(terrain);
@@ -79,7 +91,7 @@ export async function loadTerrainMap(): Promise<void> {
"maps",
mapName + ".bin",
);
fs.writeFile(outputPath, packTerrain(terrain));
fs.writeFile(outputPath, packTerrain(mapName, terrain));
const miniTerrain = await createMiniMap(terrain);
const miniOutputPath = path.join(
@@ -90,7 +102,11 @@ export async function loadTerrainMap(): Promise<void> {
"maps",
mapName + "Mini.bin",
);
fs.writeFile(miniOutputPath, packTerrain(miniTerrain));
fs.writeFile(miniOutputPath, packTerrain(mapName, miniTerrain));
}
export async function loadTerrainMaps() {
await Promise.all(maps.map((map) => loadTerrainMap(map)));
}
export async function createMiniMap(tm: Terrain[][]): Promise<Terrain[][]> {
@@ -192,7 +208,7 @@ function neighbors(x: number, y: number, map: Terrain[][]): Terrain[] {
return ns;
}
function packTerrain(map: Terrain[][]): Uint8Array {
function packTerrain(mapName: string, map: Terrain[][]): Uint8Array {
const width = map.length;
const height = map[0].length;
const packedData = new Uint8Array(4 + width * height);
@@ -229,7 +245,7 @@ function packTerrain(map: Terrain[][]): Uint8Array {
packedData[4 + y * width + x] = packedByte;
}
}
logBinaryAsBits(packedData);
logBinaryAsBits(mapName, packedData);
return packedData;
}
@@ -278,8 +294,70 @@ function processOcean(map: Terrain[][]) {
}
}
function removeSmallLakes(map: Terrain[][]) {
console.log(`removing lakes ${map.length}, ${map[0].length}`);
function getIsland(
map: Terrain[][],
x: number,
y: number,
visited: Set<string>,
) {
let island = [];
let next = [[x, y]];
while (next.length) {
const [x, y] = next.pop();
const key = `${x},${y}`;
if (
x < 0 ||
x >= map.length ||
y < 0 ||
y >= map[0].length ||
x < 0 ||
x >= map.length ||
visited.has(key)
)
continue;
if (map[x][y].type == TerrainType.Land) {
next.push([x + 1, y]);
next.push([x - 1, y]);
next.push([x, y + 1]);
next.push([x, y - 1]);
}
island.push([x, y]);
visited.add(key);
}
return island;
}
function removeSmallIslands(map: Terrain[][]) {
const visited = new Set<string>();
for (let x = 0; x < map.length; x++) {
for (let y = 0; y < map[0].length; y++) {
if (map[x][y].type == TerrainType.Land) {
const key = `${x},${y}`;
// PERF: If getIsland already visited that coordinates then it's
// useless to go over it again.
if (visited.has(key)) continue;
const island = getIsland(map, x, y, visited);
if (island.length < min_island_size) {
island.forEach((pos) => {
const x = pos[0];
const y = pos[1];
map[x][y].type = TerrainType.Water;
map[x][y].ocean = true;
});
}
}
}
}
}
function removeSmallLakes(mapName: string, map: Terrain[][]) {
console.log(`${mapName}: removing lakes ${map.length}, ${map[0].length}`);
for (let x = 0; x < map.length; x++) {
for (let y = 0; y < map[0].length; y++) {
@@ -300,11 +378,15 @@ function removeSmallLakes(map: Terrain[][]) {
}
}
function logBinaryAsBits(data: Uint8Array, length: number = 8) {
function logBinaryAsBits(
mapName: string,
data: Uint8Array,
length: number = 8,
) {
const bits = Array.from(data.slice(0, length))
.map((b) => b.toString(2).padStart(8, "0"))
.join(" ");
console.log("Binary data (bits):", bits);
console.log(`${mapName}: Binary data (bits):`, bits);
}
await loadTerrainMap();
await loadTerrainMaps();