Skip to content

Commit af0b8a8

Browse files
Configurable immunity timer (openfrontio#2763)
## Description: Resolve discussions about stalled PR openfrontio#2460 <img width="724" height="348" alt="image" src="https://github.com/user-attachments/assets/c2c9fa79-cace-431a-9ca4-b3656612fa9d" /> Changes: - Added a `Player::canAttackPlayer(other)` function to determine whether a player can be attacked. - This function is now used in most places where a fight can occur: - AttackExecution (land attacks) - Naval invasion - Warship fight - Nukes can't be thrown during the truce - Immunity only affect human players. Nations and bot will fight as usual, and can be fought against. - The immunity timer uses minutes in the modal window. UI: - The immunity phase is displayed with a timer bar at the top. This is from the original PR, to be discussed if it's not deemed visible enough: <img width="632" height="215" alt="image" src="https://github.com/user-attachments/assets/f5ab9aa0-bd4f-4503-b8d6-b40b121fba65" /> ## Please complete the following: - [x] I have added screenshots for all UI updates - [x] I process any text displayed to the user through translateText() and I've added it to the en.json file - [x] I have added relevant tests to the test directory - [x] I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced ## Please put your Discord username so you can be contacted if a bug or regression is found: IngloriousTom --------- Co-authored-by: newyearnewphil <git@nynp.dev>
1 parent ab5b044 commit af0b8a8

19 files changed

Lines changed: 385 additions & 33 deletions

index.html

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -451,6 +451,7 @@
451451
<settings-modal></settings-modal>
452452
<player-panel></player-panel>
453453
<spawn-timer></spawn-timer>
454+
<immunity-timer></immunity-timer>
454455
<help-modal></help-modal>
455456
<game-info-modal></game-info-modal>
456457
<dark-mode-button></dark-mode-button>

resources/lang/debug.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,7 @@
145145
"options_title": "host_modal.options_title",
146146
"bots": "host_modal.bots",
147147
"bots_disabled": "host_modal.bots_disabled",
148+
"player_immunity_duration": "host_modal.player_immunity_duration",
148149
"disable_nations": "host_modal.disable_nations",
149150
"instant_build": "host_modal.instant_build",
150151
"random_spawn": "host_modal.random_spawn",

resources/lang/en.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -307,6 +307,7 @@
307307
"options_title": "Options",
308308
"bots": "Bots: ",
309309
"bots_disabled": "Disabled",
310+
"player_immunity_duration": "PVP immunity duration (minutes)",
310311
"nations": "Nations: ",
311312
"disable_nations": "Disable Nations",
312313
"max_timer": "Game length (minutes)",

src/client/HostLobbyModal.ts

Lines changed: 67 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,8 @@ export class HostLobbyModal extends LitElement {
4545
@state() private gameMode: GameMode = GameMode.FFA;
4646
@state() private teamCount: TeamCountConfig = 2;
4747
@state() private bots: number = 400;
48+
@state() private spawnImmunity: boolean = false;
49+
@state() private spawnImmunityDurationMinutes: number | undefined = undefined;
4850
@state() private infiniteGold: boolean = false;
4951
@state() private donateGold: boolean = false;
5052
@state() private infiniteTroops: boolean = false;
@@ -514,7 +516,7 @@ export class HostLobbyModal extends LitElement {
514516
id="end-timer-value"
515517
min="0"
516518
max="120"
517-
.value=${String(this.maxTimerValue ?? "")}
519+
.value=${String(this.maxTimerValue ?? 0)}
518520
style="width: 60px; color: black; text-align: right; border-radius: 8px;"
519521
@input=${this.handleMaxTimerValueChanges}
520522
@keydown=${this.handleMaxTimerValueKeyDown}
@@ -524,6 +526,47 @@ export class HostLobbyModal extends LitElement {
524526
${translateText("host_modal.max_timer")}
525527
</div>
526528
</label>
529+
530+
<label
531+
for="spawn-immunity"
532+
class="option-card ${this.spawnImmunity ? "selected" : ""}"
533+
>
534+
<div class="checkbox-icon"></div>
535+
<input
536+
type="checkbox"
537+
id="spawn-immunity"
538+
@change=${(e: Event) => {
539+
const checked = (e.target as HTMLInputElement).checked;
540+
if (!checked) {
541+
this.spawnImmunityDurationMinutes = undefined;
542+
}
543+
this.spawnImmunity = checked;
544+
this.putGameConfig();
545+
}}
546+
.checked=${this.spawnImmunity}
547+
/>
548+
${
549+
this.spawnImmunity === false
550+
? ""
551+
: html`<input
552+
type="number"
553+
id="spawn-immunity-duration"
554+
min="0"
555+
max="120"
556+
step="1"
557+
.value=${String(
558+
this.spawnImmunityDurationMinutes ?? 0,
559+
)}
560+
style="width: 60px; color: black; text-align: right; border-radius: 8px;"
561+
@input=${this.handleSpawnImmunityDurationInput}
562+
@keydown=${this.handleSpawnImmunityDurationKeyDown}
563+
/>`
564+
}
565+
<div class="option-card-title">
566+
<span>${translateText("host_modal.player_immunity_duration")}</span>
567+
</div>
568+
</label>
569+
527570
<hr style="width: 100%; border-top: 1px solid #444; margin: 16px 0;" />
528571
529572
<!-- Individual disables for structures/weapons -->
@@ -691,6 +734,23 @@ export class HostLobbyModal extends LitElement {
691734
this.putGameConfig();
692735
}
693736

737+
private handleSpawnImmunityDurationKeyDown(e: KeyboardEvent) {
738+
if (["-", "+", "e", "E"].includes(e.key)) {
739+
e.preventDefault();
740+
}
741+
}
742+
743+
private handleSpawnImmunityDurationInput(e: Event) {
744+
const input = e.target as HTMLInputElement;
745+
input.value = input.value.replace(/[eE+-]/g, "");
746+
const value = parseInt(input.value, 10);
747+
if (Number.isNaN(value) || value < 0 || value > 120) {
748+
return;
749+
}
750+
this.spawnImmunityDurationMinutes = value;
751+
this.putGameConfig();
752+
}
753+
694754
private handleRandomSpawnChange(e: Event) {
695755
this.randomSpawn = Boolean((e.target as HTMLInputElement).checked);
696756
this.putGameConfig();
@@ -757,6 +817,9 @@ export class HostLobbyModal extends LitElement {
757817
}
758818

759819
private async putGameConfig() {
820+
const spawnImmunityTicks = this.spawnImmunityDurationMinutes
821+
? this.spawnImmunityDurationMinutes * 60 * 10
822+
: 0;
760823
this.dispatchEvent(
761824
new CustomEvent("update-game-config", {
762825
detail: {
@@ -775,6 +838,9 @@ export class HostLobbyModal extends LitElement {
775838
randomSpawn: this.randomSpawn,
776839
gameMode: this.gameMode,
777840
disabledUnits: this.disabledUnits,
841+
spawnImmunityDuration: this.spawnImmunity
842+
? spawnImmunityTicks
843+
: undefined,
778844
playerTeams: this.teamCount,
779845
...(this.gameMode === GameMode.Team &&
780846
this.teamCount === HumansVsNations

src/client/graphics/GameRenderer.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import { FxLayer } from "./layers/FxLayer";
1818
import { GameLeftSidebar } from "./layers/GameLeftSidebar";
1919
import { GameRightSidebar } from "./layers/GameRightSidebar";
2020
import { HeadsUpMessage } from "./layers/HeadsUpMessage";
21+
import { ImmunityTimer } from "./layers/ImmunityTimer";
2122
import { Layer } from "./layers/Layer";
2223
import { Leaderboard } from "./layers/Leaderboard";
2324
import { MainRadialMenu } from "./layers/MainRadialMenu";
@@ -234,6 +235,14 @@ export function createRenderer(
234235
spawnTimer.game = game;
235236
spawnTimer.transformHandler = transformHandler;
236237

238+
const immunityTimer = document.querySelector(
239+
"immunity-timer",
240+
) as ImmunityTimer;
241+
if (!(immunityTimer instanceof ImmunityTimer)) {
242+
console.error("immunity timer not found");
243+
}
244+
immunityTimer.game = game;
245+
237246
// When updating these layers please be mindful of the order.
238247
// Try to group layers by the return value of shouldTransform.
239248
// Not grouping the layers may cause excessive calls to context.save() and context.restore().
@@ -262,6 +271,7 @@ export function createRenderer(
262271
playerPanel,
263272
),
264273
spawnTimer,
274+
immunityTimer,
265275
leaderboard,
266276
gameLeftSidebar,
267277
unitDisplay,
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
import { LitElement, html } from "lit";
2+
import { customElement } from "lit/decorators.js";
3+
import { GameMode } from "../../../core/game/Game";
4+
import { GameView } from "../../../core/game/GameView";
5+
import { Layer } from "./Layer";
6+
7+
@customElement("immunity-timer")
8+
export class ImmunityTimer extends LitElement implements Layer {
9+
public game: GameView;
10+
11+
private isVisible = false;
12+
private isActive = false;
13+
private progressRatio = 0;
14+
15+
createRenderRoot() {
16+
this.style.position = "fixed";
17+
this.style.top = "0";
18+
this.style.left = "0";
19+
this.style.width = "100%";
20+
this.style.height = "7px";
21+
this.style.zIndex = "1000";
22+
this.style.pointerEvents = "none";
23+
return this;
24+
}
25+
26+
init() {
27+
this.isVisible = true;
28+
}
29+
30+
tick() {
31+
if (!this.game || !this.isVisible) {
32+
return;
33+
}
34+
35+
const showTeamOwnershipBar =
36+
this.game.config().gameConfig().gameMode === GameMode.Team &&
37+
!this.game.inSpawnPhase();
38+
39+
this.style.top = showTeamOwnershipBar ? "7px" : "0px";
40+
41+
const immunityDuration = this.game.config().spawnImmunityDuration();
42+
const spawnPhaseTurns = this.game.config().numSpawnPhaseTurns();
43+
44+
if (immunityDuration <= 5 * 10 || this.game.inSpawnPhase()) {
45+
this.setInactive();
46+
return;
47+
}
48+
49+
const immunityEnd = spawnPhaseTurns + immunityDuration;
50+
const ticks = this.game.ticks();
51+
52+
if (ticks >= immunityEnd || ticks < spawnPhaseTurns) {
53+
this.setInactive();
54+
return;
55+
}
56+
57+
const elapsedTicks = Math.max(0, ticks - spawnPhaseTurns);
58+
this.progressRatio = Math.min(
59+
1,
60+
Math.max(0, elapsedTicks / immunityDuration),
61+
);
62+
this.isActive = true;
63+
this.requestUpdate();
64+
}
65+
66+
private setInactive() {
67+
if (this.isActive) {
68+
this.isActive = false;
69+
this.requestUpdate();
70+
}
71+
}
72+
73+
shouldTransform(): boolean {
74+
return false;
75+
}
76+
77+
render() {
78+
if (!this.isVisible || !this.isActive) {
79+
return html``;
80+
}
81+
82+
const widthPercent = this.progressRatio * 100;
83+
84+
return html`
85+
<div class="w-full h-full flex z-[999]">
86+
<div
87+
class="h-full transition-all duration-100 ease-in-out"
88+
style="width: ${widthPercent}%; background-color: rgba(255, 165, 0, 0.9);"
89+
></div>
90+
</div>
91+
`;
92+
}
93+
}

src/core/Schemas.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,6 +179,7 @@ export const GameConfigSchema = z.object({
179179
randomSpawn: z.boolean(),
180180
maxPlayers: z.number().optional(),
181181
maxTimerValue: z.number().int().min(1).max(120).optional(),
182+
spawnImmunityDuration: z.number().int().min(0).optional(), // In ticks
182183
disabledUnits: z.enum(UnitType).array().optional(),
183184
playerTeams: TeamCountConfigSchema.optional(),
184185
});

src/core/configuration/DefaultConfig.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -246,7 +246,7 @@ export class DefaultConfig implements Config {
246246
return 30 * 10; // 30 seconds
247247
}
248248
spawnImmunityDuration(): Tick {
249-
return 5 * 10;
249+
return this._gameConfig.spawnImmunityDuration ?? 5 * 10; // default to 5 seconds
250250
}
251251

252252
gameConfig(): GameConfig {

src/core/execution/AttackExecution.ts

Lines changed: 3 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -92,16 +92,9 @@ export class AttackExecution implements Execution {
9292
}
9393
}
9494

95-
if (this.target.isPlayer()) {
96-
if (
97-
this.mg.config().numSpawnPhaseTurns() +
98-
this.mg.config().spawnImmunityDuration() >
99-
this.mg.ticks()
100-
) {
101-
console.warn("cannot attack player during immunity phase");
102-
this.active = false;
103-
return;
104-
}
95+
if (this.target.isPlayer() && !this._owner.canAttackPlayer(this.target)) {
96+
this.active = false;
97+
return;
10598
}
10699

107100
this.startTroops ??= this.mg

src/core/execution/TransportShipExecution.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,10 @@ export class TransportShipExecution implements Execution {
9393
} else {
9494
this.target = mg.player(this.targetID);
9595
}
96+
if (this.target.isPlayer() && !this.attacker.canAttackPlayer(this.target)) {
97+
this.active = false;
98+
return;
99+
}
96100

97101
this.startTroops ??= this.mg
98102
.config()

0 commit comments

Comments
 (0)