A timer and icon flashing for betryal debuff (#2430)

Resolves #2096 

## Description:

(TO CLEARIFY THEIRS NO GRACE PERIOD ADDED, AS THAT ISSUE THAT WOULD OF
NEEDED IT WAS FIXED BEFORE ON ITS OWN)

Shows the amount left in the UI for the player who trigged it
<img width="374" height="80" alt="image"
src="https://github.com/user-attachments/assets/f269c015-5a78-4e85-a9c0-cdf039d93d2a"
/>

also the betryal icon, after 15 seconds starts a slow flash, then after
10 seconds it speeds up, and then at 5 seconds it quickly flashs.

this was a nice way to show the time left without adding any new ui
componets.


video link 36 seconds (https://streamable.com/cwzxch)

## 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:

notifxy (1379678982676676639)
This commit is contained in:
Rj Manhas
2025-11-11 14:39:01 -07:00
committed by GitHub
parent 34251c0673
commit a24710a9f5
3 changed files with 103 additions and 9 deletions
+2 -1
View File
@@ -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",
+51 -1
View File
@@ -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 {
`
: ""}
<!--- Betrayal debuff timer row -->
${(() => {
const myPlayer = this.game.myPlayer();
return (
myPlayer &&
myPlayer.isTraitor() &&
myPlayer.getTraitorRemainingTicks() > 0
);
})()
? html`
<tr class="lg:px-2 lg:py-1 p-1">
<td class="lg:px-2 lg:py-1 p-1 text-left">
${this.renderBetrayalDebuffTimer()}
</td>
</tr>
`
: ""}
<!--- Outgoing attacks row -->
${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`
<tr>
<td
+50 -7
View File
@@ -118,6 +118,21 @@ export class NameLayer implements Layer {
this.container.style.zIndex = "2";
document.body.appendChild(this.container);
// Add CSS keyframes for traitor icon flashing animation
// Append to container instead of document.head to keep styles scoped to this component
const style = document.createElement("style");
style.textContent = `
@keyframes traitorFlash {
0%, 100% {
opacity: 1;
}
50% {
opacity: 0.3;
}
}
`;
this.container.appendChild(style);
this.eventBus.on(AlternateViewEvent, (e) => 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();