Files
ashen-realms/apps/web/src/app/features/combat/combat-page/combat-page.component.scss
2026-08-20 10:33:39 +02:00

715 lines
16 KiB
SCSS

:host {
display: block;
block-size: 100%;
}
.combat {
display: grid;
grid-template-columns: minmax(0, 1fr) clamp(13rem, 17vw, 17rem);
gap: var(--ar-space-4);
block-size: 100%;
}
/* ---------- stage ---------- */
// The stage is a container so the status row reflows on its own width: the
// surrounding rails can squeeze it narrow while the viewport is still wide.
.combat__stage {
position: relative;
display: grid;
container-type: inline-size;
grid-template-rows: auto minmax(0, 1fr) auto;
gap: var(--ar-space-4);
// 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);
background-color: var(--ar-bg);
background-image: url('/images/backgrounds/Aschestrasse.png');
background-position: center;
background-repeat: no-repeat;
background-size: cover;
box-shadow: var(--ar-shadow-raised);
}
@supports (
background-image: image-set(
url('/images/backgrounds/runtime/Aschestrasse-960.jpg') type('image/jpeg') 1x
)
) {
.combat__stage {
background-image: image-set(
url('/images/backgrounds/runtime/Aschestrasse-960.jpg') type('image/jpeg') 1x
);
}
}
.combat__stage::before {
position: absolute;
z-index: 0;
inset: 0;
content: '';
background:
linear-gradient(180deg, rgb(4 6 8 / 0.72) 0%, rgb(4 6 8 / 0.1) 26%, rgb(4 6 8 / 0.66) 100%);
pointer-events: none;
}
/* ---------- combatant status ---------- */
.combat__status {
position: relative;
z-index: 2;
display: grid;
grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr);
align-items: start;
gap: var(--ar-space-4);
}
.fighter {
display: flex;
align-items: center;
gap: var(--ar-space-3);
min-inline-size: 0;
}
.fighter--monster {
justify-content: flex-end;
}
.fighter__icon {
flex: 0 0 auto;
inline-size: clamp(2.75rem, 5vw, 3.75rem);
block-size: clamp(2.75rem, 5vw, 3.75rem);
object-fit: cover;
border-radius: 50%;
box-shadow:
0 0 0 1px var(--ar-border-highlight),
0 0.3rem 0.9rem rgb(0 0 0 / 0.8);
}
.fighter__meter {
display: grid;
gap: 0.3rem;
min-inline-size: 0;
flex: 1 1 auto;
max-inline-size: 22rem;
}
.fighter--monster .fighter__meter {
justify-items: end;
text-align: end;
}
.fighter__name {
margin: 0;
overflow: hidden;
color: var(--ar-text);
font-family: Georgia, 'Times New Roman', serif;
font-size: clamp(0.95rem, 1.5vw, 1.15rem);
letter-spacing: 0.02em;
text-overflow: ellipsis;
text-shadow: 0 0.1rem 0.5rem rgb(0 0 0 / 0.95);
white-space: nowrap;
}
.fighter__level {
color: var(--ar-gold);
font-family: system-ui, sans-serif;
font-size: var(--ar-font-sm);
letter-spacing: 0.08em;
text-transform: uppercase;
}
/* ---------- hp bars ---------- */
.bar {
position: relative;
display: block;
inline-size: 100%;
block-size: 1.25rem;
overflow: hidden;
border: 1px solid var(--ar-border);
background: rgb(0 0 0 / 0.72);
box-shadow: inset 0 0 0.6rem rgb(0 0 0 / 0.9);
}
.bar__fill {
position: absolute;
z-index: 0;
inset-block: 0;
inset-inline-start: 0;
display: block;
background: linear-gradient(180deg, #d0564a, #8e2b23);
}
.bar--monster .bar__fill {
inset-inline-start: auto;
inset-inline-end: 0;
}
.bar__text {
position: relative;
z-index: 1;
display: grid;
block-size: 100%;
place-items: center;
color: var(--ar-text);
font-size: var(--ar-font-sm);
font-variant-numeric: tabular-nums;
letter-spacing: 0.04em;
text-shadow: 0 0.05rem 0.25rem rgb(0 0 0 / 1);
}
/* ---------- round marker ---------- */
.combat__round {
margin: 0;
align-self: center;
padding: var(--ar-space-1) var(--ar-space-5);
border-block: 1px solid var(--ar-border-highlight);
color: var(--ar-gold);
background: linear-gradient(90deg, transparent, rgb(9 11 13 / 0.9) 18%, rgb(9 11 13 / 0.9) 82%, transparent);
font-family: Georgia, 'Times New Roman', serif;
font-size: clamp(0.95rem, 1.4vw, 1.1rem);
letter-spacing: 0.14em;
text-transform: uppercase;
white-space: nowrap;
}
/* ---------- battlefield ---------- */
.combat__field {
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;
}
.sprite {
display: block;
max-inline-size: 100%;
inline-size: auto;
object-fit: contain;
object-position: bottom;
filter: drop-shadow(0 0.5rem 0.9rem rgb(0 0 0 / 0.7));
}
/* Six 384px frames laid out horizontally; frame 0 is the resting stance. */
.sprite--player {
justify-self: start;
block-size: 80%;
aspect-ratio: 1;
transform: scaleX(-1);
background-image: url('/images/combat/sprites/warrior-attack-sheet-384.png');
background-repeat: no-repeat;
background-position: 0% 0;
background-size: 600% 100%;
}
.sprite--player.sprite--hit {
background-image: url('/images/combat/sprites/warrior-hit-sheet-384.png');
}
/* 6 frames across a 600%-wide sheet land on 0/20/40/60/80/100%. */
@keyframes warrior-frames {
from {
background-position: 0% 0;
}
to {
background-position: 120% 0;
}
}
.sprite--monster {
justify-self: end;
block-size: calc(var(--sprite-scale, 0.6) * 100%);
// Every beat below scales and rotates the cut-out. Pivoting at the feet keeps
// it standing on the same spot instead of sinking behind the action bar.
transform-origin: bottom center;
}
/* The monster is a single cut-out, so every beat below is transform-only. All
of them animate translate/rotate/scale rather than `transform`, and any step
that touches `filter` has to repeat the base drop-shadow from `.sprite`,
because a keyframe filter replaces the whole list. */
/* Getting hit: a short recoil to the right, away from the player, with a red
glow pulse. 240ms so it finishes inside the 260ms riposte pause. */
@keyframes monster-flinch {
0% {
translate: 0 0;
rotate: 0deg;
filter: drop-shadow(0 0.5rem 0.9rem rgb(0 0 0 / 0.7)) drop-shadow(0 0 0 rgb(199 75 66 / 0));
}
20% {
translate: 0.45rem -0.12rem;
rotate: 1.1deg;
filter: drop-shadow(0 0.5rem 0.9rem rgb(0 0 0 / 0.7))
drop-shadow(0 0 0.65rem rgb(199 75 66 / 0.6));
}
45% {
translate: -0.28rem 0;
rotate: -1deg;
}
70% {
translate: 0.18rem 0;
rotate: 0.5deg;
}
100% {
translate: 0 0;
rotate: 0deg;
filter: drop-shadow(0 0.5rem 0.9rem rgb(0 0 0 / 0.7)) drop-shadow(0 0 0 rgb(199 75 66 / 0));
}
}
/* Striking back: a brief wind-up away from the player, then a lunge left. The
impact sits at 28% of 540ms so it reads against the warrior hit sheet. */
@keyframes monster-lunge {
0% {
translate: 0 0;
rotate: 0deg;
scale: 1;
}
12% {
translate: 0.55rem 0;
rotate: 1.4deg;
scale: 0.985;
}
28% {
translate: -2rem -0.45rem;
rotate: -2.4deg;
scale: 1.05;
}
55% {
translate: -0.8rem -0.1rem;
rotate: -0.8deg;
scale: 1.015;
}
100% {
translate: 0 0;
rotate: 0deg;
scale: 1;
}
}
/* Idle: deliberately tiny, just enough that the cut-out does not read as card-
board. The spec rules out permanent *strong* animation, not a slow breath. */
@keyframes monster-breathe {
from {
translate: 0 0;
scale: 1;
}
to {
translate: 0 -0.4%;
scale: 1.004;
}
}
/* Stage jolt when the player takes the blow. The slight scale gives the offset
somewhere to go: the stage clips its children, but its own border moves too,
so without it the shift would open a sliver at the frame. */
@keyframes stage-shake {
0%,
100% {
translate: 0 0;
scale: 1;
}
15% {
translate: -0.35% 0.08rem;
scale: 1.012;
}
35% {
translate: 0.3% -0.06rem;
scale: 1.012;
}
60% {
translate: -0.18% 0.04rem;
scale: 1.009;
}
80% {
translate: 0.1% 0;
scale: 1.004;
}
}
/* ---------- action bar ---------- */
.combat__actions {
position: relative;
z-index: 2;
display: flex;
gap: var(--ar-space-3);
min-block-size: clamp(6.5rem, 11vw, 8.5rem);
}
.action {
position: relative;
inline-size: clamp(6.5rem, 11vw, 8.5rem);
aspect-ratio: 1;
padding: 0;
border: 0;
background: transparent url('/images/hud/runtime/fight-action-frame-360.png') center / 100% 100%
no-repeat;
cursor: pointer;
}
.action__icon,
.action__label,
.action__key {
position: absolute;
inset-inline-start: 50%;
translate: -50% -50%;
}
.action__icon {
inset-block-start: 32%;
inline-size: 34%;
border-radius: 50%;
opacity: 0.92;
}
.action__label {
inset-block-start: 62%;
color: var(--ar-text);
font-family: Georgia, 'Times New Roman', serif;
font-size: clamp(0.85rem, 1.2vw, 1rem);
letter-spacing: 0.03em;
white-space: nowrap;
}
.action__key {
inset-block-start: 90%;
color: var(--ar-text-muted);
font-size: var(--ar-font-sm);
font-variant-numeric: tabular-nums;
}
.action:hover:not(:disabled) .action__label,
.action:focus-visible .action__label {
color: var(--ar-gold);
}
.action:focus-visible {
outline: 2px solid var(--ar-gold);
outline-offset: 3px;
}
.action:disabled {
cursor: progress;
opacity: 0.55;
}
/* ---------- outcome overlay ---------- */
.outcome {
position: absolute;
z-index: 3;
inset-block-start: 50%;
inset-inline-start: 50%;
display: grid;
gap: var(--ar-space-2);
justify-items: center;
inline-size: min(26rem, 80%);
padding: var(--ar-space-5);
translate: -50% -50%;
border: 1px solid var(--ar-border-highlight);
background: rgb(9 11 13 / 0.94);
box-shadow: var(--ar-shadow-raised);
text-align: center;
}
.outcome p {
margin: 0;
color: var(--ar-text-muted);
}
.outcome__title {
margin: 0;
font-family: Georgia, 'Times New Roman', serif;
font-size: clamp(1.5rem, 3vw, 2rem);
font-weight: 400;
letter-spacing: 0.12em;
text-transform: uppercase;
}
.outcome--won {
inline-size: min(32rem, 90%);
}
.outcome--won .outcome__title {
color: var(--ar-gold);
}
.outcome--lost .outcome__title {
color: var(--ar-danger);
}
.outcome__hint {
font-size: var(--ar-font-sm);
font-style: italic;
}
.outcome__button,
.combat__notice--error button {
padding: var(--ar-space-2) var(--ar-space-5);
border: 1px solid var(--ar-border-highlight);
border-radius: var(--ar-radius-sm);
color: var(--ar-text);
background: linear-gradient(180deg, #23282c, #14181b);
cursor: pointer;
font-family: Georgia, 'Times New Roman', serif;
letter-spacing: 0.04em;
}
.outcome__button {
margin-block-start: var(--ar-space-2);
}
.outcome__button:hover,
.combat__notice--error button:hover {
border-color: var(--ar-gold);
color: var(--ar-gold);
}
/* ---------- combat log ---------- */
.combat__log {
display: grid;
grid-template-rows: auto minmax(0, 1fr);
min-block-size: 0;
border: 1px solid var(--ar-border);
background: var(--ar-panel-muted);
box-shadow: var(--ar-shadow-raised);
}
.combat__log-title {
margin: 0;
padding: var(--ar-space-3) var(--ar-space-4);
border-block-end: 1px solid var(--ar-border);
color: var(--ar-gold);
font-family: Georgia, 'Times New Roman', serif;
font-size: 1.05rem;
font-weight: 400;
letter-spacing: 0.1em;
text-transform: uppercase;
}
.combat__log-body {
padding: var(--ar-space-3) var(--ar-space-4) var(--ar-space-4);
overflow-y: auto;
}
.combat__log-round {
margin: var(--ar-space-4) 0 var(--ar-space-2);
color: var(--ar-gold);
font-size: var(--ar-font-sm);
letter-spacing: 0.1em;
text-transform: uppercase;
}
.combat__log-round:first-child {
margin-block-start: 0;
}
.combat__log-entry {
margin: 0 0 var(--ar-space-1);
padding-inline-start: var(--ar-space-3);
border-inline-start: 2px solid var(--ar-danger);
color: var(--ar-text-muted);
font-size: var(--ar-font-sm);
line-height: 1.45;
}
.combat__log-entry--player {
border-inline-start-color: var(--ar-border-highlight);
}
/* ---------- notices ---------- */
.combat__notice {
grid-column: 1 / -1;
padding: var(--ar-space-4);
border: 1px solid var(--ar-border);
background: var(--ar-panel);
box-shadow: var(--ar-shadow-raised);
}
.combat__notice--error {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--ar-space-4);
border-color: var(--ar-danger);
}
.combat__notice--error button {
flex: 0 0 auto;
}
@media (prefers-reduced-motion: no-preference) {
.sprite--player.sprite--attacking,
.sprite--player.sprite--hit {
animation: warrior-frames 540ms steps(6) 1;
}
/* Order matters: the state classes below share translate/rotate/scale with
the breathing loop, so their `animation` shorthand has to come last. */
.sprite--monster {
animation: monster-breathe 5.5s ease-in-out infinite alternate;
}
.sprite--flinch {
animation: monster-flinch 240ms ease-out 1;
}
.sprite--lunge {
animation: monster-lunge 540ms cubic-bezier(0.22, 0.9, 0.3, 1) 1;
}
.combat__stage--shaken {
animation: stage-shake 200ms ease-out 1;
}
.bar__fill {
transition: inline-size var(--ar-motion-base);
}
.action__label {
transition: color var(--ar-motion-fast);
}
}
@media (width < 60rem) {
:host,
.combat {
block-size: auto;
}
.combat {
grid-template-columns: minmax(0, 1fr);
}
.combat__stage {
block-size: clamp(20rem, 55dvh, 34rem);
}
.combat__log {
max-block-size: 18rem;
}
}
// Below this the three-column status row cannot hold two names and two bars
// side by side, so the fighters stack under a centred round marker.
@container (width < 38rem) {
.combat__status {
grid-template-columns: minmax(0, 1fr);
gap: var(--ar-space-2);
}
.combat__round {
order: -1;
justify-self: center;
}
.fighter--monster {
justify-content: flex-start;
}
.fighter--monster .fighter__meter {
justify-items: start;
text-align: start;
}
.bar--monster .bar__fill {
inset-inline-start: 0;
inset-inline-end: auto;
}
.fighter__meter {
max-inline-size: none;
}
}
/* Reward summary: dark stone and bronze, large readable values, restrained
rarity emphasis. No confetti, no popups, no slot-machine reveal (spec §50). */
.rewards {
display: grid;
gap: var(--ar-space-3);
justify-items: center;
inline-size: 100%;
margin-block-start: var(--ar-space-3);
padding-block-start: var(--ar-space-3);
border-block-start: 1px solid var(--ar-border);
}
.rewards__title {
margin: 0;
color: var(--ar-text-muted);
font-family: Georgia, 'Times New Roman', serif;
font-size: var(--ar-font-sm);
font-weight: 400;
letter-spacing: 0.16em;
text-transform: uppercase;
}
.rewards__currencies {
display: flex;
gap: var(--ar-space-6);
margin: 0;
}
.rewards__currency {
display: grid;
gap: var(--ar-space-1);
justify-items: center;
}
.rewards__currency dt {
color: var(--ar-text-muted);
font-size: var(--ar-font-sm);
letter-spacing: 0.08em;
text-transform: uppercase;
}
.rewards__currency dd {
margin: 0;
color: var(--ar-gold);
font-family: Georgia, 'Times New Roman', serif;
font-size: clamp(1.25rem, 2.5vw, 1.6rem);
}
.rewards__loot {
display: flex;
flex-wrap: wrap;
gap: var(--ar-space-4);
justify-content: center;
margin: 0;
padding: 0;
list-style: none;
}