diff --git a/resources/lang/en.json b/resources/lang/en.json
index aac1034c2..9e3d78251 100644
--- a/resources/lang/en.json
+++ b/resources/lang/en.json
@@ -580,7 +580,8 @@
"alliance_renewed": "Your alliance with {name} has been renewed",
"wants_to_renew_alliance": "{name} wants to renew your alliance",
"ignore": "Ignore",
- "unit_voluntarily_deleted": "Unit voluntarily deleted"
+ "unit_voluntarily_deleted": "Unit voluntarily deleted",
+ "betrayal_debuff_ends": "{time} seconds left until betrayal debuff ends"
},
"unit_info_modal": {
"structure_info": "Structure Info",
diff --git a/src/client/graphics/layers/EventsDisplay.ts b/src/client/graphics/layers/EventsDisplay.ts
index 1141643ef..803f81e3f 100644
--- a/src/client/graphics/layers/EventsDisplay.ts
+++ b/src/client/graphics/layers/EventsDisplay.ts
@@ -877,6 +877,30 @@ export class EventsDisplay extends LitElement implements Layer {
`;
}
+ private renderBetrayalDebuffTimer() {
+ const myPlayer = this.game.myPlayer();
+ if (!myPlayer || !myPlayer.isTraitor()) {
+ return html``;
+ }
+
+ const remainingTicks = myPlayer.getTraitorRemainingTicks();
+ const remainingSeconds = Math.ceil(remainingTicks / 10);
+
+ if (remainingSeconds <= 0) {
+ return html``;
+ }
+
+ return html`
+ ${this.renderButton({
+ content: html`${translateText("events_display.betrayal_debuff_ends", {
+ time: remainingSeconds,
+ })}`,
+ className: "text-left text-yellow-400",
+ translate: false,
+ })}
+ `;
+ }
+
render() {
if (!this.active || !this._isVisible) {
return html``;
@@ -1081,6 +1105,24 @@ export class EventsDisplay extends LitElement implements Layer {
`
: ""}
+
+ ${(() => {
+ const myPlayer = this.game.myPlayer();
+ return (
+ myPlayer &&
+ myPlayer.isTraitor() &&
+ myPlayer.getTraitorRemainingTicks() > 0
+ );
+ })()
+ ? html`
+
+ |
+ ${this.renderBetrayalDebuffTimer()}
+ |
+
+ `
+ : ""}
+
${this.outgoingAttacks.length > 0
? html`
@@ -1119,7 +1161,15 @@ export class EventsDisplay extends LitElement implements Layer {
this.incomingAttacks.length === 0 &&
this.outgoingAttacks.length === 0 &&
this.outgoingLandAttacks.length === 0 &&
- this.outgoingBoats.length === 0
+ this.outgoingBoats.length === 0 &&
+ !(() => {
+ const myPlayer = this.game.myPlayer();
+ return (
+ myPlayer &&
+ myPlayer.isTraitor() &&
+ myPlayer.getTraitorRemainingTicks() > 0
+ );
+ })()
? html`
| this.onAlternateViewChange(e));
}
@@ -410,16 +425,44 @@ export class NameLayer implements Layer {
}
// Traitor icon
- const existingTraitor = iconsDiv.querySelector('[data-icon="traitor"]');
+ let existingTraitor = iconsDiv.querySelector('[data-icon="traitor"]');
if (render.player.isTraitor()) {
+ const remainingTicks = render.player.getTraitorRemainingTicks();
+ // Use precise seconds (not rounded) for smoother transitions, rounded to 0.5s intervals
+ const remainingSeconds = Math.round((remainingTicks / 10) * 2) / 2;
+
if (!existingTraitor) {
- iconsDiv.appendChild(
- this.createIconElement(
- this.traitorIconImage.src,
- iconSize,
- "traitor",
- ),
+ existingTraitor = this.createIconElement(
+ this.traitorIconImage.src,
+ iconSize,
+ "traitor",
);
+ iconsDiv.appendChild(existingTraitor);
+ }
+
+ // Apply flashing animation - smooth speed increase starting at 15s
+ if (existingTraitor instanceof HTMLImageElement) {
+ if (remainingSeconds <= 15) {
+ // Smooth transition: starts at 1s at 15 seconds, decreases to 0.2s at 0 seconds
+ // Using cubic ease-out for slower, more gradual acceleration
+ const clampedSeconds = Math.max(0, Math.min(15, remainingSeconds));
+ const normalizedTime = clampedSeconds / 15; // 0 to 1 (1 = 15s remaining, 0 = 0s remaining)
+
+ // Cubic ease-out: slower acceleration, smoother transition
+ const easedProgress = 1 - Math.pow(1 - normalizedTime, 3);
+
+ const maxDuration = 1.0; // Slow flash at 15 seconds
+ const minDuration = 0.2; // Fast flash at 0 seconds
+ const duration =
+ minDuration + (maxDuration - minDuration) * easedProgress;
+ const animationDuration = `${duration.toFixed(2)}s`;
+
+ existingTraitor.style.animation = `traitorFlash ${animationDuration} infinite`;
+ existingTraitor.style.animationTimingFunction = "ease-in-out";
+ } else {
+ // Don't flash if more than 15 seconds remaining
+ existingTraitor.style.animation = "none";
+ }
}
} else if (existingTraitor) {
existingTraitor.remove();
|