Files
OpenFrontIO/src/core/game/Game.ts
T
Léo Kosman 2c4d2334dd Feat : focus attack average position and some movement camera fixes (#740)
## Description:

Makes so that when clicking on the attack warning message in chat, the
camera focuses on the "average position" of the attack, instead of just
the player.

The average position is calculated by taking the average position of all
attacking border cells. It makes the calculation for every AttackUpdate,
which adds some calculations every tick, but it shouldn't affect
performance that much, as it's just a sum of coordinates.
If you have a better way of getting the averagePosition information
(calculating it only when necessary instead of every tick), it would be
great.

closes #703 

## Please complete the following:

- [x] I have added screenshots for all UI updates
- [x] I confirm I have thoroughly tested these changes and take full
responsibility for any bugs introduced
- [x] I understand that submitting code with bugs that could have been
caught through manual testing blocks releases and new features for all
contributors

## Please put your Discord username so you can be contacted if a bug or
regression is found:
leo21_

---------

Co-authored-by: Scott Anderson <scottanderson@users.noreply.github.com>
Co-authored-by: evanpelle <evanpelle@gmail.com>
2025-05-23 19:03:53 -07:00

655 lines
15 KiB
TypeScript

import { Config } from "../configuration/Config";
import { AllPlayersStats, ClientID } from "../Schemas";
import { GameMap, TileRef } from "./GameMap";
import {
GameUpdate,
GameUpdateType,
PlayerUpdate,
UnitUpdate,
} from "./GameUpdates";
import { PlayerView } from "./GameView";
import { Stats } from "./Stats";
export type PlayerID = string;
export type Tick = number;
export type Gold = number;
export const AllPlayers = "AllPlayers" as const;
// export type GameUpdates = Record<GameUpdateType, GameUpdate[]>;
// Create a type that maps GameUpdateType to its corresponding update type
type UpdateTypeMap<T extends GameUpdateType> = Extract<GameUpdate, { type: T }>;
// Then use it to create the record type
export type GameUpdates = {
[K in GameUpdateType]: UpdateTypeMap<K>[];
};
export interface MapPos {
x: number;
y: number;
}
export enum Difficulty {
Easy = "Easy",
Medium = "Medium",
Hard = "Hard",
Impossible = "Impossible",
}
export type Team = string;
export const Duos = "Duos" as const;
export const ColoredTeams: Record<string, Team> = {
Red: "Red",
Blue: "Blue",
Teal: "Teal",
Purple: "Purple",
Yellow: "Yellow",
Orange: "Orange",
Green: "Green",
Bot: "Bot",
} as const;
export enum GameMapType {
World = "World",
Europe = "Europe",
EuropeClassic = "Europe Classic",
Mena = "Mena",
NorthAmerica = "North America",
SouthAmerica = "South America",
Oceania = "Oceania",
BlackSea = "Black Sea",
Africa = "Africa",
Pangaea = "Pangaea",
Asia = "Asia",
Mars = "Mars",
Britannia = "Britannia",
GatewayToTheAtlantic = "Gateway to the Atlantic",
Australia = "Australia",
Iceland = "Iceland",
Japan = "Japan",
BetweenTwoSeas = "Between Two Seas",
KnownWorld = "Known World",
FaroeIslands = "Faroe Islands",
DeglaciatedAntarctica = "Deglaciated Antarctica",
FalklandIslands = "Falkland Islands",
Baikal = "Baikal",
Halkidiki = "Halkidiki",
}
export const mapCategories: Record<string, GameMapType[]> = {
continental: [
GameMapType.World,
GameMapType.NorthAmerica,
GameMapType.SouthAmerica,
GameMapType.Europe,
GameMapType.EuropeClassic,
GameMapType.Asia,
GameMapType.Africa,
GameMapType.Oceania,
],
regional: [
GameMapType.BlackSea,
GameMapType.Britannia,
GameMapType.GatewayToTheAtlantic,
GameMapType.BetweenTwoSeas,
GameMapType.Iceland,
GameMapType.Japan,
GameMapType.Mena,
GameMapType.Australia,
GameMapType.FaroeIslands,
GameMapType.FalklandIslands,
GameMapType.Baikal,
GameMapType.Halkidiki,
],
fantasy: [
GameMapType.Pangaea,
GameMapType.Mars,
GameMapType.KnownWorld,
GameMapType.DeglaciatedAntarctica,
],
};
export enum GameType {
Singleplayer = "Singleplayer",
Public = "Public",
Private = "Private",
}
export enum GameMode {
FFA = "Free For All",
Team = "Team",
}
export interface UnitInfo {
cost: (player: Player | PlayerView) => Gold;
// Determines if its owner changes when its tile is conquered.
territoryBound: boolean;
maxHealth?: number;
damage?: number;
constructionDuration?: number;
}
export enum UnitType {
TransportShip = "Transport",
Warship = "Warship",
Shell = "Shell",
SAMMissile = "SAMMissile",
Port = "Port",
AtomBomb = "Atom Bomb",
HydrogenBomb = "Hydrogen Bomb",
TradeShip = "Trade Ship",
MissileSilo = "Missile Silo",
DefensePost = "Defense Post",
SAMLauncher = "SAM Launcher",
City = "City",
MIRV = "MIRV",
MIRVWarhead = "MIRV Warhead",
Construction = "Construction",
}
export interface UnitParamsMap {
[UnitType.TransportShip]: {
troops?: number;
destination?: TileRef;
};
[UnitType.Warship]: {};
[UnitType.Shell]: {};
[UnitType.SAMMissile]: {};
[UnitType.Port]: {};
[UnitType.AtomBomb]: {
targetTile?: number;
};
[UnitType.HydrogenBomb]: {
targetTile?: number;
};
[UnitType.TradeShip]: {
dstPort: Unit;
lastSetSafeFromPirates?: number;
};
[UnitType.MissileSilo]: {
cooldownDuration?: number;
};
[UnitType.DefensePost]: {};
[UnitType.SAMLauncher]: {};
[UnitType.City]: {};
[UnitType.MIRV]: {};
[UnitType.MIRVWarhead]: {
targetTile?: number;
};
[UnitType.Construction]: {};
}
// Type helper to get params type for a specific unit type
export type UnitParams<T extends UnitType> = UnitParamsMap[T];
export type AllUnitParams = UnitParamsMap[keyof UnitParamsMap];
export const nukeTypes = [
UnitType.AtomBomb,
UnitType.HydrogenBomb,
UnitType.MIRVWarhead,
UnitType.MIRV,
] as UnitType[];
export enum Relation {
Hostile = 0,
Distrustful = 1,
Neutral = 2,
Friendly = 3,
}
export class Nation {
constructor(
public readonly spawnCell: Cell,
public readonly strength: number,
public readonly playerInfo: PlayerInfo,
) {}
}
export class Cell {
public index: number;
private strRepr: string;
constructor(
public readonly x: number,
public readonly y: number,
) {
this.strRepr = `Cell[${this.x},${this.y}]`;
}
pos(): MapPos {
return {
x: this.x,
y: this.y,
};
}
toString(): string {
return this.strRepr;
}
}
export enum TerrainType {
Plains,
Highland,
Mountain,
Lake,
Ocean,
}
export enum PlayerType {
Bot = "BOT",
Human = "HUMAN",
FakeHuman = "FAKEHUMAN",
}
export interface Execution {
isActive(): boolean;
activeDuringSpawnPhase(): boolean;
init(mg: Game, ticks: number): void;
tick(ticks: number): void;
}
export interface Attack {
id(): string;
retreating(): boolean;
retreated(): boolean;
orderRetreat(): void;
executeRetreat(): void;
target(): Player | TerraNullius;
attacker(): Player;
troops(): number;
setTroops(troops: number): void;
isActive(): boolean;
delete(): void;
// The tile the attack originated from, mostly used for boat attacks.
sourceTile(): TileRef | null;
addBorderTile(tile: TileRef): void;
removeBorderTile(tile: TileRef): void;
clearBorder(): void;
borderSize(): number;
averagePosition(): Cell | null;
}
export interface AllianceRequest {
accept(): void;
reject(): void;
requestor(): Player;
recipient(): Player;
createdAt(): Tick;
}
export interface Alliance {
requestor(): Player;
recipient(): Player;
createdAt(): Tick;
other(player: Player): Player;
}
export interface MutableAlliance extends Alliance {
expire(): void;
other(player: Player): Player;
}
export class PlayerInfo {
public readonly clan: string | null;
constructor(
public readonly flag: string | undefined,
public readonly name: string,
public readonly playerType: PlayerType,
// null if bot.
public readonly clientID: ClientID | null,
// TODO: make player id the small id
public readonly id: PlayerID,
public readonly nation?: Nation | null,
) {
// Compute clan from name
if (!name.startsWith("[") || !name.includes("]")) {
this.clan = null;
} else {
const clanMatch = name.match(/^\[([A-Z]{2,5})\]/);
this.clan = clanMatch ? clanMatch[1] : null;
}
}
}
export interface Unit {
hash(): number;
// Common properties.
id(): number;
type(): UnitType;
owner(): Player;
info(): UnitInfo;
delete(displayMessage?: boolean, destroyer?: Player): void;
tile(): TileRef;
lastTile(): TileRef;
move(tile: TileRef): void;
isActive(): boolean;
setOwner(owner: Player): void;
touch(): void;
toUpdate(): UnitUpdate;
// Targeting
setTargetTile(cell: TileRef | undefined): void;
targetTile(): TileRef | undefined;
setTargetUnit(unit: Unit | undefined): void;
targetUnit(): Unit | undefined;
setTargetedBySAM(targeted: boolean): void;
targetedBySAM(): boolean;
setInterceptedBySam(): void;
interceptedBySam(): boolean;
// Health
hasHealth(): boolean;
retreating(): boolean;
orderBoatRetreat(): void;
health(): number;
modifyHealth(delta: number, attacker?: Player): void;
// Troops
setTroops(troops: number): void;
troops(): number;
// --- UNIT SPECIFIC ---
// SAMs & Missile Silos
launch(): void;
ticksLeftInCooldown(): Tick | undefined;
isInCooldown(): boolean;
// Trade Ships
setSafeFromPirates(): void; // Only for trade ships
isSafeFromPirates(): boolean; // Only for trade ships
// Construction
constructionType(): UnitType | null;
setConstructionType(type: UnitType): void;
// Ports
cachePut(from: TileRef, to: TileRef): void;
cacheGet(from: TileRef): TileRef | undefined;
}
export interface TerraNullius {
isPlayer(): false;
id(): null;
clientID(): ClientID;
smallID(): number;
}
export interface Embargo {
createdAt: Tick;
isTemporary: boolean;
target: PlayerID;
}
export interface Player {
// Basic Info
smallID(): number;
info(): PlayerInfo;
name(): string;
displayName(): string;
clientID(): ClientID | null;
id(): PlayerID;
type(): PlayerType;
isPlayer(): this is Player;
toString(): string;
// State & Properties
isAlive(): boolean;
isTraitor(): boolean;
markTraitor(): void;
largestClusterBoundingBox: { min: Cell; max: Cell } | null;
lastTileChange(): Tick;
hasSpawned(): boolean;
setHasSpawned(hasSpawned: boolean): void;
// Territory
tiles(): ReadonlySet<TileRef>;
borderTiles(): ReadonlySet<TileRef>;
numTilesOwned(): number;
conquer(tile: TileRef): void;
relinquish(tile: TileRef): void;
// Resources & Population
gold(): Gold;
population(): number;
totalPopulation(): number;
workers(): number;
troops(): number;
targetTroopRatio(): number;
addGold(toAdd: Gold): void;
removeGold(toRemove: Gold): void;
addWorkers(toAdd: number): void;
removeWorkers(toRemove: number): void;
setTargetTroopRatio(target: number): void;
setTroops(troops: number): void;
addTroops(troops: number): void;
removeTroops(troops: number): number;
// Units
units(...types: UnitType[]): Unit[];
unitsIncludingConstruction(type: UnitType): Unit[];
buildableUnits(tile: TileRef): BuildableUnit[];
canBuild(type: UnitType, targetTile: TileRef): TileRef | false;
buildUnit<T extends UnitType>(
type: T,
spawnTile: TileRef,
params: UnitParams<T>,
): Unit;
captureUnit(unit: Unit): void;
// Relations & Diplomacy
neighbors(): (Player | TerraNullius)[];
sharesBorderWith(other: Player | TerraNullius): boolean;
relation(other: Player): Relation;
allRelationsSorted(): { player: Player; relation: Relation }[];
updateRelation(other: Player, delta: number): void;
decayRelations(): void;
isOnSameTeam(other: Player): boolean;
// Either allied or on same team.
isFriendly(other: Player): boolean;
team(): Team | null;
clan(): string | null;
incomingAllianceRequests(): AllianceRequest[];
outgoingAllianceRequests(): AllianceRequest[];
alliances(): MutableAlliance[];
allies(): Player[];
isAlliedWith(other: Player): boolean;
allianceWith(other: Player): MutableAlliance | null;
canSendAllianceRequest(other: Player): boolean;
breakAlliance(alliance: Alliance): void;
createAllianceRequest(recipient: Player): AllianceRequest | null;
// Targeting
canTarget(other: Player): boolean;
target(other: Player): void;
targets(): Player[];
transitiveTargets(): Player[];
// Communication
canSendEmoji(recipient: Player | typeof AllPlayers): boolean;
outgoingEmojis(): EmojiMessage[];
sendEmoji(recipient: Player | typeof AllPlayers, emoji: string): void;
// Donation
canDonate(recipient: Player): boolean;
donateTroops(recipient: Player, troops: number): void;
donateGold(recipient: Player, gold: number): void;
// Embargo
hasEmbargoAgainst(other: Player): boolean;
tradingPartners(): Player[];
addEmbargo(other: PlayerID, isTemporary: boolean): void;
getEmbargoes(): Embargo[];
stopEmbargo(other: PlayerID): void;
endTemporaryEmbargo(other: PlayerID): void;
canTrade(other: Player): boolean;
// Attacking.
canAttack(tile: TileRef): boolean;
createAttack(
target: Player | TerraNullius,
troops: number,
sourceTile: TileRef | null,
border: Set<number>,
): Attack;
outgoingAttacks(): Attack[];
incomingAttacks(): Attack[];
orderRetreat(attackID: string): void;
executeRetreat(attackID: string): void;
// Misc
toUpdate(): PlayerUpdate;
playerProfile(): PlayerProfile;
tradingPorts(port: Unit): Unit[];
// WARNING: this operation is expensive.
bestTransportShipSpawn(tile: TileRef): TileRef | false;
}
export interface Game extends GameMap {
expireAlliance(alliance: Alliance);
// Map & Dimensions
isOnMap(cell: Cell): boolean;
width(): number;
height(): number;
map(): GameMap;
miniMap(): GameMap;
forEachTile(fn: (tile: TileRef) => void): void;
// Player Management
player(id: PlayerID): Player;
players(): Player[];
allPlayers(): Player[];
playerByClientID(id: ClientID): Player | null;
playerBySmallID(id: number): Player | TerraNullius;
hasPlayer(id: PlayerID): boolean;
addPlayer(playerInfo: PlayerInfo): Player;
terraNullius(): TerraNullius;
owner(ref: TileRef): Player | TerraNullius;
teams(): Team[];
// Game State
ticks(): Tick;
inSpawnPhase(): boolean;
executeNextTick(): GameUpdates;
setWinner(winner: Player | Team, allPlayersStats: AllPlayersStats): void;
config(): Config;
// Units
units(...types: UnitType[]): Unit[];
unitInfo(type: UnitType): UnitInfo;
nearbyUnits(
tile: TileRef,
searchRange: number,
types: UnitType | UnitType[],
): Array<{ unit: Unit; distSquared: number }>;
addExecution(...exec: Execution[]): void;
displayMessage(
message: string,
type: MessageType,
playerID: PlayerID | null,
): void;
displayIncomingUnit(
unitID: number,
message: string,
type: MessageType,
playerID: PlayerID | null,
): void;
displayChat(
message: string,
category: string,
variables: Record<string, string>,
playerID: PlayerID | null,
isFrom: boolean,
recipient: string,
): void;
// Nations
nations(): Nation[];
numTilesWithFallout(): number;
// Optional as it's not initialized before the end of spawn phase
stats(): Stats;
}
export interface PlayerActions {
canAttack: boolean;
buildableUnits: BuildableUnit[];
canSendEmojiAllPlayers: boolean;
interaction?: PlayerInteraction;
}
export interface BuildableUnit {
canBuild: TileRef | false;
type: UnitType;
cost: number;
}
export interface PlayerProfile {
relations: Record<number, Relation>;
alliances: number[];
}
export interface PlayerBorderTiles {
borderTiles: ReadonlySet<TileRef>;
}
export interface PlayerInteraction {
sharedBorder: boolean;
canSendEmoji: boolean;
canSendAllianceRequest: boolean;
canBreakAlliance: boolean;
canTarget: boolean;
canDonate: boolean;
canEmbargo: boolean;
allianceCreatedAtTick?: Tick;
}
export interface EmojiMessage {
message: string;
senderID: number;
recipientID: number | typeof AllPlayers;
createdAt: Tick;
}
export enum MessageType {
SUCCESS,
INFO,
WARN,
ERROR,
CHAT,
}
export interface NameViewData {
x: number;
y: number;
size: number;
}