mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-22 00:41:58 +00:00
Merge branch 'main' into trade
This commit is contained in:
+23
-8
@@ -264,23 +264,39 @@
|
||||
<!-- Game components -->
|
||||
<div id="app"></div>
|
||||
|
||||
<!-- Bottom HUD: <sm=column, sm..lg=2col (HUD left | events right), lg+=3col grid centered -->
|
||||
<div
|
||||
class="fixed left-0 bottom-0 min-[1200px]:left-4 min-[1200px]:bottom-4 w-full flex flex-col sm:flex-row sm:items-end z-50 pointer-events-none"
|
||||
class="fixed bottom-0 left-0 w-full z-[200] flex flex-col pointer-events-none sm:flex-row sm:items-end lg:grid lg:grid-cols-[1fr_460px_1fr] lg:items-end min-[1200px]:bottom-4 min-[1200px]:px-4"
|
||||
style="
|
||||
padding-bottom: env(safe-area-inset-bottom);
|
||||
padding-left: env(safe-area-inset-left);
|
||||
padding-right: env(safe-area-inset-right);
|
||||
"
|
||||
>
|
||||
<!-- HUD: <sm contents (children join outer flex), sm+ flex-col 460px, lg+ col-2 -->
|
||||
<div
|
||||
class="contents sm:flex sm:flex-col sm:w-1/2 min-[1200px]:w-auto lg:max-w-[400px]"
|
||||
class="contents sm:flex sm:flex-col sm:pointer-events-none w-full sm:w-[460px] lg:col-start-2 sm:z-10"
|
||||
>
|
||||
<attacks-display class="order-2 sm:order-none w-full"></attacks-display>
|
||||
<control-panel class="order-4 sm:order-none w-full"></control-panel>
|
||||
<attacks-display
|
||||
class="w-full pointer-events-auto order-1 sm:order-none"
|
||||
></attacks-display>
|
||||
<div
|
||||
class="pointer-events-auto bg-gray-800/70 backdrop-blur-xs sm:rounded-tr-lg lg:rounded-t-lg min-[1200px]:rounded-lg shadow-lg order-3 sm:order-none"
|
||||
>
|
||||
<control-panel class="w-full"></control-panel>
|
||||
<unit-display class="hidden lg:block w-full"></unit-display>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- events+chat: <sm between attacks and control (order-2), sm+ right side, lg+ col-3 -->
|
||||
<div
|
||||
class="contents sm:flex sm:flex-col sm:flex-1 min-[1200px]:w-auto min-[1200px]:fixed min-[1200px]:right-0 min-[1200px]:bottom-0 sm:items-end pointer-events-none"
|
||||
class="flex flex-col pointer-events-none items-end order-2 sm:order-none sm:flex-1 lg:col-start-3 lg:self-end lg:justify-end min-[1200px]:mr-4"
|
||||
>
|
||||
<chat-display
|
||||
class="order-1 sm:order-none w-full sm:w-auto"
|
||||
class="w-full sm:w-auto pointer-events-auto"
|
||||
></chat-display>
|
||||
<events-display
|
||||
class="order-3 sm:order-none w-full sm:w-auto"
|
||||
class="w-full sm:w-auto pointer-events-auto"
|
||||
></events-display>
|
||||
</div>
|
||||
</div>
|
||||
@@ -290,7 +306,6 @@
|
||||
<build-menu></build-menu>
|
||||
<win-modal></win-modal>
|
||||
<game-starting-modal></game-starting-modal>
|
||||
<unit-display></unit-display>
|
||||
<div
|
||||
class="flex flex-col items-end fixed top-0 right-0 min-[1200px]:top-4 min-[1200px]:right-4 z-1000 gap-2"
|
||||
>
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 512 KiB |
@@ -0,0 +1,110 @@
|
||||
{
|
||||
"name": "SanFrancisco",
|
||||
"nations": [
|
||||
{
|
||||
"coordinates": [996, 990],
|
||||
"name": "San Francisco",
|
||||
"flag": "California"
|
||||
},
|
||||
{
|
||||
"coordinates": [1177, 1389],
|
||||
"name": "San Mateo",
|
||||
"flag": "California"
|
||||
},
|
||||
{
|
||||
"coordinates": [1323, 1598],
|
||||
"name": "Palo Alto",
|
||||
"flag": "California"
|
||||
},
|
||||
{
|
||||
"coordinates": [1618, 1653],
|
||||
"name": "San Jose",
|
||||
"flag": "California"
|
||||
},
|
||||
{
|
||||
"coordinates": [1756, 1467],
|
||||
"name": "Fremont",
|
||||
"flag": "California"
|
||||
},
|
||||
{
|
||||
"coordinates": [1537, 1173],
|
||||
"name": "Hayward",
|
||||
"flag": "California"
|
||||
},
|
||||
{
|
||||
"coordinates": [1308, 873],
|
||||
"name": "Oakland",
|
||||
"flag": "California"
|
||||
},
|
||||
{
|
||||
"coordinates": [1230, 514],
|
||||
"name": "Richmond",
|
||||
"flag": "California"
|
||||
},
|
||||
{
|
||||
"coordinates": [1635, 517],
|
||||
"name": "Concord",
|
||||
"flag": "California"
|
||||
},
|
||||
{
|
||||
"coordinates": [1253, 778],
|
||||
"name": "Berkeley",
|
||||
"flag": "California"
|
||||
},
|
||||
{
|
||||
"coordinates": [1749, 87],
|
||||
"name": "Fairfield",
|
||||
"flag": "California"
|
||||
},
|
||||
{
|
||||
"coordinates": [1348, 279],
|
||||
"name": "Vallejo",
|
||||
"flag": "California"
|
||||
},
|
||||
{
|
||||
"coordinates": [1122, 18],
|
||||
"name": "Napa",
|
||||
"flag": "California"
|
||||
},
|
||||
{
|
||||
"coordinates": [757, 152],
|
||||
"name": "Novato",
|
||||
"flag": "California"
|
||||
},
|
||||
{
|
||||
"coordinates": [868, 539],
|
||||
"name": "San Rafael",
|
||||
"flag": "California"
|
||||
},
|
||||
{
|
||||
"coordinates": [930, 1233],
|
||||
"name": "Daly City",
|
||||
"flag": "California"
|
||||
},
|
||||
{
|
||||
"coordinates": [1955, 521],
|
||||
"name": "Pittsburg",
|
||||
"flag": "California"
|
||||
},
|
||||
{
|
||||
"coordinates": [1647, 849],
|
||||
"name": "Dublin",
|
||||
"flag": "California"
|
||||
},
|
||||
{
|
||||
"coordinates": [503, 575],
|
||||
"name": "Bolinas",
|
||||
"flag": "California"
|
||||
},
|
||||
{
|
||||
"coordinates": [1882, 1082],
|
||||
"name": "Livermore",
|
||||
"flag": "California"
|
||||
},
|
||||
{
|
||||
"coordinates": [215, 26],
|
||||
"name": "Bodega Bay",
|
||||
"flag": "California"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -76,6 +76,7 @@ var maps = []struct {
|
||||
{Name: "hawaii"},
|
||||
{Name: "niledelta"},
|
||||
{Name: "arctic"},
|
||||
{Name: "sanfrancisco"},
|
||||
{Name: "big_plains", IsTest: true},
|
||||
{Name: "half_land_half_ocean", IsTest: true},
|
||||
{Name: "ocean_and_land", IsTest: true},
|
||||
|
||||
Generated
+7
-4
@@ -21,7 +21,7 @@
|
||||
"colord": "^2.9.3",
|
||||
"colorjs.io": "^0.5.2",
|
||||
"compression": "^1.8.1",
|
||||
"dompurify": "^3.1.7",
|
||||
"dompurify": "^3.3.2",
|
||||
"dotenv": "^16.5.0",
|
||||
"ejs": "^3.1.10",
|
||||
"express": "^4.22.1",
|
||||
@@ -7034,10 +7034,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/dompurify": {
|
||||
"version": "3.2.6",
|
||||
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.2.6.tgz",
|
||||
"integrity": "sha512-/2GogDQlohXPZe6D6NOgQvXLPSYBqIWMnZ8zzOhn09REE4eyAzb+Hed3jhoM9OkuaJ8P6ZGTTVWQKAi8ieIzfQ==",
|
||||
"version": "3.3.2",
|
||||
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.2.tgz",
|
||||
"integrity": "sha512-6obghkliLdmKa56xdbLOpUZ43pAR6xFy1uOrxBaIDjT+yaRuuybLjGS9eVBoSR/UPU5fq3OXClEHLJNGvbxKpQ==",
|
||||
"license": "(MPL-2.0 OR Apache-2.0)",
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@types/trusted-types": "^2.0.7"
|
||||
}
|
||||
|
||||
+1
-1
@@ -104,7 +104,7 @@
|
||||
"colord": "^2.9.3",
|
||||
"colorjs.io": "^0.5.2",
|
||||
"compression": "^1.8.1",
|
||||
"dompurify": "^3.1.7",
|
||||
"dompurify": "^3.3.2",
|
||||
"dotenv": "^16.5.0",
|
||||
"ejs": "^3.1.10",
|
||||
"express": "^4.22.1",
|
||||
|
||||
@@ -237,6 +237,10 @@
|
||||
{
|
||||
"key": "stop_trading",
|
||||
"requiresPlayer": true
|
||||
},
|
||||
{
|
||||
"key": "stop_trading_all",
|
||||
"requiresPlayer": false
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -643,7 +643,8 @@
|
||||
{
|
||||
"code": "Empire of Japan",
|
||||
"continent": "Asia",
|
||||
"name": "Empire of Japan"
|
||||
"name": "Empire of Japan",
|
||||
"restricted": true
|
||||
},
|
||||
{
|
||||
"code": "Empire of Japan1",
|
||||
|
||||
@@ -337,7 +337,8 @@
|
||||
"hawaii": "Hawaii",
|
||||
"alps": "Alps",
|
||||
"niledelta": "Nile Delta",
|
||||
"arctic": "Arctic"
|
||||
"arctic": "Arctic",
|
||||
"sanfrancisco": "San Francisco"
|
||||
},
|
||||
"map_categories": {
|
||||
"featured": "Featured",
|
||||
@@ -686,7 +687,8 @@
|
||||
"mirv_ready": "[P1] has enough gold to launch a MIRV!",
|
||||
"snowballing": "[P1] is snowballing too fast!",
|
||||
"cheating": "[P1] is cheating!",
|
||||
"stop_trading": "Stop trading with [P1]!"
|
||||
"stop_trading": "Stop trading with [P1]!",
|
||||
"stop_trading_all": "Please stop trading with all!"
|
||||
}
|
||||
},
|
||||
"build_menu": {
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
{
|
||||
"map": {
|
||||
"height": 1700,
|
||||
"num_land_tiles": 1887961,
|
||||
"width": 2000
|
||||
},
|
||||
"map16x": {
|
||||
"height": 425,
|
||||
"num_land_tiles": 112964,
|
||||
"width": 500
|
||||
},
|
||||
"map4x": {
|
||||
"height": 850,
|
||||
"num_land_tiles": 465078,
|
||||
"width": 1000
|
||||
},
|
||||
"name": "SanFrancisco",
|
||||
"nations": [
|
||||
{
|
||||
"coordinates": [996, 990],
|
||||
"flag": "California",
|
||||
"name": "San Francisco"
|
||||
},
|
||||
{
|
||||
"coordinates": [1177, 1389],
|
||||
"flag": "California",
|
||||
"name": "San Mateo"
|
||||
},
|
||||
{
|
||||
"coordinates": [1323, 1598],
|
||||
"flag": "California",
|
||||
"name": "Palo Alto"
|
||||
},
|
||||
{
|
||||
"coordinates": [1618, 1653],
|
||||
"flag": "California",
|
||||
"name": "San Jose"
|
||||
},
|
||||
{
|
||||
"coordinates": [1756, 1467],
|
||||
"flag": "California",
|
||||
"name": "Fremont"
|
||||
},
|
||||
{
|
||||
"coordinates": [1537, 1173],
|
||||
"flag": "California",
|
||||
"name": "Hayward"
|
||||
},
|
||||
{
|
||||
"coordinates": [1308, 873],
|
||||
"flag": "California",
|
||||
"name": "Oakland"
|
||||
},
|
||||
{
|
||||
"coordinates": [1230, 514],
|
||||
"flag": "California",
|
||||
"name": "Richmond"
|
||||
},
|
||||
{
|
||||
"coordinates": [1635, 517],
|
||||
"flag": "California",
|
||||
"name": "Concord"
|
||||
},
|
||||
{
|
||||
"coordinates": [1253, 778],
|
||||
"flag": "California",
|
||||
"name": "Berkeley"
|
||||
},
|
||||
{
|
||||
"coordinates": [1749, 87],
|
||||
"flag": "California",
|
||||
"name": "Fairfield"
|
||||
},
|
||||
{
|
||||
"coordinates": [1348, 279],
|
||||
"flag": "California",
|
||||
"name": "Vallejo"
|
||||
},
|
||||
{
|
||||
"coordinates": [1122, 18],
|
||||
"flag": "California",
|
||||
"name": "Napa"
|
||||
},
|
||||
{
|
||||
"coordinates": [757, 152],
|
||||
"flag": "California",
|
||||
"name": "Novato"
|
||||
},
|
||||
{
|
||||
"coordinates": [868, 539],
|
||||
"flag": "California",
|
||||
"name": "San Rafael"
|
||||
},
|
||||
{
|
||||
"coordinates": [930, 1233],
|
||||
"flag": "California",
|
||||
"name": "Daly City"
|
||||
},
|
||||
{
|
||||
"coordinates": [1955, 521],
|
||||
"flag": "California",
|
||||
"name": "Pittsburg"
|
||||
},
|
||||
{
|
||||
"coordinates": [1647, 849],
|
||||
"flag": "California",
|
||||
"name": "Dublin"
|
||||
},
|
||||
{
|
||||
"coordinates": [503, 575],
|
||||
"flag": "California",
|
||||
"name": "Bolinas"
|
||||
},
|
||||
{
|
||||
"coordinates": [1882, 1082],
|
||||
"flag": "California",
|
||||
"name": "Livermore"
|
||||
},
|
||||
{
|
||||
"coordinates": [215, 26],
|
||||
"flag": "California",
|
||||
"name": "Bodega Bay"
|
||||
}
|
||||
]
|
||||
}
|
||||
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
Binary file not shown.
|
After Width: | Height: | Size: 14 KiB |
@@ -194,7 +194,7 @@ export function joinLobby(
|
||||
|
||||
async function createClientGame(
|
||||
lobbyConfig: LobbyConfig,
|
||||
clientID: ClientID,
|
||||
clientID: ClientID | undefined,
|
||||
eventBus: EventBus,
|
||||
transport: Transport,
|
||||
userSettings: UserSettings,
|
||||
@@ -267,7 +267,7 @@ export class ClientGameRunner {
|
||||
|
||||
constructor(
|
||||
private lobby: LobbyConfig,
|
||||
private clientID: ClientID,
|
||||
private clientID: ClientID | undefined,
|
||||
private eventBus: EventBus,
|
||||
private renderer: GameRenderer,
|
||||
private input: InputHandler,
|
||||
@@ -294,7 +294,7 @@ export class ClientGameRunner {
|
||||
}
|
||||
|
||||
private async saveGame(update: WinUpdate) {
|
||||
if (this.myPlayer === null) {
|
||||
if (!this.clientID) {
|
||||
return;
|
||||
}
|
||||
const players: PlayerRecord[] = [
|
||||
@@ -544,6 +544,7 @@ export class ClientGameRunner {
|
||||
return;
|
||||
}
|
||||
if (this.myPlayer === null) {
|
||||
if (!this.clientID) return;
|
||||
const myPlayer = this.gameView.playerByClientID(this.clientID);
|
||||
if (myPlayer === null) return;
|
||||
this.myPlayer = myPlayer;
|
||||
@@ -578,6 +579,7 @@ export class ClientGameRunner {
|
||||
const tile = this.gameView.ref(cell.x, cell.y);
|
||||
|
||||
if (this.myPlayer === null) {
|
||||
if (!this.clientID) return;
|
||||
const myPlayer = this.gameView.playerByClientID(this.clientID);
|
||||
if (myPlayer === null) return;
|
||||
this.myPlayer = myPlayer;
|
||||
@@ -639,6 +641,7 @@ export class ClientGameRunner {
|
||||
}
|
||||
|
||||
if (this.myPlayer === null) {
|
||||
if (!this.clientID) return;
|
||||
const myPlayer = this.gameView.playerByClientID(this.clientID);
|
||||
if (myPlayer === null) return;
|
||||
this.myPlayer = myPlayer;
|
||||
@@ -664,6 +667,7 @@ export class ClientGameRunner {
|
||||
}
|
||||
|
||||
if (this.myPlayer === null) {
|
||||
if (!this.clientID) return;
|
||||
const myPlayer = this.gameView.playerByClientID(this.clientID);
|
||||
if (myPlayer === null) return;
|
||||
this.myPlayer = myPlayer;
|
||||
|
||||
@@ -119,7 +119,7 @@ export class GameModeSelector extends LitElement {
|
||||
const special = this.lobbies?.games?.["special"]?.[0];
|
||||
|
||||
return html`
|
||||
<div class="flex flex-col gap-4 w-[84%] sm:w-full mx-auto pb-4 sm:pb-0">
|
||||
<div class="flex flex-col gap-4 w-full px-4 sm:px-0 mx-auto pb-4 sm:pb-0">
|
||||
<!-- Solo: mobile only, top -->
|
||||
<div class="sm:hidden h-14">
|
||||
${this.renderSmallActionCard(
|
||||
|
||||
+79
-47
@@ -92,6 +92,8 @@ export class GhostStructureChangedEvent implements GameEvent {
|
||||
constructor(public readonly ghostStructure: PlayerBuildableUnitType | null) {}
|
||||
}
|
||||
|
||||
export class ConfirmGhostStructureEvent implements GameEvent {}
|
||||
|
||||
export class SwapRocketDirectionEvent implements GameEvent {
|
||||
constructor(public readonly rocketDirectionUp: boolean) {}
|
||||
}
|
||||
@@ -339,6 +341,14 @@ export class InputHandler {
|
||||
this.setGhostStructure(null);
|
||||
}
|
||||
|
||||
if (
|
||||
(e.code === "Enter" || e.code === "NumpadEnter") &&
|
||||
this.uiState.ghostStructure !== null
|
||||
) {
|
||||
e.preventDefault();
|
||||
this.eventBus.emit(new ConfirmGhostStructureEvent());
|
||||
}
|
||||
|
||||
if (
|
||||
[
|
||||
this.keybinds.moveUp,
|
||||
@@ -410,54 +420,11 @@ export class InputHandler {
|
||||
this.eventBus.emit(new CenterCameraEvent());
|
||||
}
|
||||
|
||||
if (e.code === this.keybinds.buildCity) {
|
||||
// Two-phase build keybind matching: exact code match first, then digit/Numpad alias.
|
||||
const matchedBuild = this.resolveBuildKeybind(e.code);
|
||||
if (matchedBuild !== null) {
|
||||
e.preventDefault();
|
||||
this.setGhostStructure(UnitType.City);
|
||||
}
|
||||
|
||||
if (e.code === this.keybinds.buildFactory) {
|
||||
e.preventDefault();
|
||||
this.setGhostStructure(UnitType.Factory);
|
||||
}
|
||||
|
||||
if (e.code === this.keybinds.buildPort) {
|
||||
e.preventDefault();
|
||||
this.setGhostStructure(UnitType.Port);
|
||||
}
|
||||
|
||||
if (e.code === this.keybinds.buildDefensePost) {
|
||||
e.preventDefault();
|
||||
this.setGhostStructure(UnitType.DefensePost);
|
||||
}
|
||||
|
||||
if (e.code === this.keybinds.buildMissileSilo) {
|
||||
e.preventDefault();
|
||||
this.setGhostStructure(UnitType.MissileSilo);
|
||||
}
|
||||
|
||||
if (e.code === this.keybinds.buildSamLauncher) {
|
||||
e.preventDefault();
|
||||
this.setGhostStructure(UnitType.SAMLauncher);
|
||||
}
|
||||
|
||||
if (e.code === this.keybinds.buildAtomBomb) {
|
||||
e.preventDefault();
|
||||
this.setGhostStructure(UnitType.AtomBomb);
|
||||
}
|
||||
|
||||
if (e.code === this.keybinds.buildHydrogenBomb) {
|
||||
e.preventDefault();
|
||||
this.setGhostStructure(UnitType.HydrogenBomb);
|
||||
}
|
||||
|
||||
if (e.code === this.keybinds.buildWarship) {
|
||||
e.preventDefault();
|
||||
this.setGhostStructure(UnitType.Warship);
|
||||
}
|
||||
|
||||
if (e.code === this.keybinds.buildMIRV) {
|
||||
e.preventDefault();
|
||||
this.setGhostStructure(UnitType.MIRV);
|
||||
this.setGhostStructure(matchedBuild);
|
||||
}
|
||||
|
||||
if (e.code === this.keybinds.swapDirection) {
|
||||
@@ -616,6 +583,71 @@ export class InputHandler {
|
||||
this.eventBus.emit(new GhostStructureChangedEvent(ghostStructure));
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts the digit character from KeyboardEvent.code.
|
||||
* Codes look like "Digit0".."Digit9" (6 chars, digit at index 5) and
|
||||
* "Numpad0".."Numpad9" (7 chars, digit at index 6). Returns null if not a digit key.
|
||||
*/
|
||||
private digitFromKeyCode(code: string): string | null {
|
||||
if (
|
||||
code?.length === 6 &&
|
||||
code.startsWith("Digit") &&
|
||||
/^[0-9]$/.test(code[5])
|
||||
)
|
||||
return code[5];
|
||||
if (
|
||||
code?.length === 7 &&
|
||||
code.startsWith("Numpad") &&
|
||||
/^[0-9]$/.test(code[6])
|
||||
)
|
||||
return code[6];
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Strict equality only: used for first-pass exact KeyboardEvent.code match. */
|
||||
private buildKeybindMatches(code: string, keybindValue: string): boolean {
|
||||
return code === keybindValue;
|
||||
}
|
||||
|
||||
/** Digit/Numpad alias match: used only when no exact match was found. */
|
||||
private buildKeybindMatchesDigit(
|
||||
code: string,
|
||||
keybindValue: string,
|
||||
): boolean {
|
||||
const digit = this.digitFromKeyCode(code);
|
||||
const bindDigit = this.digitFromKeyCode(keybindValue);
|
||||
return digit !== null && bindDigit !== null && digit === bindDigit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves a keyup code to a build action: exact code match first, then digit/Numpad alias.
|
||||
* Returns the UnitType to set as ghost, or null if no build keybind matched.
|
||||
*/
|
||||
private resolveBuildKeybind(code: string): PlayerBuildableUnitType | null {
|
||||
const buildKeybinds: ReadonlyArray<{
|
||||
key: string;
|
||||
type: PlayerBuildableUnitType;
|
||||
}> = [
|
||||
{ key: "buildCity", type: UnitType.City },
|
||||
{ key: "buildFactory", type: UnitType.Factory },
|
||||
{ key: "buildPort", type: UnitType.Port },
|
||||
{ key: "buildDefensePost", type: UnitType.DefensePost },
|
||||
{ key: "buildMissileSilo", type: UnitType.MissileSilo },
|
||||
{ key: "buildSamLauncher", type: UnitType.SAMLauncher },
|
||||
{ key: "buildAtomBomb", type: UnitType.AtomBomb },
|
||||
{ key: "buildHydrogenBomb", type: UnitType.HydrogenBomb },
|
||||
{ key: "buildWarship", type: UnitType.Warship },
|
||||
{ key: "buildMIRV", type: UnitType.MIRV },
|
||||
];
|
||||
for (const { key, type } of buildKeybinds) {
|
||||
if (this.buildKeybindMatches(code, this.keybinds[key])) return type;
|
||||
}
|
||||
for (const { key, type } of buildKeybinds) {
|
||||
if (this.buildKeybindMatchesDigit(code, this.keybinds[key])) return type;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private getPinchDistance(): number {
|
||||
const pointerEvents = Array.from(this.pointers.values());
|
||||
const dx = pointerEvents[0].clientX - pointerEvents[1].clientX;
|
||||
|
||||
@@ -130,6 +130,8 @@ export class JoinLobbyModal extends BaseModal {
|
||||
.lobbyCreatorClientID=${hostClientID}
|
||||
.currentClientID=${this.currentClientID}
|
||||
.teamCount=${this.gameConfig?.playerTeams ?? 2}
|
||||
.isPublicGame=${this.gameConfig?.gameType ===
|
||||
GameType.Public}
|
||||
.nationCount=${nationsConfigToSlider(
|
||||
this.gameConfig?.nations ?? "default",
|
||||
this.nationCount,
|
||||
|
||||
@@ -113,7 +113,8 @@ export class LocalServer {
|
||||
gameStartInfo: this.lobbyConfig.gameStartInfo,
|
||||
turns: [],
|
||||
lobbyCreatedAt: this.lobbyConfig.gameStartInfo.lobbyCreatedAt,
|
||||
myClientID: this.clientID,
|
||||
// Don't send myClientID for replays — viewer has no player identity.
|
||||
myClientID: this.lobbyConfig.gameRecord ? undefined : this.clientID,
|
||||
} satisfies ServerStartGameMessage);
|
||||
}
|
||||
|
||||
@@ -127,7 +128,7 @@ export class LocalServer {
|
||||
gameStartInfo: this.lobbyConfig.gameStartInfo!,
|
||||
turns: this.turns,
|
||||
lobbyCreatedAt: this.lobbyConfig.gameStartInfo!.lobbyCreatedAt,
|
||||
myClientID: this.clientID,
|
||||
myClientID: this.lobbyConfig.gameRecord ? undefined : this.clientID,
|
||||
} satisfies ServerStartGameMessage);
|
||||
}
|
||||
if (clientMsg.type === "intent") {
|
||||
|
||||
+6
-3
@@ -36,9 +36,12 @@ export function getGameModeLabel(gameConfig: GameConfig): string {
|
||||
|
||||
// Humans vs Nations
|
||||
if (playerTeams === HumansVsNations) {
|
||||
return translateText("public_lobby.teams_hvn_detailed", {
|
||||
num: maxPlayers ?? 0,
|
||||
});
|
||||
if (maxPlayers) {
|
||||
return translateText("public_lobby.teams_hvn_detailed", {
|
||||
num: maxPlayers,
|
||||
});
|
||||
}
|
||||
return translateText("public_lobby.teams_hvn");
|
||||
}
|
||||
|
||||
// Named team types (Duos, Trios, Quads)
|
||||
|
||||
@@ -35,11 +35,24 @@ export class LobbyTeamView extends LitElement {
|
||||
@property({ attribute: "team-count" }) teamCount: TeamCountConfig = 2;
|
||||
@property({ type: Function }) onKickPlayer?: (clientID: string) => void;
|
||||
@property({ type: Number }) nationCount: number = 0;
|
||||
@property({ type: Boolean }) isPublicGame: boolean = false;
|
||||
|
||||
private theme: PastelTheme = new PastelTheme();
|
||||
@state() private showTeamColors: boolean = false;
|
||||
private userSettings: UserSettings = new UserSettings();
|
||||
|
||||
/**
|
||||
* For public HumansVsNations games, nation count always matches human count
|
||||
* (server enforces this in NationCreation). For private games, the host
|
||||
* controls the nation count via the slider.
|
||||
*/
|
||||
private get effectiveNationCount(): number {
|
||||
if (this.isPublicGame && this.teamCount === HumansVsNations) {
|
||||
return this.clients.length;
|
||||
}
|
||||
return this.nationCount;
|
||||
}
|
||||
|
||||
willUpdate(changedProperties: Map<string, any>) {
|
||||
// Recompute team preview when relevant properties change
|
||||
// clients is updated from WebSocket lobby_info events
|
||||
@@ -47,7 +60,8 @@ export class LobbyTeamView extends LitElement {
|
||||
changedProperties.has("gameMode") ||
|
||||
changedProperties.has("clients") ||
|
||||
changedProperties.has("teamCount") ||
|
||||
changedProperties.has("nationCount")
|
||||
changedProperties.has("nationCount") ||
|
||||
changedProperties.has("isPublicGame")
|
||||
) {
|
||||
const teamsList = this.getTeamList();
|
||||
this.computeTeamPreview(teamsList);
|
||||
@@ -67,8 +81,8 @@ export class LobbyTeamView extends LitElement {
|
||||
? translateText("host_modal.player")
|
||||
: translateText("host_modal.players")}
|
||||
<span style="margin: 0 8px;">•</span>
|
||||
${this.nationCount}
|
||||
${this.nationCount === 1
|
||||
${this.effectiveNationCount}
|
||||
${this.effectiveNationCount === 1
|
||||
? translateText("host_modal.nation_player")
|
||||
: translateText("host_modal.nation_players")}
|
||||
</div>
|
||||
@@ -179,12 +193,12 @@ export class LobbyTeamView extends LitElement {
|
||||
private renderTeamCard(preview: TeamPreviewData, isEmpty: boolean = false) {
|
||||
const displayCount =
|
||||
preview.team === ColoredTeams.Nations
|
||||
? this.nationCount
|
||||
? this.effectiveNationCount
|
||||
: preview.players.length;
|
||||
|
||||
const maxTeamSize =
|
||||
preview.team === ColoredTeams.Nations
|
||||
? this.nationCount
|
||||
? this.effectiveNationCount
|
||||
: this.teamMaxSize;
|
||||
|
||||
const teamLabel = getTranslatedPlayerTeamLabel(preview.team);
|
||||
@@ -245,7 +259,7 @@ export class LobbyTeamView extends LitElement {
|
||||
|
||||
private getTeamList(): Team[] {
|
||||
if (this.gameMode !== GameMode.Team) return [];
|
||||
const playerCount = this.clients.length + this.nationCount;
|
||||
const playerCount = this.clients.length + this.effectiveNationCount;
|
||||
const config = this.teamCount;
|
||||
|
||||
if (config === HumansVsNations) {
|
||||
@@ -309,7 +323,7 @@ export class LobbyTeamView extends LitElement {
|
||||
const assignment = assignTeamsLobbyPreview(
|
||||
players,
|
||||
teams,
|
||||
this.nationCount,
|
||||
this.effectiveNationCount,
|
||||
);
|
||||
const buckets = new Map<Team, ClientInfo[]>();
|
||||
for (const t of teams) buckets.set(t, []);
|
||||
@@ -333,7 +347,9 @@ export class LobbyTeamView extends LitElement {
|
||||
// Fallback: divide players across teams; guard against 0 and empty lobbies
|
||||
this.teamMaxSize = Math.max(
|
||||
1,
|
||||
Math.ceil((this.clients.length + this.nationCount) / teams.length),
|
||||
Math.ceil(
|
||||
(this.clients.length + this.effectiveNationCount) / teams.length,
|
||||
),
|
||||
);
|
||||
}
|
||||
this.teamPreview = teams.map((t) => ({
|
||||
|
||||
@@ -22,7 +22,7 @@ export class MainLayout extends LitElement {
|
||||
class="relative [.in-game_&]:hidden flex flex-col flex-1 overflow-hidden w-full px-0 lg:px-[clamp(1.5rem,3vw,3rem)] pt-0 lg:pt-[clamp(0.75rem,1.5vw,1.5rem)] pb-0 lg:pb-[clamp(0.75rem,1.5vw,1.5rem)]"
|
||||
>
|
||||
<div
|
||||
class="w-full lg:max-w-[20cm] mx-auto flex flex-col flex-1 gap-0 lg:gap-[clamp(1.5rem,3vw,3rem)] overflow-y-auto overflow-x-hidden"
|
||||
class="w-full lg:max-w-[20cm] mx-auto flex flex-col flex-1 gap-0 lg:gap-[clamp(1.5rem,3vw,3rem)] overflow-y-auto overflow-x-hidden sm:px-4 lg:px-0"
|
||||
>
|
||||
${this._initialChildren}
|
||||
</div>
|
||||
|
||||
@@ -100,7 +100,7 @@ export class PlayPage extends LitElement {
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="w-full pb-4 lg:pb-0 flex flex-col gap-4 lg:grid lg:grid-cols-[2fr_1fr] lg:gap-4"
|
||||
class="w-full pb-4 lg:pb-0 flex flex-col gap-4 sm:-mx-4 sm:w-[calc(100%+2rem)] lg:mx-0 lg:w-full lg:grid lg:grid-cols-[2fr_1fr] lg:gap-4"
|
||||
>
|
||||
<!-- Mobile: spacer for fixed top bar -->
|
||||
<div
|
||||
|
||||
@@ -1,22 +1,30 @@
|
||||
export class AnimatedSprite {
|
||||
private frameHeight: number;
|
||||
private frameWidth: number;
|
||||
private currentFrame: number = 0;
|
||||
private elapsedTime: number = 0;
|
||||
private active: boolean = true;
|
||||
|
||||
constructor(
|
||||
private image: CanvasImageSource,
|
||||
private frameWidth: number,
|
||||
private frameCount: number,
|
||||
private frameDuration: number, // in milliseconds
|
||||
private looping: boolean = false,
|
||||
private originX: number,
|
||||
private originY: number,
|
||||
) {
|
||||
if ("height" in image) {
|
||||
if (frameCount <= 0) {
|
||||
throw new Error("Animated sprite should at least have one frame");
|
||||
}
|
||||
if ("height" in image && "width" in image) {
|
||||
this.frameHeight = (image as HTMLImageElement | HTMLCanvasElement).height;
|
||||
this.frameWidth = Math.floor(
|
||||
(image as HTMLImageElement | HTMLCanvasElement).width / frameCount,
|
||||
);
|
||||
} else {
|
||||
throw new Error("Image source must have a 'height' property.");
|
||||
throw new Error(
|
||||
"Image source must have 'width' and 'height' properties.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -18,7 +18,6 @@ import { colorizeCanvas } from "./SpriteLoader";
|
||||
|
||||
type AnimatedSpriteConfig = {
|
||||
url: string;
|
||||
frameWidth: number;
|
||||
frameCount: number;
|
||||
frameDuration: number; // ms per frame
|
||||
looping?: boolean;
|
||||
@@ -29,7 +28,6 @@ type AnimatedSpriteConfig = {
|
||||
const ANIMATED_SPRITE_CONFIG: Partial<Record<FxType, AnimatedSpriteConfig>> = {
|
||||
[FxType.MiniFire]: {
|
||||
url: miniFire,
|
||||
frameWidth: 7,
|
||||
frameCount: 6,
|
||||
frameDuration: 100,
|
||||
looping: true,
|
||||
@@ -38,7 +36,6 @@ const ANIMATED_SPRITE_CONFIG: Partial<Record<FxType, AnimatedSpriteConfig>> = {
|
||||
},
|
||||
[FxType.MiniSmoke]: {
|
||||
url: miniSmoke,
|
||||
frameWidth: 11,
|
||||
frameCount: 4,
|
||||
frameDuration: 120,
|
||||
looping: true,
|
||||
@@ -47,7 +44,6 @@ const ANIMATED_SPRITE_CONFIG: Partial<Record<FxType, AnimatedSpriteConfig>> = {
|
||||
},
|
||||
[FxType.MiniBigSmoke]: {
|
||||
url: miniBigSmoke,
|
||||
frameWidth: 24,
|
||||
frameCount: 5,
|
||||
frameDuration: 120,
|
||||
looping: true,
|
||||
@@ -56,8 +52,7 @@ const ANIMATED_SPRITE_CONFIG: Partial<Record<FxType, AnimatedSpriteConfig>> = {
|
||||
},
|
||||
[FxType.MiniSmokeAndFire]: {
|
||||
url: miniSmokeAndFire,
|
||||
frameWidth: 24,
|
||||
frameCount: 5,
|
||||
frameCount: 6,
|
||||
frameDuration: 120,
|
||||
looping: true,
|
||||
originX: 9,
|
||||
@@ -65,7 +60,6 @@ const ANIMATED_SPRITE_CONFIG: Partial<Record<FxType, AnimatedSpriteConfig>> = {
|
||||
},
|
||||
[FxType.MiniExplosion]: {
|
||||
url: miniExplosion,
|
||||
frameWidth: 13,
|
||||
frameCount: 4,
|
||||
frameDuration: 70,
|
||||
looping: false,
|
||||
@@ -74,7 +68,6 @@ const ANIMATED_SPRITE_CONFIG: Partial<Record<FxType, AnimatedSpriteConfig>> = {
|
||||
},
|
||||
[FxType.Dust]: {
|
||||
url: dust,
|
||||
frameWidth: 9,
|
||||
frameCount: 3,
|
||||
frameDuration: 100,
|
||||
looping: false,
|
||||
@@ -83,7 +76,6 @@ const ANIMATED_SPRITE_CONFIG: Partial<Record<FxType, AnimatedSpriteConfig>> = {
|
||||
},
|
||||
[FxType.UnitExplosion]: {
|
||||
url: unitExplosion,
|
||||
frameWidth: 19,
|
||||
frameCount: 4,
|
||||
frameDuration: 70,
|
||||
looping: false,
|
||||
@@ -92,7 +84,6 @@ const ANIMATED_SPRITE_CONFIG: Partial<Record<FxType, AnimatedSpriteConfig>> = {
|
||||
},
|
||||
[FxType.BuildingExplosion]: {
|
||||
url: buildingExplosion,
|
||||
frameWidth: 17,
|
||||
frameCount: 10,
|
||||
frameDuration: 70,
|
||||
looping: false,
|
||||
@@ -101,7 +92,6 @@ const ANIMATED_SPRITE_CONFIG: Partial<Record<FxType, AnimatedSpriteConfig>> = {
|
||||
},
|
||||
[FxType.SinkingShip]: {
|
||||
url: sinkingShip,
|
||||
frameWidth: 16,
|
||||
frameCount: 14,
|
||||
frameDuration: 90,
|
||||
looping: false,
|
||||
@@ -110,7 +100,6 @@ const ANIMATED_SPRITE_CONFIG: Partial<Record<FxType, AnimatedSpriteConfig>> = {
|
||||
},
|
||||
[FxType.Nuke]: {
|
||||
url: nuke,
|
||||
frameWidth: 60,
|
||||
frameCount: 9,
|
||||
frameDuration: 70,
|
||||
looping: false,
|
||||
@@ -119,7 +108,6 @@ const ANIMATED_SPRITE_CONFIG: Partial<Record<FxType, AnimatedSpriteConfig>> = {
|
||||
},
|
||||
[FxType.SAMExplosion]: {
|
||||
url: SAMExplosion,
|
||||
frameWidth: 48,
|
||||
frameCount: 9,
|
||||
frameDuration: 70,
|
||||
looping: false,
|
||||
@@ -128,7 +116,6 @@ const ANIMATED_SPRITE_CONFIG: Partial<Record<FxType, AnimatedSpriteConfig>> = {
|
||||
},
|
||||
[FxType.Conquest]: {
|
||||
url: conquestSword,
|
||||
frameWidth: 21,
|
||||
frameCount: 10,
|
||||
frameDuration: 90,
|
||||
looping: false,
|
||||
@@ -181,7 +168,6 @@ export class AnimatedSpriteLoader {
|
||||
|
||||
return new AnimatedSprite(
|
||||
image,
|
||||
config.frameWidth,
|
||||
config.frameCount,
|
||||
config.frameDuration,
|
||||
config.looping ?? true,
|
||||
@@ -229,7 +215,6 @@ export class AnimatedSpriteLoader {
|
||||
|
||||
return new AnimatedSprite(
|
||||
image,
|
||||
config.frameWidth,
|
||||
config.frameCount,
|
||||
config.frameDuration,
|
||||
config.looping ?? true,
|
||||
|
||||
@@ -221,7 +221,7 @@ export class AttacksDisplay extends LitElement implements Layer {
|
||||
return this.incomingAttacks.map(
|
||||
(attack) => html`
|
||||
<div
|
||||
class="flex items-center gap-0.5 w-full bg-gray-800/70 backdrop-blur-xs min-[1200px]:rounded-lg sm:rounded-r-lg px-1.5 py-0.5 overflow-hidden"
|
||||
class="flex items-center gap-0.5 w-full bg-gray-800/70 backdrop-blur-xs sm:rounded-lg px-1.5 py-0.5 overflow-hidden"
|
||||
>
|
||||
${this.renderButton({
|
||||
content: html`<img
|
||||
@@ -254,7 +254,7 @@ export class AttacksDisplay extends LitElement implements Layer {
|
||||
/>`,
|
||||
onClick: () => this.handleRetaliate(attack),
|
||||
className:
|
||||
"ml-auto inline-flex items-center justify-center cursor-pointer bg-red-900/50 hover:bg-red-800/70 rounded-lg px-1.5 py-1 border border-red-700/50",
|
||||
"ml-auto inline-flex items-center justify-center cursor-pointer bg-red-900/50 hover:bg-red-800/70 sm:rounded-lg px-1.5 py-1 border border-red-700/50",
|
||||
translate: false,
|
||||
})
|
||||
: ""}
|
||||
@@ -269,7 +269,7 @@ export class AttacksDisplay extends LitElement implements Layer {
|
||||
return this.outgoingAttacks.map(
|
||||
(attack) => html`
|
||||
<div
|
||||
class="flex items-center gap-0.5 w-full bg-gray-800/70 backdrop-blur-xs min-[1200px]:rounded-lg sm:rounded-r-lg px-1.5 py-0.5 overflow-hidden"
|
||||
class="flex items-center gap-0.5 w-full bg-gray-800/70 backdrop-blur-xs sm:rounded-lg px-1.5 py-0.5 overflow-hidden"
|
||||
>
|
||||
${this.renderButton({
|
||||
content: html`<img
|
||||
@@ -311,7 +311,7 @@ export class AttacksDisplay extends LitElement implements Layer {
|
||||
return this.outgoingLandAttacks.map(
|
||||
(landAttack) => html`
|
||||
<div
|
||||
class="flex items-center gap-0.5 w-full bg-gray-800/70 backdrop-blur-xs min-[1200px]:rounded-lg sm:rounded-r-lg px-1.5 py-0.5 overflow-hidden"
|
||||
class="flex items-center gap-0.5 w-full bg-gray-800/70 backdrop-blur-xs sm:rounded-lg px-1.5 py-0.5 overflow-hidden"
|
||||
>
|
||||
${this.renderButton({
|
||||
content: html`<img
|
||||
@@ -367,7 +367,7 @@ export class AttacksDisplay extends LitElement implements Layer {
|
||||
return this.outgoingBoats.map(
|
||||
(boat) => html`
|
||||
<div
|
||||
class="flex items-center gap-0.5 w-full bg-gray-800/70 backdrop-blur-xs min-[1200px]:rounded-lg sm:rounded-r-lg px-1.5 py-0.5 overflow-hidden"
|
||||
class="flex items-center gap-0.5 w-full bg-gray-800/70 backdrop-blur-xs sm:rounded-lg px-1.5 py-0.5 overflow-hidden"
|
||||
>
|
||||
${this.renderButton({
|
||||
content: html`${this.renderBoatIcon(boat)}
|
||||
@@ -403,7 +403,7 @@ export class AttacksDisplay extends LitElement implements Layer {
|
||||
return this.incomingBoats.map(
|
||||
(boat) => html`
|
||||
<div
|
||||
class="flex items-center gap-0.5 w-full bg-gray-800/70 backdrop-blur-xs min-[1200px]:rounded-lg sm:rounded-r-lg px-1.5 py-0.5 overflow-hidden"
|
||||
class="flex items-center gap-0.5 w-full bg-gray-800/70 backdrop-blur-xs sm:rounded-lg px-1.5 py-0.5 overflow-hidden"
|
||||
>
|
||||
${this.renderButton({
|
||||
content: html`${this.renderBoatIcon(boat)}
|
||||
@@ -441,7 +441,7 @@ export class AttacksDisplay extends LitElement implements Layer {
|
||||
|
||||
return html`
|
||||
<div
|
||||
class="w-full mb-1 mt-1 sm:mt-0 pointer-events-auto grid grid-cols-2 sm:grid-cols-1 gap-1 text-white text-sm lg:text-base"
|
||||
class="w-full mb-1 mt-1 sm:mt-0 pointer-events-auto grid grid-cols-2 gap-1 text-white text-sm lg:text-base"
|
||||
>
|
||||
${this.renderOutgoingAttacks()} ${this.renderOutgoingLandAttacks()}
|
||||
${this.renderBoats()} ${this.renderIncomingAttacks()}
|
||||
|
||||
@@ -40,9 +40,6 @@ export class ControlPanel extends LitElement implements Layer {
|
||||
@state()
|
||||
private _attackingTroops: number = 0;
|
||||
|
||||
@state()
|
||||
private _touchDragging = false;
|
||||
|
||||
private _troopRateIsIncreasing: boolean = true;
|
||||
|
||||
private _lastTroopIncreaseRate: number;
|
||||
@@ -127,73 +124,13 @@ export class ControlPanel extends LitElement implements Layer {
|
||||
this.requestUpdate();
|
||||
}
|
||||
|
||||
private _outsideTouchHandler: ((ev: Event) => void) | null = null;
|
||||
|
||||
private handleAttackTouchStart(e: TouchEvent) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
if (this._touchDragging) {
|
||||
this.closeAttackBar();
|
||||
return;
|
||||
}
|
||||
|
||||
this._touchDragging = true;
|
||||
|
||||
setTimeout(() => {
|
||||
this._outsideTouchHandler = () => {
|
||||
this.closeAttackBar();
|
||||
};
|
||||
document.addEventListener("touchstart", this._outsideTouchHandler);
|
||||
}, 0);
|
||||
}
|
||||
|
||||
private closeAttackBar() {
|
||||
this._touchDragging = false;
|
||||
if (this._outsideTouchHandler) {
|
||||
document.removeEventListener("touchstart", this._outsideTouchHandler);
|
||||
this._outsideTouchHandler = null;
|
||||
}
|
||||
}
|
||||
|
||||
private handleBarTouch(e: TouchEvent) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
this.setRatioFromTouch(e.touches[0]);
|
||||
|
||||
const onMove = (ev: TouchEvent) => {
|
||||
ev.preventDefault();
|
||||
this.setRatioFromTouch(ev.touches[0]);
|
||||
};
|
||||
|
||||
const onEnd = () => {
|
||||
document.removeEventListener("touchmove", onMove);
|
||||
document.removeEventListener("touchend", onEnd);
|
||||
};
|
||||
|
||||
document.addEventListener("touchmove", onMove, { passive: false });
|
||||
document.addEventListener("touchend", onEnd);
|
||||
}
|
||||
|
||||
private setRatioFromTouch(touch: Touch) {
|
||||
const barEl = this.querySelector(".attack-drag-bar");
|
||||
if (!barEl) return;
|
||||
|
||||
const rect = barEl.getBoundingClientRect();
|
||||
const ratio = (rect.bottom - touch.clientY) / (rect.bottom - rect.top);
|
||||
this.attackRatio =
|
||||
Math.round(Math.max(1, Math.min(100, ratio * 100))) / 100;
|
||||
this.onAttackRatioChange(this.attackRatio);
|
||||
}
|
||||
|
||||
private handleRatioSliderInput(e: Event) {
|
||||
const value = Number((e.target as HTMLInputElement).value);
|
||||
this.attackRatio = value / 100;
|
||||
this.onAttackRatioChange(this.attackRatio);
|
||||
}
|
||||
|
||||
private renderTroopBar() {
|
||||
private calculateTroopBar(): { greenPercent: number; orangePercent: number } {
|
||||
const base = Math.max(this._maxTroops, 1);
|
||||
const greenPercentRaw = (this._troops / base) * 100;
|
||||
const orangePercentRaw = (this._attackingTroops / base) * 100;
|
||||
@@ -204,9 +141,14 @@ export class ControlPanel extends LitElement implements Layer {
|
||||
Math.min(100 - greenPercent, orangePercentRaw),
|
||||
);
|
||||
|
||||
return { greenPercent, orangePercent };
|
||||
}
|
||||
|
||||
private renderMobileTroopBar() {
|
||||
const { greenPercent, orangePercent } = this.calculateTroopBar();
|
||||
return html`
|
||||
<div
|
||||
class="w-full h-6 lg:h-8 border border-gray-600 rounded-md bg-gray-900/60 overflow-hidden relative"
|
||||
class="w-full h-6 border border-gray-600 rounded-md bg-gray-900/60 overflow-hidden relative"
|
||||
>
|
||||
<div class="h-full flex">
|
||||
${greenPercent > 0
|
||||
@@ -223,7 +165,7 @@ export class ControlPanel extends LitElement implements Layer {
|
||||
: ""}
|
||||
</div>
|
||||
<div
|
||||
class="absolute inset-0 flex items-center justify-between px-1.5 lg:px-2 text-xs lg:text-sm font-bold leading-none pointer-events-none"
|
||||
class="absolute inset-0 flex items-center justify-between px-1.5 text-xs font-bold leading-none pointer-events-none"
|
||||
translate="no"
|
||||
>
|
||||
<span class="text-white drop-shadow-[0_1px_1px_rgba(0,0,0,0.8)]"
|
||||
@@ -243,10 +185,10 @@ export class ControlPanel extends LitElement implements Layer {
|
||||
aria-hidden="true"
|
||||
width="12"
|
||||
height="12"
|
||||
class="lg:w-4 lg:h-4 brightness-0 invert drop-shadow-[0_1px_1px_rgba(0,0,0,0.8)]"
|
||||
class="brightness-0 invert drop-shadow-[0_1px_1px_rgba(0,0,0,0.8)]"
|
||||
/>
|
||||
<span
|
||||
class="text-[10px] lg:text-xs font-bold drop-shadow-[0_1px_1px_rgba(0,0,0,0.8)] ${this
|
||||
class="text-[10px] font-bold drop-shadow-[0_1px_1px_rgba(0,0,0,0.8)] ${this
|
||||
._troopRateIsIncreasing
|
||||
? "text-green-400"
|
||||
: "text-orange-400"}"
|
||||
@@ -257,127 +199,175 @@ export class ControlPanel extends LitElement implements Layer {
|
||||
`;
|
||||
}
|
||||
|
||||
render() {
|
||||
private renderDesktopTroopBar() {
|
||||
const { greenPercent, orangePercent } = this.calculateTroopBar();
|
||||
return html`
|
||||
<div
|
||||
class="relative pointer-events-auto ${this._isVisible
|
||||
? "relative z-[60] w-full lg:max-w-[400px] text-sm lg:text-base bg-gray-800/70 p-1.5 pr-2 lg:p-5 shadow-lg sm:rounded-tr-lg min-[1200px]:rounded-lg backdrop-blur-xs"
|
||||
: "hidden"}"
|
||||
@contextmenu=${(e: MouseEvent) => e.preventDefault()}
|
||||
class="w-full h-6 border border-gray-600 rounded-md bg-gray-900/60 overflow-hidden relative"
|
||||
>
|
||||
<div class="flex gap-2 lg:gap-3 items-center">
|
||||
<!-- Gold: 1/4 -->
|
||||
<div
|
||||
class="flex items-center justify-center p-1 lg:p-1.5 lg:gap-1 border rounded-md border-yellow-400 font-bold text-yellow-400 text-xs lg:text-sm w-1/5 lg:w-auto shrink-0"
|
||||
translate="no"
|
||||
>
|
||||
<img
|
||||
src=${goldCoinIcon}
|
||||
width="13"
|
||||
height="13"
|
||||
class="lg:w-4 lg:h-4"
|
||||
/>
|
||||
<span class="px-0.5">${renderNumber(this._gold)}</span>
|
||||
</div>
|
||||
<!-- Troop bar: 2/4 -->
|
||||
<div class="w-3/5 lg:flex-1">${this.renderTroopBar()}</div>
|
||||
<!-- Attack ratio: 1/4 -->
|
||||
<div
|
||||
class="relative w-1/5 shrink-0 flex items-center justify-center gap-1 cursor-pointer lg:hidden"
|
||||
@touchstart=${(e: TouchEvent) => this.handleAttackTouchStart(e)}
|
||||
>
|
||||
<div class="flex flex-col items-center w-10 shrink-0">
|
||||
<div
|
||||
class="flex items-center gap-0.5 text-white text-xs font-bold tabular-nums"
|
||||
translate="no"
|
||||
>
|
||||
<img
|
||||
src=${swordIcon}
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
width="10"
|
||||
height="10"
|
||||
class="brightness-0 invert sepia saturate-[10000%] hue-rotate-[0deg]"
|
||||
style="filter: brightness(0) saturate(100%) invert(36%) sepia(95%) saturate(5500%) hue-rotate(350deg) brightness(95%) contrast(95%);"
|
||||
/>
|
||||
${(this.attackRatio * 100).toFixed(0)}%
|
||||
</div>
|
||||
<div class="text-[10px] text-red-400 tabular-nums" translate="no">
|
||||
(${renderTroops(
|
||||
(this.game?.myPlayer()?.troops() ?? 0) * this.attackRatio,
|
||||
)})
|
||||
</div>
|
||||
</div>
|
||||
<!-- Small red vertical bar indicator -->
|
||||
<div class="shrink-0">
|
||||
<div
|
||||
class="w-1.5 h-8 bg-white/20 rounded-full relative overflow-hidden"
|
||||
>
|
||||
<div
|
||||
class="absolute bottom-0 w-full bg-red-500 rounded-full transition-all duration-200"
|
||||
style="height: ${this.attackRatio * 100}%"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="h-full flex">
|
||||
${greenPercent > 0
|
||||
? html`<div
|
||||
class="h-full bg-green-500 transition-[width] duration-200"
|
||||
style="width: ${greenPercent}%;"
|
||||
></div>`
|
||||
: ""}
|
||||
${orangePercent > 0
|
||||
? html`<div
|
||||
class="h-full bg-orange-400 transition-[width] duration-200"
|
||||
style="width: ${orangePercent}%;"
|
||||
></div>`
|
||||
: ""}
|
||||
</div>
|
||||
${this._touchDragging
|
||||
? html`
|
||||
<div
|
||||
class="absolute bottom-full right-0 flex flex-col items-center pointer-events-auto z-[10000] bg-gray-800/70 backdrop-blur-xs rounded-tl-lg sm:rounded-lg p-2 w-12"
|
||||
style="height: 50vh;"
|
||||
@touchstart=${(e: TouchEvent) => this.handleBarTouch(e)}
|
||||
>
|
||||
<span class="text-red-400 text-sm font-bold mb-1" translate="no"
|
||||
>${(this.attackRatio * 100).toFixed(0)}%</span
|
||||
>
|
||||
<div
|
||||
class="attack-drag-bar flex-1 w-3 bg-white/20 rounded-full relative overflow-hidden"
|
||||
>
|
||||
<div
|
||||
class="absolute bottom-0 w-full bg-red-500 rounded-full"
|
||||
style="height: ${this.attackRatio * 100}%"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
: ""}
|
||||
<!-- Attack ratio bar (desktop, always visible) -->
|
||||
<div class="hidden lg:block mt-2">
|
||||
<div
|
||||
class="flex items-center justify-between text-sm font-bold mb-1"
|
||||
translate="no"
|
||||
<div
|
||||
class="absolute inset-0 flex items-center justify-start px-1.5 text-xs font-bold leading-none pointer-events-none gap-0.5"
|
||||
translate="no"
|
||||
>
|
||||
<span class="text-white drop-shadow-[0_1px_1px_rgba(0,0,0,0.8)]"
|
||||
>${renderTroops(this._troops)}</span
|
||||
>
|
||||
<span class="text-white flex items-center gap-1"
|
||||
><img
|
||||
src=${swordIcon}
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
width="14"
|
||||
height="14"
|
||||
style="filter: brightness(0) saturate(100%) invert(36%) sepia(95%) saturate(5500%) hue-rotate(350deg) brightness(95%) contrast(95%);"
|
||||
/>Attack Ratio</span
|
||||
>
|
||||
<span class="text-white tabular-nums"
|
||||
>${(this.attackRatio * 100).toFixed(0)}%
|
||||
(${renderTroops(
|
||||
(this.game?.myPlayer()?.troops() ?? 0) * this.attackRatio,
|
||||
)})</span
|
||||
>
|
||||
</div>
|
||||
<span class="text-white/60 drop-shadow-[0_1px_1px_rgba(0,0,0,0.8)]"
|
||||
>/</span
|
||||
>
|
||||
<span class="text-white drop-shadow-[0_1px_1px_rgba(0,0,0,0.8)]"
|
||||
>${renderTroops(this._maxTroops)}</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderDesktop() {
|
||||
return html`
|
||||
<!-- Row 1: troop rate | troop bar | gold -->
|
||||
<div class="flex gap-1.5 items-center mb-1.5">
|
||||
<!-- Troop rate -->
|
||||
<div
|
||||
class="flex items-center gap-1 shrink-0 border rounded-md font-bold text-xs p-1 w-[5.5rem] ${this
|
||||
._troopRateIsIncreasing
|
||||
? "border-green-400"
|
||||
: "border-orange-400"}"
|
||||
translate="no"
|
||||
>
|
||||
<img
|
||||
src=${soldierIcon}
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
width="13"
|
||||
height="13"
|
||||
class="shrink-0"
|
||||
style="filter: ${this._troopRateIsIncreasing
|
||||
? "brightness(0) saturate(100%) invert(74%) sepia(44%) saturate(500%) hue-rotate(83deg) brightness(103%)"
|
||||
: "brightness(0) saturate(100%) invert(65%) sepia(60%) saturate(600%) hue-rotate(330deg) brightness(105%)"}"
|
||||
/>
|
||||
<span
|
||||
class="text-xs font-bold tabular-nums ${this._troopRateIsIncreasing
|
||||
? "text-green-400"
|
||||
: "text-orange-400"}"
|
||||
>+${renderTroops(this.troopRate)}/s</span
|
||||
>
|
||||
</div>
|
||||
<!-- Troop bar -->
|
||||
<div class="flex-1">${this.renderDesktopTroopBar()}</div>
|
||||
<!-- Gold -->
|
||||
<div
|
||||
class="flex items-center gap-1 shrink-0 border rounded-md border-yellow-400 font-bold text-yellow-400 text-xs p-1 w-[4.5rem]"
|
||||
translate="no"
|
||||
>
|
||||
<img src=${goldCoinIcon} width="13" height="13" class="shrink-0" />
|
||||
<span class="tabular-nums">${renderNumber(this._gold)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Row 2: attack ratio | slider -->
|
||||
<div class="flex items-center gap-2" translate="no">
|
||||
<div
|
||||
class="flex items-center gap-1 shrink-0 border border-gray-600 rounded-md p-1 text-xs font-bold text-white cursor-pointer w-[7rem]"
|
||||
>
|
||||
<img
|
||||
src=${swordIcon}
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
width="12"
|
||||
height="12"
|
||||
style="filter: brightness(0) invert(1);"
|
||||
/>
|
||||
<span
|
||||
>${(this.attackRatio * 100).toFixed(0)}%
|
||||
(${renderTroops(
|
||||
(this.game?.myPlayer()?.troops() ?? 0) * this.attackRatio,
|
||||
)})</span
|
||||
>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min="1"
|
||||
max="100"
|
||||
.value=${String(Math.round(this.attackRatio * 100))}
|
||||
@input=${(e: Event) => this.handleRatioSliderInput(e)}
|
||||
class="flex-1 h-2 accent-blue-500 cursor-pointer"
|
||||
/>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderMobile() {
|
||||
return html`
|
||||
<div class="flex gap-2 items-center">
|
||||
<!-- Gold -->
|
||||
<div
|
||||
class="flex items-center justify-center p-1 gap-0.5 border rounded-md border-yellow-400 font-bold text-yellow-400 text-xs w-1/5 shrink-0"
|
||||
translate="no"
|
||||
>
|
||||
<img src=${goldCoinIcon} width="13" height="13" />
|
||||
<span class="px-0.5">${renderNumber(this._gold)}</span>
|
||||
</div>
|
||||
<!-- Troop bar -->
|
||||
<div class="w-[40%] shrink-0 flex items-center">
|
||||
${this.renderMobileTroopBar()}
|
||||
</div>
|
||||
<!-- Sword + % label -->
|
||||
<div class="flex flex-col items-center shrink-0 gap-0.5" translate="no">
|
||||
<img
|
||||
src=${swordIcon}
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
width="10"
|
||||
height="10"
|
||||
style="filter: brightness(0) invert(1);"
|
||||
/>
|
||||
<span class="text-white text-xs font-bold tabular-nums"
|
||||
>${(this.attackRatio * 100).toFixed(0)}%</span
|
||||
>
|
||||
</div>
|
||||
<!-- Attack ratio slider -->
|
||||
<div class="flex-1" translate="no">
|
||||
<input
|
||||
type="range"
|
||||
min="1"
|
||||
max="100"
|
||||
.value=${String(Math.round(this.attackRatio * 100))}
|
||||
@input=${(e: Event) => this.handleRatioSliderInput(e)}
|
||||
class="w-full h-2 accent-red-500 cursor-pointer"
|
||||
class="w-full h-1.5 accent-blue-500 cursor-pointer"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
render() {
|
||||
return html`
|
||||
<div
|
||||
class="relative pointer-events-auto ${this._isVisible
|
||||
? "relative w-full text-sm px-2 py-1.5"
|
||||
: "hidden"}"
|
||||
@contextmenu=${(e: MouseEvent) => e.preventDefault()}
|
||||
>
|
||||
<div class="lg:hidden">${this.renderMobile()}</div>
|
||||
<div class="hidden lg:block">${this.renderDesktop()}</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
createRenderRoot() {
|
||||
return this; // Disable shadow DOM to allow Tailwind styles
|
||||
}
|
||||
|
||||
@@ -794,9 +794,7 @@ export class EventsDisplay extends LitElement implements Layer {
|
||||
<!-- Events Toggle (when hidden) -->
|
||||
${this._hidden
|
||||
? html`
|
||||
<div
|
||||
class="relative w-fit min-[1200px]:bottom-4 min-[1200px]:right-4 z-50"
|
||||
>
|
||||
<div class="relative w-fit z-50">
|
||||
${this.renderButton({
|
||||
content: html`
|
||||
<span class="flex items-center gap-2">
|
||||
@@ -811,18 +809,18 @@ export class EventsDisplay extends LitElement implements Layer {
|
||||
`,
|
||||
onClick: this.toggleHidden,
|
||||
className:
|
||||
"text-white cursor-pointer pointer-events-auto w-fit p-2 lg:p-3 min-[1200px]:rounded-lg max-sm:rounded-tr-lg sm:rounded-tl-lg bg-gray-800/70 backdrop-blur-xs",
|
||||
"text-white cursor-pointer pointer-events-auto w-fit p-2 lg:p-3 min-[1200px]:rounded-lg sm:rounded-tl-lg bg-gray-800/70 backdrop-blur-xs",
|
||||
})}
|
||||
</div>
|
||||
`
|
||||
: html`
|
||||
<!-- Main Events Display -->
|
||||
<div
|
||||
class="relative w-full min-[1200px]:bottom-4 min-[1200px]:right-4 z-50 min-[1200px]:w-96 backdrop-blur-sm"
|
||||
class="relative w-full z-50 min-[1200px]:w-96 backdrop-blur-sm"
|
||||
>
|
||||
<!-- Button Bar -->
|
||||
<div
|
||||
class="w-full p-2 lg:p-3 bg-gray-800/70 min-[1200px]:rounded-t-lg sm:rounded-tl-lg"
|
||||
class="w-full p-2 lg:p-3 bg-gray-800/70 sm:rounded-tl-lg min-[1200px]:rounded-t-lg"
|
||||
>
|
||||
<div class="flex justify-between items-center gap-3">
|
||||
<div class="flex gap-4">
|
||||
|
||||
@@ -31,7 +31,8 @@ export class MultiTabModal extends LitElement implements Layer {
|
||||
if (
|
||||
this.game.inSpawnPhase() ||
|
||||
this.game.config().gameConfig().gameType === GameType.Singleplayer ||
|
||||
this.game.config().serverConfig().env() === GameEnv.Dev
|
||||
this.game.config().serverConfig().env() === GameEnv.Dev ||
|
||||
this.game.config().isReplay()
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ import { TileRef } from "../../../core/game/GameMap";
|
||||
import { GameUpdateType } from "../../../core/game/GameUpdates";
|
||||
import { GameView, UnitView } from "../../../core/game/GameView";
|
||||
import {
|
||||
ConfirmGhostStructureEvent,
|
||||
GhostStructureChangedEvent,
|
||||
MouseMoveEvent,
|
||||
MouseUpEvent,
|
||||
@@ -43,6 +44,11 @@ import {
|
||||
} from "./StructureDrawingUtils";
|
||||
import bitmapFont from "/fonts/round_6x6_modified.xml?url";
|
||||
|
||||
/** True for nuke types (AtomBomb, HydrogenBomb): ghost is preserved after placement so user can place multiple or keep selection (Enter/key confirm). */
|
||||
export function shouldPreserveGhostAfterBuild(unitType: UnitType): boolean {
|
||||
return unitType === UnitType.AtomBomb || unitType === UnitType.HydrogenBomb;
|
||||
}
|
||||
|
||||
extend([a11yPlugin]);
|
||||
|
||||
class StructureRenderInfo {
|
||||
@@ -92,6 +98,7 @@ export class StructureIconsLayer implements Layer {
|
||||
> = new Map(Structures.types.map((type) => [type, { visible: true }]));
|
||||
private lastGhostQueryAt: number;
|
||||
private visibilityStateDirty = true;
|
||||
private pendingConfirm: MouseUpEvent | null = null;
|
||||
private hasHiddenStructure = false;
|
||||
potentialUpgrade: StructureRenderInfo | undefined;
|
||||
|
||||
@@ -171,7 +178,12 @@ export class StructureIconsLayer implements Layer {
|
||||
);
|
||||
this.eventBus.on(MouseMoveEvent, (e) => this.moveGhost(e));
|
||||
|
||||
this.eventBus.on(MouseUpEvent, (e) => this.createStructure(e));
|
||||
this.eventBus.on(MouseUpEvent, (e) => this.requestConfirmStructure(e));
|
||||
this.eventBus.on(ConfirmGhostStructureEvent, () =>
|
||||
this.requestConfirmStructure(
|
||||
new MouseUpEvent(this.mousePos.x, this.mousePos.y),
|
||||
),
|
||||
);
|
||||
|
||||
window.addEventListener("resize", () => this.resizeCanvas());
|
||||
await this.setupRenderer();
|
||||
@@ -307,7 +319,10 @@ export class StructureIconsLayer implements Layer {
|
||||
this.ghostUnit.container.filters = [];
|
||||
}
|
||||
|
||||
if (!this.ghostUnit) return;
|
||||
if (!this.ghostUnit) {
|
||||
this.pendingConfirm = null;
|
||||
return;
|
||||
}
|
||||
|
||||
const unit = buildables.find(
|
||||
(u) => u.type === this.ghostUnit!.buildableUnit.type,
|
||||
@@ -322,6 +337,7 @@ export class StructureIconsLayer implements Layer {
|
||||
this.ghostUnit.container.filters = [
|
||||
new OutlineFilter({ thickness: 2, color: "rgba(255, 0, 0, 1)" }),
|
||||
];
|
||||
this.pendingConfirm = null;
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -369,6 +385,14 @@ export class StructureIconsLayer implements Layer {
|
||||
: Math.min(1, scale / ICON_SCALE_FACTOR_ZOOMED_OUT);
|
||||
this.ghostUnit.container.scale.set(s);
|
||||
this.ghostUnit.range?.scale.set(this.transformHandler.scale);
|
||||
|
||||
if (this.pendingConfirm !== null) {
|
||||
const ev = this.pendingConfirm;
|
||||
this.pendingConfirm = null;
|
||||
if (this.isGhostReadyForConfirm()) {
|
||||
this.createStructure(ev);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -399,6 +423,30 @@ export class StructureIconsLayer implements Layer {
|
||||
.fill({ color: 0x000000, alpha: 0.65 });
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the ghost exists and buildableUnit has been refreshed (canBuild or canUpgrade set).
|
||||
* Used to avoid running createStructure before renderGhost's async buildables() has updated the ghost.
|
||||
*/
|
||||
private isGhostReadyForConfirm(): boolean {
|
||||
if (!this.ghostUnit) return false;
|
||||
const bu = this.ghostUnit.buildableUnit;
|
||||
return bu.canBuild !== false || bu.canUpgrade !== false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Request confirm (place/upgrade): run createStructure now if ghost is ready, otherwise defer until
|
||||
* renderGhost's buildables() callback has updated the ghost. Shared by Enter (ConfirmGhostStructureEvent)
|
||||
* and mouse click (MouseUpEvent) so numpad-select-then-confirm works.
|
||||
*/
|
||||
private requestConfirmStructure(e: MouseUpEvent): void {
|
||||
if (!this.ghostUnit && !this.uiState.ghostStructure) return;
|
||||
if (this.isGhostReadyForConfirm()) {
|
||||
this.createStructure(e);
|
||||
} else {
|
||||
this.pendingConfirm = e;
|
||||
}
|
||||
}
|
||||
|
||||
private createStructure(e: MouseUpEvent) {
|
||||
if (!this.ghostUnit) return;
|
||||
if (
|
||||
@@ -420,6 +468,7 @@ export class StructureIconsLayer implements Layer {
|
||||
this.ghostUnit.buildableUnit.type,
|
||||
),
|
||||
);
|
||||
this.removeGhostStructure();
|
||||
} else if (this.ghostUnit.buildableUnit.canBuild) {
|
||||
const unitType = this.ghostUnit.buildableUnit.type;
|
||||
const rocketDirectionUp =
|
||||
@@ -433,8 +482,12 @@ export class StructureIconsLayer implements Layer {
|
||||
rocketDirectionUp,
|
||||
),
|
||||
);
|
||||
if (!shouldPreserveGhostAfterBuild(unitType)) {
|
||||
this.removeGhostStructure();
|
||||
}
|
||||
} else {
|
||||
this.removeGhostStructure();
|
||||
}
|
||||
this.removeGhostStructure();
|
||||
}
|
||||
|
||||
private moveGhost(e: MouseMoveEvent) {
|
||||
@@ -489,6 +542,7 @@ export class StructureIconsLayer implements Layer {
|
||||
}
|
||||
|
||||
private clearGhostStructure() {
|
||||
this.pendingConfirm = null;
|
||||
if (this.ghostUnit) {
|
||||
this.ghostUnit.container.destroy();
|
||||
this.ghostUnit.range?.destroy();
|
||||
|
||||
@@ -126,86 +126,80 @@ export class UnitDisplay extends LitElement implements Layer {
|
||||
}
|
||||
|
||||
return html`
|
||||
<div
|
||||
class="hidden min-[1200px]:flex fixed bottom-4 left-1/2 transform -translate-x-1/2 z-[1100] 2xl:flex-row xl:flex-col min-[1200px]:flex-col 2xl:gap-5 xl:gap-2 min-[1200px]:gap-2 justify-center items-center"
|
||||
>
|
||||
<div class="bg-gray-800/70 backdrop-blur-xs rounded-lg p-0.5">
|
||||
<div class="grid grid-rows-1 auto-cols-max grid-flow-col gap-1 w-fit">
|
||||
${this.renderUnitItem(
|
||||
cityIcon,
|
||||
this._cities,
|
||||
UnitType.City,
|
||||
"city",
|
||||
this.keybinds["buildCity"]?.key ?? "1",
|
||||
)}
|
||||
${this.renderUnitItem(
|
||||
factoryIcon,
|
||||
this._factories,
|
||||
UnitType.Factory,
|
||||
"factory",
|
||||
this.keybinds["buildFactory"]?.key ?? "2",
|
||||
)}
|
||||
${this.renderUnitItem(
|
||||
portIcon,
|
||||
this._port,
|
||||
UnitType.Port,
|
||||
"port",
|
||||
this.keybinds["buildPort"]?.key ?? "3",
|
||||
)}
|
||||
${this.renderUnitItem(
|
||||
defensePostIcon,
|
||||
this._defensePost,
|
||||
UnitType.DefensePost,
|
||||
"defense_post",
|
||||
this.keybinds["buildDefensePost"]?.key ?? "4",
|
||||
)}
|
||||
${this.renderUnitItem(
|
||||
missileSiloIcon,
|
||||
this._missileSilo,
|
||||
UnitType.MissileSilo,
|
||||
"missile_silo",
|
||||
this.keybinds["buildMissileSilo"]?.key ?? "5",
|
||||
)}
|
||||
${this.renderUnitItem(
|
||||
samLauncherIcon,
|
||||
this._samLauncher,
|
||||
UnitType.SAMLauncher,
|
||||
"sam_launcher",
|
||||
this.keybinds["buildSamLauncher"]?.key ?? "6",
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-gray-800/70 backdrop-blur-xs rounded-lg p-0.5 w-fit">
|
||||
<div class="grid grid-rows-1 auto-cols-max grid-flow-col gap-1">
|
||||
${this.renderUnitItem(
|
||||
warshipIcon,
|
||||
this._warships,
|
||||
UnitType.Warship,
|
||||
"warship",
|
||||
this.keybinds["buildWarship"]?.key ?? "7",
|
||||
)}
|
||||
${this.renderUnitItem(
|
||||
atomBombIcon,
|
||||
null,
|
||||
UnitType.AtomBomb,
|
||||
"atom_bomb",
|
||||
this.keybinds["buildAtomBomb"]?.key ?? "8",
|
||||
)}
|
||||
${this.renderUnitItem(
|
||||
hydrogenBombIcon,
|
||||
null,
|
||||
UnitType.HydrogenBomb,
|
||||
"hydrogen_bomb",
|
||||
this.keybinds["buildHydrogenBomb"]?.key ?? "9",
|
||||
)}
|
||||
${this.renderUnitItem(
|
||||
mirvIcon,
|
||||
null,
|
||||
UnitType.MIRV,
|
||||
"mirv",
|
||||
this.keybinds["buildMIRV"]?.key ?? "0",
|
||||
)}
|
||||
</div>
|
||||
<div class="border-t border-white/10 p-0.5 w-full">
|
||||
<div
|
||||
class="grid grid-rows-1 auto-cols-max grid-flow-col gap-0.5 w-fit mx-auto"
|
||||
>
|
||||
${this.renderUnitItem(
|
||||
cityIcon,
|
||||
this._cities,
|
||||
UnitType.City,
|
||||
"city",
|
||||
this.keybinds["buildCity"]?.key ?? "1",
|
||||
)}
|
||||
${this.renderUnitItem(
|
||||
factoryIcon,
|
||||
this._factories,
|
||||
UnitType.Factory,
|
||||
"factory",
|
||||
this.keybinds["buildFactory"]?.key ?? "2",
|
||||
)}
|
||||
${this.renderUnitItem(
|
||||
portIcon,
|
||||
this._port,
|
||||
UnitType.Port,
|
||||
"port",
|
||||
this.keybinds["buildPort"]?.key ?? "3",
|
||||
)}
|
||||
${this.renderUnitItem(
|
||||
defensePostIcon,
|
||||
this._defensePost,
|
||||
UnitType.DefensePost,
|
||||
"defense_post",
|
||||
this.keybinds["buildDefensePost"]?.key ?? "4",
|
||||
)}
|
||||
${this.renderUnitItem(
|
||||
missileSiloIcon,
|
||||
this._missileSilo,
|
||||
UnitType.MissileSilo,
|
||||
"missile_silo",
|
||||
this.keybinds["buildMissileSilo"]?.key ?? "5",
|
||||
)}
|
||||
${this.renderUnitItem(
|
||||
samLauncherIcon,
|
||||
this._samLauncher,
|
||||
UnitType.SAMLauncher,
|
||||
"sam_launcher",
|
||||
this.keybinds["buildSamLauncher"]?.key ?? "6",
|
||||
)}
|
||||
${this.renderUnitItem(
|
||||
warshipIcon,
|
||||
this._warships,
|
||||
UnitType.Warship,
|
||||
"warship",
|
||||
this.keybinds["buildWarship"]?.key ?? "7",
|
||||
)}
|
||||
${this.renderUnitItem(
|
||||
atomBombIcon,
|
||||
null,
|
||||
UnitType.AtomBomb,
|
||||
"atom_bomb",
|
||||
this.keybinds["buildAtomBomb"]?.key ?? "8",
|
||||
)}
|
||||
${this.renderUnitItem(
|
||||
hydrogenBombIcon,
|
||||
null,
|
||||
UnitType.HydrogenBomb,
|
||||
"hydrogen_bomb",
|
||||
this.keybinds["buildHydrogenBomb"]?.key ?? "9",
|
||||
)}
|
||||
${this.renderUnitItem(
|
||||
mirvIcon,
|
||||
null,
|
||||
UnitType.MIRV,
|
||||
"mirv",
|
||||
this.keybinds["buildMIRV"]?.key ?? "0",
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
@@ -243,7 +237,7 @@ export class UnitDisplay extends LitElement implements Layer {
|
||||
${hovered
|
||||
? html`
|
||||
<div
|
||||
class="absolute -top-[250%] left-1/2 -translate-x-1/2 text-gray-200 text-center w-max text-xs bg-gray-800/90 backdrop-blur-xs rounded-sm p-1 z-20 shadow-lg pointer-events-none"
|
||||
class="absolute -top-[250%] left-1/2 -translate-x-1/2 text-gray-200 text-center w-max text-xs bg-gray-800/90 backdrop-blur-xs rounded-sm p-1 z-[100] shadow-lg pointer-events-none"
|
||||
>
|
||||
<div class="font-bold text-sm mb-1">
|
||||
${translateText(
|
||||
@@ -265,7 +259,7 @@ export class UnitDisplay extends LitElement implements Layer {
|
||||
<div
|
||||
class="${this.canBuild(unitType)
|
||||
? ""
|
||||
: "opacity-40"} border border-slate-500 rounded-sm pr-2 pb-1 flex items-center gap-2 cursor-pointer
|
||||
: "opacity-40"} border border-slate-500 rounded-sm px-0.5 pb-0.5 flex items-center gap-0.5 cursor-pointer
|
||||
${selected ? "hover:bg-gray-400/10" : "hover:bg-gray-800"}
|
||||
rounded-sm text-white ${selected ? "bg-slate-400/20" : ""}"
|
||||
@click=${() => {
|
||||
@@ -299,12 +293,14 @@ export class UnitDisplay extends LitElement implements Layer {
|
||||
@mouseleave=${() =>
|
||||
this.eventBus?.emit(new ToggleStructureEvent(null))}
|
||||
>
|
||||
${html`<div class="ml-1 text-xs relative -top-1.5 text-gray-400">
|
||||
${html`<div class="ml-0.5 text-[10px] relative -top-1 text-gray-400">
|
||||
${displayHotkey}
|
||||
</div>`}
|
||||
<div class="flex items-center gap-1 pt-1">
|
||||
<img src=${icon} alt=${structureKey} class="align-middle size-6" />
|
||||
${number !== null ? renderNumber(number) : null}
|
||||
<div class="flex items-center gap-0.5 pt-0.5">
|
||||
<img src=${icon} alt=${structureKey} class="align-middle size-5" />
|
||||
${number !== null
|
||||
? html`<span class="text-xs">${renderNumber(number)}</span>`
|
||||
: null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -33,7 +33,7 @@ import { simpleHash } from "./Util";
|
||||
|
||||
export async function createGameRunner(
|
||||
gameStart: GameStartInfo,
|
||||
clientID: ClientID,
|
||||
clientID: ClientID | undefined,
|
||||
mapLoader: GameMapLoader,
|
||||
callBack: (gu: GameUpdateViewData | ErrorUpdate) => void,
|
||||
): Promise<GameRunner> {
|
||||
|
||||
+3
-2
@@ -552,8 +552,9 @@ export const ServerStartGameMessageSchema = z.object({
|
||||
turns: TurnSchema.array(),
|
||||
gameStartInfo: GameStartInfoSchema,
|
||||
lobbyCreatedAt: z.number(),
|
||||
// The clientID assigned to this connection by the server
|
||||
myClientID: ID,
|
||||
// The clientID assigned to this connection by the server.
|
||||
// Absent for replays where the viewer has no player identity.
|
||||
myClientID: ID.optional(),
|
||||
});
|
||||
|
||||
export const ServerDesyncSchema = z.object({
|
||||
|
||||
@@ -36,7 +36,7 @@ export class Executor {
|
||||
constructor(
|
||||
private mg: Game,
|
||||
private gameID: GameID,
|
||||
private clientID: ClientID,
|
||||
private clientID: ClientID | undefined,
|
||||
) {
|
||||
// Add one to avoid id collisions with bots.
|
||||
this.random = new PseudoRandom(simpleHash(gameID) + 1);
|
||||
|
||||
@@ -137,6 +137,7 @@ export enum GameMapType {
|
||||
Alps = "Alps",
|
||||
NileDelta = "Nile Delta",
|
||||
Arctic = "Arctic",
|
||||
SanFrancisco = "San Francisco",
|
||||
}
|
||||
|
||||
export type GameMapName = keyof typeof GameMapType;
|
||||
@@ -186,6 +187,7 @@ export const mapCategories: Record<string, GameMapType[]> = {
|
||||
GameMapType.Alps,
|
||||
GameMapType.NileDelta,
|
||||
GameMapType.Arctic,
|
||||
GameMapType.SanFrancisco,
|
||||
],
|
||||
fantasy: [
|
||||
GameMapType.Pangaea,
|
||||
|
||||
@@ -657,7 +657,7 @@ export class GameView implements GameMap {
|
||||
public worker: WorkerClient,
|
||||
private _config: Config,
|
||||
private _mapData: TerrainMapData,
|
||||
private _myClientID: ClientID,
|
||||
private _myClientID: ClientID | undefined,
|
||||
private _myUsername: string,
|
||||
private _gameID: GameID,
|
||||
private humans: Player[],
|
||||
@@ -785,7 +785,9 @@ export class GameView implements GameMap {
|
||||
}
|
||||
});
|
||||
|
||||
this._myPlayer ??= this.playerByClientID(this._myClientID);
|
||||
if (this._myClientID) {
|
||||
this._myPlayer ??= this.playerByClientID(this._myClientID);
|
||||
}
|
||||
|
||||
for (const unit of this._units.values()) {
|
||||
unit._wasUpdated = false;
|
||||
@@ -1103,7 +1105,7 @@ export class GameView implements GameMap {
|
||||
);
|
||||
}
|
||||
|
||||
myClientID(): ClientID {
|
||||
myClientID(): ClientID | undefined {
|
||||
return this._myClientID;
|
||||
}
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ export class WorkerClient {
|
||||
|
||||
constructor(
|
||||
private gameStartInfo: GameStartInfo,
|
||||
private clientID: ClientID,
|
||||
private clientID: ClientID | undefined,
|
||||
) {
|
||||
this.worker = new Worker(new URL("./Worker.worker.ts", import.meta.url), {
|
||||
type: "module",
|
||||
|
||||
@@ -39,7 +39,7 @@ interface BaseWorkerMessage {
|
||||
export interface InitMessage extends BaseWorkerMessage {
|
||||
type: "init";
|
||||
gameStartInfo: GameStartInfo;
|
||||
clientID: ClientID;
|
||||
clientID: ClientID | undefined;
|
||||
}
|
||||
|
||||
export interface TurnMessage extends BaseWorkerMessage {
|
||||
|
||||
@@ -80,6 +80,7 @@ const frequency: Partial<Record<GameMapName, number>> = {
|
||||
Alps: 4,
|
||||
NileDelta: 4,
|
||||
Arctic: 6,
|
||||
SanFrancisco: 3,
|
||||
};
|
||||
|
||||
const TEAM_WEIGHTS: { config: TeamCountConfig; weight: number }[] = [
|
||||
|
||||
+188
-1
@@ -1,5 +1,11 @@
|
||||
import { AutoUpgradeEvent, InputHandler } from "../src/client/InputHandler";
|
||||
import {
|
||||
AutoUpgradeEvent,
|
||||
ConfirmGhostStructureEvent,
|
||||
InputHandler,
|
||||
} from "../src/client/InputHandler";
|
||||
import { UIState } from "../src/client/graphics/UIState";
|
||||
import { EventBus } from "../src/core/EventBus";
|
||||
import { UnitType } from "../src/core/game/Game";
|
||||
|
||||
class MockPointerEvent {
|
||||
button: number;
|
||||
@@ -462,4 +468,185 @@ describe("InputHandler AutoUpgrade", () => {
|
||||
spy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Enter key confirm ghost structure", () => {
|
||||
let uiState: UIState;
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.removeItem("settings.keybinds");
|
||||
uiState = {
|
||||
attackRatio: 20,
|
||||
ghostStructure: null,
|
||||
rocketDirectionUp: true,
|
||||
overlappingRailroads: [],
|
||||
ghostRailPaths: [],
|
||||
} as UIState;
|
||||
inputHandler = new InputHandler(uiState, mockCanvas, eventBus);
|
||||
inputHandler.initialize();
|
||||
});
|
||||
|
||||
test("emits ConfirmGhostStructureEvent on Enter when ghost structure is set", () => {
|
||||
const mockEmit = vi.spyOn(eventBus, "emit");
|
||||
uiState.ghostStructure = UnitType.City;
|
||||
|
||||
window.dispatchEvent(new KeyboardEvent("keydown", { code: "Enter" }));
|
||||
|
||||
expect(mockEmit).toHaveBeenCalledWith(
|
||||
expect.any(ConfirmGhostStructureEvent),
|
||||
);
|
||||
});
|
||||
|
||||
test("emits ConfirmGhostStructureEvent on NumpadEnter when ghost structure is set", () => {
|
||||
const mockEmit = vi.spyOn(eventBus, "emit");
|
||||
uiState.ghostStructure = UnitType.Factory;
|
||||
|
||||
window.dispatchEvent(
|
||||
new KeyboardEvent("keydown", { code: "NumpadEnter" }),
|
||||
);
|
||||
|
||||
expect(mockEmit).toHaveBeenCalledWith(
|
||||
expect.any(ConfirmGhostStructureEvent),
|
||||
);
|
||||
});
|
||||
|
||||
test("does not emit ConfirmGhostStructureEvent on Enter when no ghost structure", () => {
|
||||
const mockEmit = vi.spyOn(eventBus, "emit");
|
||||
expect(uiState.ghostStructure).toBeNull();
|
||||
|
||||
window.dispatchEvent(new KeyboardEvent("keydown", { code: "Enter" }));
|
||||
|
||||
const confirmCalls = mockEmit.mock.calls.filter(
|
||||
(call) => call[0] instanceof ConfirmGhostStructureEvent,
|
||||
);
|
||||
expect(confirmCalls).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Numpad number keys for build keybinds", () => {
|
||||
beforeEach(() => {
|
||||
localStorage.removeItem("settings.keybinds");
|
||||
inputHandler.destroy();
|
||||
const uiState: UIState = {
|
||||
attackRatio: 20,
|
||||
ghostStructure: null,
|
||||
rocketDirectionUp: true,
|
||||
overlappingRailroads: [],
|
||||
ghostRailPaths: [],
|
||||
} as UIState;
|
||||
inputHandler = new InputHandler(uiState, mockCanvas, eventBus);
|
||||
inputHandler.initialize();
|
||||
});
|
||||
|
||||
test("Numpad1 sets ghost structure to City when buildCity is Digit1", () => {
|
||||
window.dispatchEvent(
|
||||
new KeyboardEvent("keyup", { code: "Numpad1", key: "1" }),
|
||||
);
|
||||
expect(inputHandler["uiState"].ghostStructure).toBe(UnitType.City);
|
||||
});
|
||||
|
||||
test("Numpad5 sets ghost structure to MissileSilo when buildMissileSilo is Digit5", () => {
|
||||
window.dispatchEvent(
|
||||
new KeyboardEvent("keyup", { code: "Numpad5", key: "5" }),
|
||||
);
|
||||
expect(inputHandler["uiState"].ghostStructure).toBe(UnitType.MissileSilo);
|
||||
});
|
||||
|
||||
test("Numpad0 sets ghost structure to MIRV when buildMIRV is Digit0", () => {
|
||||
window.dispatchEvent(
|
||||
new KeyboardEvent("keyup", { code: "Numpad0", key: "0" }),
|
||||
);
|
||||
expect(inputHandler["uiState"].ghostStructure).toBe(UnitType.MIRV);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Build keybind two-phase matching (exact code first, then digit/Numpad alias)", () => {
|
||||
beforeEach(() => {
|
||||
localStorage.removeItem("settings.keybinds");
|
||||
inputHandler.destroy();
|
||||
const uiState: UIState = {
|
||||
attackRatio: 20,
|
||||
ghostStructure: null,
|
||||
rocketDirectionUp: true,
|
||||
overlappingRailroads: [],
|
||||
ghostRailPaths: [],
|
||||
} as UIState;
|
||||
inputHandler = new InputHandler(uiState, mockCanvas, eventBus);
|
||||
inputHandler.initialize();
|
||||
});
|
||||
|
||||
test("exact code match wins: Digit1 sets City when buildCity=Digit1 and buildFactory=Numpad1", () => {
|
||||
localStorage.setItem(
|
||||
"settings.keybinds",
|
||||
JSON.stringify({
|
||||
buildCity: "Digit1",
|
||||
buildFactory: "Numpad1",
|
||||
}),
|
||||
);
|
||||
inputHandler.destroy();
|
||||
const uiState: UIState = {
|
||||
attackRatio: 20,
|
||||
ghostStructure: null,
|
||||
rocketDirectionUp: true,
|
||||
overlappingRailroads: [],
|
||||
ghostRailPaths: [],
|
||||
} as UIState;
|
||||
inputHandler = new InputHandler(uiState, mockCanvas, eventBus);
|
||||
inputHandler.initialize();
|
||||
|
||||
window.dispatchEvent(
|
||||
new KeyboardEvent("keyup", { code: "Digit1", key: "1" }),
|
||||
);
|
||||
|
||||
expect(inputHandler["uiState"].ghostStructure).toBe(UnitType.City);
|
||||
});
|
||||
|
||||
test("exact code match wins: Numpad1 sets Factory when buildCity=Digit1 and buildFactory=Numpad1", () => {
|
||||
localStorage.setItem(
|
||||
"settings.keybinds",
|
||||
JSON.stringify({
|
||||
buildCity: "Digit1",
|
||||
buildFactory: "Numpad1",
|
||||
}),
|
||||
);
|
||||
inputHandler.destroy();
|
||||
const uiState: UIState = {
|
||||
attackRatio: 20,
|
||||
ghostStructure: null,
|
||||
rocketDirectionUp: true,
|
||||
overlappingRailroads: [],
|
||||
ghostRailPaths: [],
|
||||
} as UIState;
|
||||
inputHandler = new InputHandler(uiState, mockCanvas, eventBus);
|
||||
inputHandler.initialize();
|
||||
|
||||
window.dispatchEvent(
|
||||
new KeyboardEvent("keyup", { code: "Numpad1", key: "1" }),
|
||||
);
|
||||
|
||||
expect(inputHandler["uiState"].ghostStructure).toBe(UnitType.Factory);
|
||||
});
|
||||
|
||||
test("digit alias used when no exact match: Numpad1 sets City when only buildCity=Digit1", () => {
|
||||
localStorage.setItem(
|
||||
"settings.keybinds",
|
||||
JSON.stringify({ buildCity: "Digit1" }),
|
||||
);
|
||||
inputHandler.destroy();
|
||||
const uiState: UIState = {
|
||||
attackRatio: 20,
|
||||
ghostStructure: null,
|
||||
rocketDirectionUp: true,
|
||||
overlappingRailroads: [],
|
||||
ghostRailPaths: [],
|
||||
} as UIState;
|
||||
inputHandler = new InputHandler(uiState, mockCanvas, eventBus);
|
||||
inputHandler.initialize();
|
||||
|
||||
window.dispatchEvent(
|
||||
new KeyboardEvent("keyup", { code: "Numpad1", key: "1" }),
|
||||
);
|
||||
|
||||
expect(inputHandler["uiState"].ghostStructure).toBe(UnitType.City);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { shouldPreserveGhostAfterBuild } from "../../../../src/client/graphics/layers/StructureIconsLayer";
|
||||
import { UnitType } from "../../../../src/core/game/Game";
|
||||
|
||||
/**
|
||||
* Tests for StructureIconsLayer edge cases mentioned in comments:
|
||||
* - Locked nuke / AtomBomb / HydrogenBomb: when confirming placement (Enter or key),
|
||||
* the ghost is preserved so the user can place multiple nukes or keep the nuke
|
||||
* selected. Other structure types clear the ghost after placement.
|
||||
*/
|
||||
describe("StructureIconsLayer ghost preservation (locked nuke / Enter confirm)", () => {
|
||||
describe("shouldPreserveGhostAfterBuild", () => {
|
||||
test("returns true for AtomBomb so ghost is not cleared after placement", () => {
|
||||
expect(shouldPreserveGhostAfterBuild(UnitType.AtomBomb)).toBe(true);
|
||||
});
|
||||
|
||||
test("returns true for HydrogenBomb so ghost is not cleared after placement", () => {
|
||||
expect(shouldPreserveGhostAfterBuild(UnitType.HydrogenBomb)).toBe(true);
|
||||
});
|
||||
|
||||
test("returns false for City so ghost is cleared after placement", () => {
|
||||
expect(shouldPreserveGhostAfterBuild(UnitType.City)).toBe(false);
|
||||
});
|
||||
|
||||
test("returns false for Factory so ghost is cleared after placement", () => {
|
||||
expect(shouldPreserveGhostAfterBuild(UnitType.Factory)).toBe(false);
|
||||
});
|
||||
|
||||
test("returns false for other buildable types (Port, DefensePost, MissileSilo, SAMLauncher, Warship, MIRV)", () => {
|
||||
expect(shouldPreserveGhostAfterBuild(UnitType.Port)).toBe(false);
|
||||
expect(shouldPreserveGhostAfterBuild(UnitType.DefensePost)).toBe(false);
|
||||
expect(shouldPreserveGhostAfterBuild(UnitType.MissileSilo)).toBe(false);
|
||||
expect(shouldPreserveGhostAfterBuild(UnitType.SAMLauncher)).toBe(false);
|
||||
expect(shouldPreserveGhostAfterBuild(UnitType.Warship)).toBe(false);
|
||||
expect(shouldPreserveGhostAfterBuild(UnitType.MIRV)).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user