mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-30 23:53:39 +00:00
6ca81211ea
## Description: The red alert frame for betrayals was added in https://github.com/openfrontio/OpenFrontIO/pull/1195. It also flashes red for incoming land attacks since https://github.com/openfrontio/OpenFrontIO/pull/2358. The same color for betrayals and attacks leads to confusion. And possibly red alert fatigue. But when players find themselves fatigued and want to shut it off for awhile, they can't because the setting doesn't exist in-game. Also, the setting description on the homepage settings didn't yet reflect that the alert frame flashes for attacks too. This PR fixes this by: - making the color for land attacks orange. This is well discernable from red for various colorblindness types, while still looking alarming. - adding the setting to in-game SettingsModal - adding land attack to setting description Reference to comments on it on Dev Discord: https://discord.com/channels/1359946986937258015/1381347989464809664/1441232666065240064 https://discord.com/channels/1359946986937258015/1360078040222142564/1434574256704061523 Orange alert frame on being attacked over land: https://github.com/user-attachments/assets/e0772d62-5b25-4213-a393-dd5af13e8bc9 Settings description change and addition to in-game toggles: <img width="560" height="160" alt="Added to description what was added in PR 2358" src="https://github.com/user-attachments/assets/bc6e2206-b7ac-498d-9009-d2b6e302d3cf" /> <img width="665" height="425" alt="In SettingsModal and with attacks added to description" src="https://github.com/user-attachments/assets/d489830c-e359-4a5f-8eb4-3caa7d0c21b2" /> ## 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: tryout33
258 lines
7.2 KiB
TypeScript
258 lines
7.2 KiB
TypeScript
import { LitElement, css, html } from "lit";
|
|
import { customElement, state } from "lit/decorators.js";
|
|
import {
|
|
BrokeAllianceUpdate,
|
|
GameUpdateType,
|
|
} from "../../../core/game/GameUpdates";
|
|
import { GameView } from "../../../core/game/GameView";
|
|
import { UserSettings } from "../../../core/game/UserSettings";
|
|
import { Layer } from "./Layer";
|
|
|
|
// Parameters for the alert animation
|
|
const ALERT_SPEED = 1.6;
|
|
const ALERT_COUNT = 2;
|
|
const RETALIATION_WINDOW_TICKS = 15 * 10; // 15 seconds
|
|
const ALERT_COOLDOWN_TICKS = 15 * 10; // 15 seconds
|
|
|
|
@customElement("alert-frame")
|
|
export class AlertFrame extends LitElement implements Layer {
|
|
public game: GameView;
|
|
private userSettings: UserSettings = new UserSettings();
|
|
|
|
@state()
|
|
private isActive = false;
|
|
@state()
|
|
private alertType: "betrayal" | "land-attack" = "betrayal";
|
|
|
|
private animationTimeout: number | null = null;
|
|
private seenAttackIds: Set<string> = new Set();
|
|
private lastAlertTick: number = -1;
|
|
// Map of player ID -> tick when we last attacked them
|
|
private outgoingAttackTicks: Map<number, number> = new Map();
|
|
|
|
static styles = css`
|
|
.alert-border {
|
|
position: fixed;
|
|
top: 0;
|
|
left: 0;
|
|
width: 100%;
|
|
height: 100%;
|
|
pointer-events: none;
|
|
border: 17px solid;
|
|
box-sizing: border-box;
|
|
z-index: 40;
|
|
opacity: 0;
|
|
}
|
|
|
|
.alert-border.betrayal {
|
|
border-color: #ee0000;
|
|
}
|
|
|
|
.alert-border.land-attack {
|
|
border-color: #ffa500;
|
|
}
|
|
|
|
.alert-border.animate {
|
|
animation: alertBlink ${ALERT_SPEED}s ease-in-out ${ALERT_COUNT};
|
|
}
|
|
|
|
@keyframes alertBlink {
|
|
0% {
|
|
opacity: 0;
|
|
}
|
|
50% {
|
|
opacity: 1;
|
|
}
|
|
100% {
|
|
opacity: 0;
|
|
}
|
|
}
|
|
`;
|
|
|
|
constructor() {
|
|
super();
|
|
if (!document.querySelector("style[data-alert-frame]")) {
|
|
const styleEl = document.createElement("style");
|
|
styleEl.setAttribute("data-alert-frame", "");
|
|
styleEl.textContent = AlertFrame.styles.cssText;
|
|
document.head.appendChild(styleEl);
|
|
}
|
|
}
|
|
|
|
createRenderRoot() {
|
|
return this;
|
|
}
|
|
|
|
init() {
|
|
// Listen for BrokeAllianceUpdate events directly from game updates
|
|
}
|
|
|
|
tick() {
|
|
if (!this.game) {
|
|
return; // Game not initialized yet
|
|
}
|
|
|
|
const myPlayer = this.game.myPlayer();
|
|
|
|
// Clear tracked attacks if player dies or doesn't exist
|
|
if (!myPlayer || !myPlayer.isAlive()) {
|
|
this.seenAttackIds.clear();
|
|
this.outgoingAttackTicks.clear();
|
|
this.lastAlertTick = -1;
|
|
return;
|
|
}
|
|
|
|
// Track outgoing attacks to detect retaliation
|
|
this.trackOutgoingAttacks();
|
|
|
|
// Check for BrokeAllianceUpdate events
|
|
this.game
|
|
.updatesSinceLastTick()
|
|
?.[GameUpdateType.BrokeAlliance]?.forEach((update) => {
|
|
this.onBrokeAllianceUpdate(update as BrokeAllianceUpdate);
|
|
});
|
|
|
|
// Check for new incoming attacks
|
|
this.checkForNewAttacks();
|
|
}
|
|
|
|
// The alert frame is not affected by the camera transform
|
|
shouldTransform(): boolean {
|
|
return false;
|
|
}
|
|
|
|
private onBrokeAllianceUpdate(update: BrokeAllianceUpdate) {
|
|
const myPlayer = this.game.myPlayer();
|
|
if (!myPlayer) return;
|
|
|
|
const betrayed = this.game.playerBySmallID(update.betrayedID);
|
|
|
|
// Only trigger alert if the current player is the betrayed one
|
|
if (betrayed === myPlayer) {
|
|
this.alertType = "betrayal";
|
|
this.activateAlert();
|
|
}
|
|
}
|
|
|
|
private activateAlert() {
|
|
if (this.userSettings.alertFrame()) {
|
|
this.isActive = true;
|
|
this.lastAlertTick = this.game.ticks();
|
|
this.requestUpdate();
|
|
}
|
|
}
|
|
|
|
private trackOutgoingAttacks() {
|
|
const myPlayer = this.game.myPlayer();
|
|
if (!myPlayer || !myPlayer.isAlive()) {
|
|
return;
|
|
}
|
|
|
|
const currentTick = this.game.ticks();
|
|
const outgoingAttacks = myPlayer.outgoingAttacks();
|
|
|
|
// Track when we attack other players (not terra nullius)
|
|
for (const attack of outgoingAttacks) {
|
|
// Only track attacks on players (targetID !== 0 means it's a player, not unclaimed land)
|
|
if (attack.targetID !== 0 && !attack.retreating) {
|
|
const existingTick = this.outgoingAttackTicks.get(attack.targetID);
|
|
|
|
// Only update timestamp if:
|
|
// 1. This is a new attack (not in map yet), OR
|
|
// 2. The existing entry has expired (older than retaliation window)
|
|
if (
|
|
existingTick === undefined ||
|
|
currentTick - existingTick >= RETALIATION_WINDOW_TICKS
|
|
) {
|
|
this.outgoingAttackTicks.set(attack.targetID, currentTick);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Clean up old entries (older than retaliation window)
|
|
for (const [playerID, tick] of this.outgoingAttackTicks.entries()) {
|
|
if (currentTick - tick > RETALIATION_WINDOW_TICKS) {
|
|
this.outgoingAttackTicks.delete(playerID);
|
|
}
|
|
}
|
|
}
|
|
|
|
private checkForNewAttacks() {
|
|
const myPlayer = this.game.myPlayer();
|
|
if (!myPlayer || !myPlayer.isAlive()) {
|
|
return;
|
|
}
|
|
|
|
const incomingAttacks = myPlayer.incomingAttacks();
|
|
const currentTick = this.game.ticks();
|
|
|
|
// Check if we're in cooldown (within 10 seconds of last alert)
|
|
const inCooldown =
|
|
this.lastAlertTick !== -1 &&
|
|
currentTick - this.lastAlertTick < ALERT_COOLDOWN_TICKS;
|
|
|
|
// Find new attacks that we haven't seen yet
|
|
const playerTroops = myPlayer.troops();
|
|
const minAttackTroopsThreshold = playerTroops / 5; // 1/5 of current troops
|
|
|
|
for (const attack of incomingAttacks) {
|
|
// Only alert for non-retreating attacks
|
|
if (!attack.retreating && !this.seenAttackIds.has(attack.id)) {
|
|
// Check if this is a retaliation (we attacked them recently)
|
|
const ourAttackTick = this.outgoingAttackTicks.get(attack.attackerID);
|
|
const isRetaliation =
|
|
ourAttackTick !== undefined &&
|
|
currentTick - ourAttackTick < RETALIATION_WINDOW_TICKS;
|
|
|
|
// Check if attack is too small (less than 1/5 of our troops)
|
|
const isSmallAttack = attack.troops < minAttackTroopsThreshold;
|
|
|
|
// Don't alert if:
|
|
// 1. We're in cooldown from a recent alert
|
|
// 2. This is a retaliation (we attacked them within 15 seconds)
|
|
// 3. The attack is too small (less than 1/5 of our troops)
|
|
if (!inCooldown && !isRetaliation && !isSmallAttack) {
|
|
this.seenAttackIds.add(attack.id);
|
|
this.alertType = "land-attack";
|
|
this.activateAlert();
|
|
} else {
|
|
// Still mark as seen so we don't alert later
|
|
this.seenAttackIds.add(attack.id);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Clean up IDs for attacks that are no longer active (retreating or completed)
|
|
const activeAttackIds = new Set(incomingAttacks.map((a) => a.id));
|
|
|
|
// Remove IDs for attacks that are no longer in the incoming attacks list
|
|
for (const attackId of this.seenAttackIds) {
|
|
if (!activeAttackIds.has(attackId)) {
|
|
this.seenAttackIds.delete(attackId);
|
|
}
|
|
}
|
|
}
|
|
|
|
public dismissAlert() {
|
|
this.isActive = false;
|
|
if (this.animationTimeout) {
|
|
clearTimeout(this.animationTimeout);
|
|
this.animationTimeout = null;
|
|
}
|
|
this.requestUpdate();
|
|
}
|
|
|
|
render() {
|
|
if (!this.isActive) {
|
|
return html``;
|
|
}
|
|
|
|
return html`
|
|
<div
|
|
class=${`alert-border animate ${this.alertType}`}
|
|
@animationend=${() => this.dismissAlert()}
|
|
></div>
|
|
`;
|
|
}
|
|
}
|