combat
This commit is contained in:
BIN
apps/web/public/assets/hud-elements/x.png
Normal file
BIN
apps/web/public/assets/hud-elements/x.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.5 MiB |
@@ -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="" />
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user