This commit is contained in:
Bastian Wagner
2026-08-20 09:47:24 +02:00
parent 67237f5ad8
commit b0e769d0da
12 changed files with 3904 additions and 7 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

View File

@@ -1,6 +1,6 @@
<section class="combat" aria-label="Kampf">
@if (combat(); as combat) {
<div class="combat__stage" [class.combat__stage--shaken]="phase() === 'hit'">
<div class="combat__stage" [class.combat__stage--shaken]="stageShake()">
<header class="combat__status">
<div class="fighter fighter--player">
<img class="fighter__icon" [src]="playerIcon" alt="" />

View File

@@ -20,7 +20,12 @@
container-type: inline-size;
grid-template-rows: auto minmax(0, 1fr) auto;
gap: var(--ar-space-4);
min-block-size: 26rem;
// The sprites are sized as a share of the stage, so the stage has to carry a
// height of its own. With only a min-block-size the tallest cut-out -- the
// road bandit -- sized the battlefield row from its intrinsic height and
// pushed the page into a scrollbar. The subtracted 12.5rem is the shell
// chrome around the main column: top bar, footer and its own padding.
block-size: max(24rem, calc(100dvh - 12.5rem));
padding: var(--ar-space-4);
overflow: hidden;
border: 1px solid var(--ar-border);
@@ -182,7 +187,11 @@
position: relative;
z-index: 1;
display: grid;
// The single row is spelled out rather than left implicit: only then is it a
// definite height, and only then do the sprites' percentage heights resolve
// against the battlefield instead of against their own artwork.
grid-template-columns: 1fr 1fr;
grid-template-rows: minmax(0, 1fr);
align-items: end;
min-block-size: 0;
}
@@ -602,7 +611,7 @@
}
.combat__stage {
min-block-size: clamp(24rem, 55vh, 34rem);
block-size: clamp(20rem, 55dvh, 34rem);
}
.combat__log {

View File

@@ -162,7 +162,8 @@ describe('CombatPageComponent', () => {
expect(sprite?.classList.contains('sprite--hit')).toBe(true);
expect(monster?.classList.contains('sprite--lunge')).toBe(true);
expect(monster?.classList.contains('sprite--flinch')).toBe(false);
expect(stage?.classList.contains('combat__stage--shaken')).toBe(true);
// The stage jolt is wired up but no longer fires on an ordinary hit.
expect(stage?.classList.contains('combat__stage--shaken')).toBe(false);
expect(element.textContent).toContain('90 / 100');
expect(countOccurrences(element.textContent, monsterHitLine)).toBe(2);

View File

@@ -28,6 +28,8 @@ const SWING_MS = 540;
const RECOIL_MS = 540;
// Beat between the player's blow landing and the monster striking back.
const RIPOSTE_DELAY_MS = 260;
// Length of the stage jolt keyframes, see `stage-shake` in the stylesheet.
const STAGE_SHAKE_MS = 200;
@Component({
selector: 'app-combat-page',
@@ -47,9 +49,15 @@ export class CombatPageComponent implements OnInit {
private readonly displayed = signal<Combat | null>(null);
private readonly replaying = signal(false);
// The stage jolt stays wired up but is no longer fired by an ordinary hit --
// it was too much for every single round. Call `shakeStage()` to bring it
// back for a specific ability.
private readonly stageShaking = signal(false);
protected readonly combat = this.displayed.asReadonly();
protected readonly phase = signal<CombatPhase>('idle');
protected readonly monsterPhase = signal<MonsterPhase>('idle');
protected readonly stageShake = this.stageShaking.asReadonly();
protected readonly busy = computed(() => this.replaying() || this.combatStore.actionPending());
protected readonly playerIcon = PLAYER_ICON;
@@ -125,6 +133,16 @@ export class CombatPageComponent implements OnInit {
}
}
/** Jolts the whole stage once. Reserved for abilities; no attack triggers it. */
protected shakeStage(): void {
this.stageShaking.set(true);
setTimeout(() => {
if (!this.destroyed) {
this.stageShaking.set(false);
}
}, STAGE_SHAKE_MS);
}
protected retry(): void {
void this.loadFromRoute();
}

View File

@@ -1,12 +1,14 @@
<div class="app-shell">
<app-top-bar [character]="worldStore.character()" />
<div class="app-shell__content">
<div class="app-shell__content" [class.app-shell__content--no-context]="inCombat()">
<app-side-navigation />
<main class="app-shell__main" aria-label="Spielinhalt">
<router-outlet />
</main>
<app-context-panel />
@if (!inCombat()) {
<app-context-panel />
}
</div>
<app-game-footer />

View File

@@ -16,6 +16,12 @@
min-block-size: 0;
}
// Without the context rail the main column takes its place. The narrow layouts
// below re-declare the template, so they keep working either way.
.app-shell__content--no-context {
grid-template-columns: minmax(11rem, 13rem) minmax(0, 1fr);
}
.app-shell__main {
min-inline-size: 0;
min-block-size: 0;

View File

@@ -1,5 +1,5 @@
import { Component, inject } from '@angular/core';
import { RouterOutlet } from '@angular/router';
import { Router, RouterOutlet, isActive } from '@angular/router';
import { WorldStore } from '../../features/world/world.store';
import { ContextPanelComponent } from '../context-panel/context-panel.component';
import { GameFooterComponent } from '../game-footer/game-footer.component';
@@ -20,4 +20,8 @@ import { TopBarComponent } from '../top-bar/top-bar.component';
})
export class AppShellComponent {
protected readonly worldStore = inject(WorldStore);
// The fight has its own log rail and wants the width, and the area info
// belongs to the world view anyway, so the rail is dropped during combat.
protected readonly inCombat = isActive('/combat', inject(Router));
}