feat(web): translate combat log, action buttons, and victory/defeat screen to English

Translates formatEvent() and monsterIntentLabel() combat-log templates,
default participant-name fallbacks (Du/Der Gegner -> You/The enemy), and
every static string in the combat page template: round header, action
buttons, outcome headings, rewards panel, post-combat buttons, log panel,
and loading/error/aria-label text.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ACkMEDYiwtcfchqKkiUJNX
This commit is contained in:
Bastian Wagner
2026-08-21 21:40:04 +02:00
parent 5586e6c672
commit 0923fc0a23
3 changed files with 73 additions and 73 deletions

View File

@@ -1,4 +1,4 @@
<section class="combat" aria-label="Kampf">
<section class="combat" aria-label="Combat">
@if (combat(); as combat) {
<div class="combat__stage" [class.combat__stage--shaken]="stageShake()">
<header class="combat__status">
@@ -9,7 +9,7 @@
<div
class="bar bar--player"
role="progressbar"
[attr.aria-label]="'Lebenspunkte ' + combat.player.name"
[attr.aria-label]="'Health points ' + combat.player.name"
[attr.aria-valuenow]="combat.player.currentHp"
[attr.aria-valuemin]="0"
[attr.aria-valuemax]="combat.player.maxHp"
@@ -20,7 +20,7 @@
</div>
</div>
<p class="combat__round" data-combat-round>Runde {{ combat.round }}</p>
<p class="combat__round" data-combat-round>Round {{ combat.round }}</p>
<div class="fighter fighter--monster">
<div class="fighter__meter">
@@ -31,7 +31,7 @@
<div
class="bar bar--monster"
role="progressbar"
[attr.aria-label]="'Lebenspunkte ' + combat.monster.name"
[attr.aria-label]="'Health points ' + combat.monster.name"
[attr.aria-valuenow]="combat.monster.currentHp"
[attr.aria-valuemin]="0"
[attr.aria-valuemax]="combat.monster.maxHp"
@@ -80,7 +80,7 @@
(click)="performAction('ATTACK')"
>
<img class="action__icon" src="/images/hud/runtime/AttackIcon-96.png" alt="" />
<span class="action__label">Angriff</span>
<span class="action__label">Attack</span>
<span class="action__key">1</span>
</button>
<button
@@ -91,7 +91,7 @@
(click)="performAction('HEAVY_STRIKE')"
>
<img class="action__icon" src="/images/hud/runtime/AttackIcon-96.png" alt="" />
<span class="action__label">Schwerer Hieb</span>
<span class="action__label">Heavy Strike</span>
<span class="action__key">2</span>
</button>
<button
@@ -102,7 +102,7 @@
(click)="performAction('SHIELD_BASH')"
>
<img class="action__icon" src="/images/hud/runtime/CharacterIcon-128.png" alt="" />
<span class="action__label">Schildstoß</span>
<span class="action__label">Shield Bash</span>
<span class="action__key">3</span>
</button>
<button
@@ -113,7 +113,7 @@
(click)="performAction('DEFEND')"
>
<img class="action__icon" src="/images/hud/runtime/CharacterIcon-128.png" alt="" />
<span class="action__label">Verteidigen</span>
<span class="action__label">Defend</span>
<span class="action__key">4</span>
</button>
<button
@@ -124,7 +124,7 @@
(click)="performAction('POTION')"
>
<img class="action__icon" src="/images/items/small-healing-potion.png" alt="" />
<span class="action__label">Trank {{ combat.player.potionsRemaining }}/{{ combat.player.potionsMax }}</span>
<span class="action__label">Potion {{ combat.player.potionsRemaining }}/{{ combat.player.potionsMax }}</span>
<span class="action__key">5</span>
</button>
}
@@ -132,24 +132,24 @@
@if (combat.status === 'WON') {
<div class="outcome outcome--won" data-combat-result="WON">
<h2 class="outcome__title">Sieg</h2>
<p>{{ combat.monster.name }} wurde besiegt.</p>
<h2 class="outcome__title">Victory</h2>
<p>{{ combat.monster.name }} has been defeated.</p>
@if (combat.rewards; as rewards) {
<section class="rewards" data-combat-rewards aria-label="Belohnungen">
<h3 class="rewards__title">Belohnungen</h3>
<section class="rewards" data-combat-rewards aria-label="Rewards">
<h3 class="rewards__title">Rewards</h3>
@if (rewards.silver) {
<dl class="rewards__currencies">
<div class="rewards__currency" data-reward-silver>
<dt>Silber</dt>
<dt>Silver</dt>
<dd>+{{ rewards.silver }}</dd>
</div>
</dl>
}
@if (rewards.items.length) {
<h3 class="rewards__title">Beute</h3>
<h3 class="rewards__title">Loot</h3>
<ul class="rewards__loot">
@for (reward of rewards.items; track reward.characterItemId) {
<li>
@@ -158,14 +158,14 @@
}
</ul>
} @else {
<p class="outcome__hint" data-reward-empty>Keine besondere Beute gefunden.</p>
<p class="outcome__hint" data-reward-empty>No notable loot found.</p>
}
</section>
}
<div class="outcome__buttons">
<button type="button" class="outcome__button" data-combat-to-hunt (click)="goToHunt()">
Weiter jagen
Keep Hunting
</button>
<button
type="button"
@@ -173,7 +173,7 @@
data-combat-to-location
(click)="goToLocation()"
>
Zum Ort
To Location
</button>
<button
type="button"
@@ -181,17 +181,17 @@
data-combat-to-inventory
(click)="goToInventory()"
>
Inventar öffnen
Open Inventory
</button>
</div>
</div>
} @else if (combat.status === 'LOST') {
<div class="outcome outcome--lost" data-combat-result="LOST">
<h2 class="outcome__title">Niederlage</h2>
<p>{{ combat.player.name }} wurde im Kampf besiegt.</p>
<h2 class="outcome__title">Defeat</h2>
<p>{{ combat.player.name }} has been defeated.</p>
<div class="outcome__buttons">
<button type="button" class="outcome__button" data-combat-to-hunt (click)="goToHunt()">
Weiter jagen
Keep Hunting
</button>
<button
type="button"
@@ -199,18 +199,18 @@
data-combat-to-location
(click)="goToLocation()"
>
Zum Ort
To Location
</button>
</div>
</div>
}
</div>
<aside class="combat__log" aria-label="Kampfprotokoll">
<h2 class="combat__log-title">Kampflog</h2>
<aside class="combat__log" aria-label="Combat Log">
<h2 class="combat__log-title">Combat Log</h2>
<div class="combat__log-body">
@for (round of logRounds(); track round.round) {
<p class="combat__log-round">Runde {{ round.round }}</p>
<p class="combat__log-round">Round {{ round.round }}</p>
@for (event of round.events; track event.sequence) {
<p class="combat__log-entry" [class.combat__log-entry--player]="event.source === 'PLAYER'">
{{ formatEvent(event) }}
@@ -220,13 +220,13 @@
</div>
</aside>
} @else if (combatStore.loading()) {
<p class="combat__notice" role="status">Kampf wird geladen</p>
<p class="combat__notice" role="status">Loading combat</p>
}
@if (combatStore.error(); as error) {
<section class="combat__notice combat__notice--error" role="alert">
<p>{{ error }}</p>
<button type="button" data-combat-retry (click)="retry()">Erneut versuchen</button>
<button type="button" data-combat-retry (click)="retry()">Retry</button>
</section>
}
</section>

View File

@@ -28,7 +28,7 @@ const activeCombat: Combat = {
rewards: null,
};
const monsterHitLine = 'Aschenratte trifft Aric Duskwalker für 5 Schaden.';
const monsterHitLine = 'Aschenratte hits Aric Duskwalker for 5 damage.';
function countOccurrences(haystack: string | null, needle: string): number {
return haystack ? haystack.split(needle).length - 1 : 0;
@@ -91,7 +91,7 @@ describe('CombatPageComponent', () => {
expect(combatStore.loadCombat).toHaveBeenCalledWith('combat-1');
});
it('shows the player, monster, HP bars, round, and the Angriff action', async () => {
it('shows the player, monster, HP bars, round, and the Attack action', async () => {
const fixture = await setup(activeCombat);
const element = fixture.nativeElement as HTMLElement;
@@ -99,22 +99,22 @@ describe('CombatPageComponent', () => {
expect(element.textContent).toContain('95 / 100');
expect(element.textContent).toContain('Aschenratte');
expect(element.textContent).toContain('31 / 45');
expect(element.querySelector('[data-combat-round]')?.textContent).toContain('Runde 2');
expect(element.querySelector('[data-combat-round]')?.textContent).toContain('Round 2');
expect(element.querySelector('[data-combat-attack]')).toBeTruthy();
});
it('shows all five combat actions with their German labels', async () => {
it('shows all five combat actions with their English labels', async () => {
const fixture = await setup(activeCombat);
const element = fixture.nativeElement as HTMLElement;
expect(element.querySelector('[data-combat-attack]')?.textContent).toContain('Angriff');
expect(element.querySelector('[data-combat-heavy-strike]')?.textContent).toContain('Schwerer Hieb');
expect(element.querySelector('[data-combat-shield-bash]')?.textContent).toContain('Schildstoß');
expect(element.querySelector('[data-combat-defend]')?.textContent).toContain('Verteidigen');
expect(element.querySelector('[data-combat-potion]')?.textContent).toContain('Trank 2/2');
expect(element.querySelector('[data-combat-attack]')?.textContent).toContain('Attack');
expect(element.querySelector('[data-combat-heavy-strike]')?.textContent).toContain('Heavy Strike');
expect(element.querySelector('[data-combat-shield-bash]')?.textContent).toContain('Shield Bash');
expect(element.querySelector('[data-combat-defend]')?.textContent).toContain('Defend');
expect(element.querySelector('[data-combat-potion]')?.textContent).toContain('Potion 2/2');
});
it('sends HEAVY_STRIKE when Schwerer Hieb is clicked', async () => {
it('sends HEAVY_STRIKE when Heavy Strike is clicked', async () => {
const fixture = await setup(activeCombat);
const element = fixture.nativeElement as HTMLElement;
@@ -123,7 +123,7 @@ describe('CombatPageComponent', () => {
expect(combatStore.performAction).toHaveBeenCalledWith('HEAVY_STRIKE');
});
it('sends SHIELD_BASH when Schildstoß is clicked', async () => {
it('sends SHIELD_BASH when Shield Bash is clicked', async () => {
const fixture = await setup(activeCombat);
const element = fixture.nativeElement as HTMLElement;
@@ -132,7 +132,7 @@ describe('CombatPageComponent', () => {
expect(combatStore.performAction).toHaveBeenCalledWith('SHIELD_BASH');
});
it('sends DEFEND when Verteidigen is clicked', async () => {
it('sends DEFEND when Defend is clicked', async () => {
const fixture = await setup(activeCombat);
const element = fixture.nativeElement as HTMLElement;
@@ -141,7 +141,7 @@ describe('CombatPageComponent', () => {
expect(combatStore.performAction).toHaveBeenCalledWith('DEFEND');
});
it('sends POTION when Trank is clicked', async () => {
it('sends POTION when Potion is clicked', async () => {
const fixture = await setup(activeCombat);
const element = fixture.nativeElement as HTMLElement;
@@ -169,7 +169,7 @@ describe('CombatPageComponent', () => {
const element = fixture.nativeElement as HTMLElement;
expect(element.querySelector('[data-combat-telegraph]')?.textContent).toContain(
'Aschenratte bereitet Schweren Hieb vor.',
'Aschenratte is winding up a Heavy Strike.',
);
});
@@ -192,21 +192,21 @@ describe('CombatPageComponent', () => {
});
const element = fixture.nativeElement as HTMLElement;
expect(element.textContent).toContain('Aric Duskwalker trinkt einen Trank und heilt 35 Lebenspunkte.');
expect(element.textContent).toContain('Aric Duskwalker geht in die Verteidigung.');
expect(element.textContent).toContain('Aschenratte bereitet Schweren Hieb vor.');
expect(element.textContent).toContain('Aric Duskwalker unterbricht den vorbereiteten Angriff von Aschenratte.');
expect(element.textContent).toContain('Aric Duskwalker drinks a potion and heals 35 HP.');
expect(element.textContent).toContain('Aric Duskwalker braces to defend.');
expect(element.textContent).toContain('Aschenratte is winding up a Heavy Strike.');
expect(element.textContent).toContain("Aric Duskwalker interrupts Aschenratte's attack.");
});
it('renders the structured events as readable German combat-log entries', async () => {
it('renders the structured events as readable English combat-log entries', async () => {
const fixture = await setup(activeCombat);
const element = fixture.nativeElement as HTMLElement;
expect(element.textContent).toContain('Aric Duskwalker trifft Aschenratte für 14 Schaden.');
expect(element.textContent).toContain('Aschenratte trifft Aric Duskwalker für 5 Schaden.');
expect(element.textContent).toContain('Aric Duskwalker hits Aschenratte for 14 damage.');
expect(element.textContent).toContain('Aschenratte hits Aric Duskwalker for 5 damage.');
});
it('calls combatStore.performAction("ATTACK") when Angriff is clicked', async () => {
it('calls combatStore.performAction("ATTACK") when Attack is clicked', async () => {
const fixture = await setup(activeCombat);
const element = fixture.nativeElement as HTMLElement;
@@ -303,7 +303,7 @@ describe('CombatPageComponent', () => {
await vi.advanceTimersByTimeAsync(1260);
fixture.detectChanges();
expect(element.querySelector('[data-combat-telegraph]')?.textContent).toContain('bereitet Schweren Hieb vor');
expect(element.querySelector('[data-combat-telegraph]')?.textContent).toContain('is winding up a Heavy Strike');
expect(monster?.classList.contains('sprite--lunge')).toBe(false);
});
@@ -334,7 +334,7 @@ describe('CombatPageComponent', () => {
await vi.advanceTimersByTimeAsync(540);
fixture.detectChanges();
expect(element.textContent).toContain('Aric Duskwalker unterbricht den vorbereiteten Angriff von Aschenratte.');
expect(element.textContent).toContain("Aric Duskwalker interrupts Aschenratte's attack.");
expect(element.querySelector('[data-combat-telegraph]')).toBeNull();
expect(monster?.classList.contains('sprite--lunge')).toBe(false);
});
@@ -365,7 +365,7 @@ describe('CombatPageComponent', () => {
// Before the checkpoint: the pre-heal HP and potion count still show.
expect(element.textContent).toContain('70 / 100');
expect(element.querySelector('[data-combat-potion]')?.textContent).toContain('Trank 2/2');
expect(element.querySelector('[data-combat-potion]')?.textContent).toContain('Potion 2/2');
// First checkpoint: the heal already landed from the player's own action,
// so the HP bar and potion count update here -- well before the monster's
@@ -373,8 +373,8 @@ describe('CombatPageComponent', () => {
await vi.advanceTimersByTimeAsync(540);
fixture.detectChanges();
expect(element.textContent).toContain('85 / 100');
expect(element.querySelector('[data-combat-potion]')?.textContent).toContain('Trank 1/2');
expect(element.textContent).toContain('Aric Duskwalker trinkt einen Trank und heilt 15 Lebenspunkte.');
expect(element.querySelector('[data-combat-potion]')?.textContent).toContain('Potion 1/2');
expect(element.textContent).toContain('Aric Duskwalker drinks a potion and heals 15 HP.');
// The monster's reply is still held back at this point.
expect(countOccurrences(element.textContent, monsterHitLine)).toBe(1);
@@ -444,7 +444,7 @@ describe('CombatPageComponent', () => {
expect(worldStore.refreshCharacter).toHaveBeenCalledOnce();
});
it('disables Angriff while an action is pending', async () => {
it('disables Attack while an action is pending', async () => {
const fixture = await setup(activeCombat);
combatStore.actionPending.set(true);
fixture.detectChanges();
@@ -453,21 +453,21 @@ describe('CombatPageComponent', () => {
expect(element.querySelector<HTMLButtonElement>('[data-combat-attack]')?.disabled).toBe(true);
});
it('shows the victory state and hides Angriff when the combat is WON', async () => {
it('shows the victory state and hides Attack when the combat is WON', async () => {
const fixture = await setup({ ...activeCombat, status: 'WON' });
const element = fixture.nativeElement as HTMLElement;
expect(element.querySelector('[data-combat-result="WON"]')).toBeTruthy();
expect(element.textContent).toContain('Sieg');
expect(element.textContent).toContain('Victory');
expect(element.querySelector('[data-combat-attack]')).toBeNull();
});
it('shows the defeat state and hides Angriff when the combat is LOST', async () => {
it('shows the defeat state and hides Attack when the combat is LOST', async () => {
const fixture = await setup({ ...activeCombat, status: 'LOST' });
const element = fixture.nativeElement as HTMLElement;
expect(element.querySelector('[data-combat-result="LOST"]')).toBeTruthy();
expect(element.textContent).toContain('Niederlage');
expect(element.textContent).toContain('Defeat');
expect(element.querySelector('[data-combat-attack]')).toBeNull();
});
@@ -542,7 +542,7 @@ describe('CombatPageComponent', () => {
it('hides the silver tile entirely when the kill granted none', async () => {
// Every monster seeded in this slice rolls silverMin/silverMax = 0 (design
// R7), so without this guard the victory screen advertises "Silber +0"
// R7), so without this guard the victory screen advertises "Silver +0"
// after every single fight in the shipped content.
const fixture = await setup({
...wonWithRewards,
@@ -589,7 +589,7 @@ describe('CombatPageComponent', () => {
const element = fixture.nativeElement as HTMLElement;
expect(element.querySelector('[data-reward-empty]')?.textContent).toContain(
'Keine besondere Beute gefunden.',
'No notable loot found.',
);
expect(element.querySelector('[role="alert"]')).toBeNull();
});

View File

@@ -196,7 +196,7 @@ export class CombatPageComponent implements OnInit {
}
// The location is the screen a fight resolves back into. It sits beside
// "Weiter jagen" rather than replacing it, so the hunt loop keeps its
// "Keep Hunting" rather than replacing it, so the hunt loop keeps its
// one-click rhythm.
protected goToLocation(): void {
void this.router.navigate(['/location']);
@@ -233,7 +233,7 @@ export class CombatPageComponent implements OnInit {
if (!combat || combat.monster.pendingIntent !== 'HEAVY_ATTACK') {
return null;
}
return `${combat.monster.name} bereitet Schweren Hieb vor.`;
return `${combat.monster.name} is winding up a Heavy Strike.`;
}
protected logRounds(): CombatLogRound[] {
@@ -254,36 +254,36 @@ export class CombatPageComponent implements OnInit {
protected formatEvent(event: CombatEvent): string {
const combat = this.displayed();
const playerName = combat?.player.name ?? 'Du';
const monsterName = combat?.monster.name ?? 'Der Gegner';
const playerName = combat?.player.name ?? 'You';
const monsterName = combat?.monster.name ?? 'The enemy';
if (event.type === 'DAMAGE') {
const attacker = event.source === 'PLAYER' ? playerName : monsterName;
const defender = event.target === 'PLAYER' ? playerName : monsterName;
return `${attacker} trifft ${defender} für ${event.amount} Schaden.`;
return `${attacker} hits ${defender} for ${event.amount} damage.`;
}
if (event.type === 'HEAL') {
return `${playerName} trinkt einen Trank und heilt ${event.amount} Lebenspunkte.`;
return `${playerName} drinks a potion and heals ${event.amount} HP.`;
}
if (event.type === 'DEFEND') {
return `${playerName} geht in die Verteidigung.`;
return `${playerName} braces to defend.`;
}
if (event.type === 'TELEGRAPH') {
return `${monsterName} bereitet Schweren Hieb vor.`;
return `${monsterName} is winding up a Heavy Strike.`;
}
if (event.type === 'INTERRUPT') {
return `${playerName} unterbricht den vorbereiteten Angriff von ${monsterName}.`;
return `${playerName} interrupts ${monsterName}'s attack.`;
}
if (event.type === 'COMBAT_WON') {
return `${monsterName} wurde besiegt.`;
return `${monsterName} has been defeated.`;
}
return `${playerName} wurde im Kampf besiegt.`;
return `${playerName} has been defeated.`;
}
private wait(ms: number): Promise<void> {