448 lines
21 KiB
TypeScript
448 lines
21 KiB
TypeScript
// /src/app/radar/TeamSidebar.tsx
|
|
'use client'
|
|
import React, { useEffect, useState } from 'react'
|
|
import { useAvatarDirectoryStore } from '../../lib/useAvatarDirectoryStore'
|
|
|
|
export type Team = 'T' | 'CT'
|
|
|
|
export type SidebarPlayer = {
|
|
id: string
|
|
name?: string | null
|
|
hp?: number | null
|
|
armor?: number | null
|
|
helmet?: boolean | null
|
|
defuse?: boolean | null
|
|
hasBomb?: boolean | null
|
|
alive?: boolean | null
|
|
activeWeapon?: string | { name?: string | null } | null
|
|
grenades?: Partial<Record<
|
|
'hegrenade'|'smokegrenade'|'flashbang'|'decoy'|'molotov'|'incgrenade',
|
|
number
|
|
>> | null
|
|
weapons?: { name: string; state?: string | null }[] | null
|
|
}
|
|
|
|
const EQUIP_BASE = '/assets/img/icons/equipment'
|
|
const equipIcon = (file: string) => `${EQUIP_BASE}/${file}`
|
|
|
|
/* ── Inline SVG Icons (weiß via currentColor) ── */
|
|
const HeartIcon = ({ className = 'w-3.5 h-3.5' }: { className?: string }) => (
|
|
<svg aria-hidden viewBox="0 0 640 640" className={className + ' text-white'} fill="currentColor" xmlns="http://www.w3.org/2000/svg">
|
|
<path d="M305 151.1L320 171.8L335 151.1C360 116.5 400.2 96 442.9 96C516.4 96 576 155.6 576 229.1L576 231.7C576 343.9 436.1 474.2 363.1 529.9C350.7 539.3 335.5 544 320 544C304.5 544 289.2 539.4 276.9 529.9C203.9 474.2 64 343.9 64 231.7L64 229.1C64 155.6 123.6 96 197.1 96C239.8 96 280 116.5 305 151.1z"/>
|
|
</svg>
|
|
)
|
|
|
|
const ShieldIcon = ({ className = 'w-3.5 h-3.5' }: { className?: string }) => (
|
|
<svg aria-hidden viewBox="0 0 640 640" className={className + ' text-white'} fill="currentColor" xmlns="http://www.w3.org/2000/svg">
|
|
<path d="M320 64C324.6 64 329.2 65 333.4 66.9L521.8 146.8C543.8 156.1 560.2 177.8 560.1 204C559.6 303.2 518.8 484.7 346.5 567.2C329.8 575.2 310.4 575.2 293.7 567.2C121.3 484.7 80.6 303.2 80.1 204C80 177.8 96.4 156.1 118.4 146.8L306.7 66.9C310.9 65 315.4 64 320 64z"/>
|
|
</svg>
|
|
)
|
|
|
|
/* ── Rotes Bomben-Icon via CSS-Maske, damit es sicher rot ist ── */
|
|
const BombMaskIcon = ({ src, title, className = 'h-3.5 w-3.5' }: { src: string; title?: string; className?: string }) => (
|
|
<span
|
|
title={title}
|
|
role="img"
|
|
aria-label={title}
|
|
className={`inline-block align-middle bg-red-500 ${className}`}
|
|
style={{
|
|
maskImage: `url("${src}")`,
|
|
WebkitMaskImage: `url("${src}")`,
|
|
maskRepeat: 'no-repeat',
|
|
WebkitMaskRepeat: 'no-repeat',
|
|
maskPosition: 'center',
|
|
WebkitMaskPosition: 'center',
|
|
maskSize: 'contain',
|
|
WebkitMaskSize: 'contain',
|
|
}}
|
|
/>
|
|
)
|
|
|
|
/* ── Gear Blöcke (links/rechts trennen) ── */
|
|
function leftGear(opts: { armor?: number|null; helmet?: boolean|null }) {
|
|
const out: { src: string; title: string; key: string }[] = []
|
|
if ((opts.armor ?? 0) > 0) out.push({ src: equipIcon('armor.svg'), title: 'Kevlar', key: 'armor' })
|
|
if (opts.helmet) out.push({ src: equipIcon('helmet.svg'), title: 'Helmet', key: 'helmet' })
|
|
return out
|
|
}
|
|
function rightGear(opts: { hasBomb?: boolean|null; team: Team; defuse?: boolean|null }) {
|
|
const out: { src: string; title: string; key: string }[] = []
|
|
if (opts.hasBomb) out.push({ src: equipIcon('c4.svg'), title: 'C4', key: 'c4' })
|
|
if (opts.team === 'CT' && opts.defuse) out.push({ src: equipIcon('defuser.svg'), title: 'Defuse Kit', key: 'defuser' })
|
|
return out
|
|
}
|
|
|
|
/* ── Normalisierung ── */
|
|
function normWeaponName(raw?: string | null) {
|
|
if (!raw) return ''
|
|
let k = String(raw).toLowerCase().replace(/^weapon_/, '').replace(/\s+/g, '')
|
|
if (k === 'usp-s' || k === 'usp-silencer') k = 'usp_silencer'
|
|
if (k === 'm4a1-s' || k === 'm4a1s') k = 'm4a1_silencer'
|
|
if (k === 'm4a1s_off' || k === 'm4a1-s_off') k = 'm4a1_silencer_off'
|
|
return k
|
|
}
|
|
function isActiveWeapon(itemName?: string|null, active?: string | { name?: string|null } | null, state?: string|null) {
|
|
if ((state ?? '').toLowerCase() === 'active') return true
|
|
const ni = normWeaponName(itemName)
|
|
const na = typeof active === 'string' ? normWeaponName(active) : normWeaponName(active?.name ?? null)
|
|
return !!ni && !!na && ni === na
|
|
}
|
|
|
|
/* ── Sets ── */
|
|
const GRENADE_SET = new Set(['hegrenade','smokegrenade','flashbang','decoy','molotov','incgrenade'])
|
|
const PRIMARY_SET = new Set([
|
|
'ak47','aug','sg556','galilar','famas','m4a1','m4a1_silencer','m4a1_silencer_off',
|
|
'awp','ssg08','scar20','g3sg1','xm1014','mag7','sawedoff','nova','m249','negev',
|
|
'p90','ump45','mp9','mp7','mp5sd','mac10','bizon'
|
|
])
|
|
const SECONDARY_SET = new Set([
|
|
'hkp2000','p2000','p250','glock','deagle','elite','usp_silencer','usp_silencer_off',
|
|
'fiveseven','cz75a','tec9','revolver','taser'
|
|
])
|
|
|
|
/* ── Icons ── */
|
|
const WEAPON_ALIAS: Record<string, string> = {
|
|
// Pistols
|
|
'hkp2000':'hkp2000','p2000':'p2000','p250':'p250','glock':'glock',
|
|
'deagle':'deagle','elite':'elite','usp_silencer':'usp_silencer','usp':'usp_silencer',
|
|
'usp_silencer_off':'usp_silencer_off','fiveseven':'fiveseven','cz75a':'cz75a','tec9':'tec9','revolver':'revolver',
|
|
// SMGs
|
|
'mac10':'mac10','mp7':'mp7','mp5sd':'mp5sd','mp9':'mp9','bizon':'bizon','ump45':'ump45','p90':'p90',
|
|
// Rifles
|
|
'ak47':'ak47','aug':'aug','sg556':'sg556','galilar':'galilar','famas':'famas',
|
|
'm4a1':'m4a1','m4a1_silencer':'m4a1_silencer','m4a1_silencer_off':'m4a1_silencer_off',
|
|
// Snipers / Heavy / Shotguns
|
|
'awp':'awp','ssg08':'ssg08','scar20':'scar20','g3sg1':'g3sg1',
|
|
'xm1014':'xm1014','mag7':'mag7','sawedoff':'sawedoff','nova':'nova',
|
|
'm249':'m249','negev':'negev',
|
|
// Grenades / misc
|
|
'hegrenade':'hegrenade','incgrenade':'incgrenade','molotov':'molotov',
|
|
'smokegrenade':'smokegrenade','flashbang':'flashbang','decoy':'decoy',
|
|
'taser':'taser','defuser':'defuser','c4':'c4','planted_c4':'planted_c4',
|
|
// Knives
|
|
'knife':'knife','knife_t':'knife_t','melee':'melee'
|
|
}
|
|
function weaponIconFromName(raw?: string | null): string | null {
|
|
if (!raw) return null
|
|
const k = normWeaponName(raw)
|
|
const file = WEAPON_ALIAS[k]
|
|
return file ? equipIcon(`${file}.svg`) : null
|
|
}
|
|
|
|
const GRENADE_DISPLAY_ORDER = ['flashbang','smokegrenade','hegrenade','molotov','incgrenade','decoy'] as const
|
|
function grenadeIconFromKey(k: string): string {
|
|
switch (k) {
|
|
case 'hegrenade': return equipIcon('hegrenade.svg')
|
|
case 'smokegrenade':return equipIcon('smokegrenade.svg')
|
|
case 'flashbang': return equipIcon('flashbang.svg')
|
|
case 'decoy': return equipIcon('decoy.svg')
|
|
case 'molotov': return equipIcon('molotov.svg')
|
|
case 'incgrenade': return equipIcon('incgrenade.svg')
|
|
default: return equipIcon('hegrenade.svg')
|
|
}
|
|
}
|
|
|
|
function activeWeaponNameOf(w?: string | { name?: string | null } | null): string | null {
|
|
if (!w) return null
|
|
if (typeof w === 'string') return w
|
|
if (typeof w === 'object' && w?.name) return w.name
|
|
return null
|
|
}
|
|
|
|
export default function TeamSidebar({
|
|
team, teamId, players, align = 'left', onHoverPlayer, score, oppScore
|
|
}: {
|
|
team: Team
|
|
teamId?: string
|
|
players: SidebarPlayer[]
|
|
align?: 'left' | 'right'
|
|
onHoverPlayer?: (id: string | null) => void
|
|
score?: number
|
|
oppScore?: number
|
|
}) {
|
|
const [teamLogo, setTeamLogo] = useState<string | null>(null)
|
|
const [teamApiName, setTeamApiName] = useState<string | null>(null)
|
|
const BOT_ICON = '/assets/img/icons/ui/bot.svg'
|
|
const isBotId = (id: string) => id?.toUpperCase().startsWith('BOT:')
|
|
|
|
useEffect(() => {
|
|
let abort = false
|
|
;(async () => {
|
|
if (!teamId) { setTeamLogo(null); setTeamApiName(null); return }
|
|
try {
|
|
const res = await fetch(`/api/team/${teamId}`, { cache: 'no-store' })
|
|
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
|
const data = await res.json()
|
|
if (!abort) { setTeamLogo(data?.logo || null); setTeamApiName(data?.name || null) }
|
|
} catch { if (!abort) { setTeamLogo(null); setTeamApiName(null) } }
|
|
})()
|
|
return () => { abort = true }
|
|
}, [teamId])
|
|
|
|
const ensureTeamsLoaded = useAvatarDirectoryStore(s => s.ensureTeamsLoaded)
|
|
const avatarById = useAvatarDirectoryStore(s => s.byId)
|
|
const avatarVer = useAvatarDirectoryStore(s => s.version)
|
|
useEffect(() => { if (teamId) ensureTeamsLoaded([teamId]) }, [teamId, ensureTeamsLoaded])
|
|
|
|
const defaultTeamName = team === 'CT' ? 'Counter-Terrorists' : 'Terrorists'
|
|
const teamName = teamApiName || defaultTeamName
|
|
|
|
const teamColor = team === 'CT' ? 'text-blue-400' : 'text-amber-400'
|
|
const barArmor = team === 'CT' ? 'bg-blue-500' : 'bg-amber-500'
|
|
const ringColor = team === 'CT' ? 'ring-blue-500' : 'ring-amber-500'
|
|
const isRight = align === 'right'
|
|
|
|
const fallbackLogo = '/assets/img/logos/cs2.webp'
|
|
const logoSrc = teamLogo || fallbackLogo
|
|
|
|
const aliveCount = players.filter(p => p.alive !== false && (p.hp ?? 1) > 0).length
|
|
const sorted = [...players].sort((a,b)=>{
|
|
const al = (b.alive ? 1 : 0) - (a.alive ? 1 : 0); if (al) return al
|
|
const hp = (b.hp ?? -1) - (a.hp ?? -1); if (hp) return hp
|
|
return (a.name ?? '').localeCompare(b.name ?? '')
|
|
})
|
|
|
|
return (
|
|
<aside className="h-full min-h-0 flex flex-col rounded-md border border-neutral-700/60 bg-neutral-900/30 p-2 overflow-hidden">
|
|
{/* Header */}
|
|
<div className="flex items-center justify-between text-xs uppercase tracking-wide opacity-80">
|
|
<span className={`font-semibold flex items-center gap-2 ${teamColor}`}>
|
|
{/* Logo größer */}
|
|
<img src={logoSrc} alt={teamName} className="w-7 h-7 md:w-8 md:h-8 object-contain" />
|
|
<span className="hidden sm:inline">{teamName}</span>
|
|
</span>
|
|
|
|
<span className="flex items-center gap-2">
|
|
{/* Score-Pill in der Sidebar */}
|
|
{(typeof score === 'number' && typeof oppScore === 'number') && (
|
|
<span className="px-2 py-0.5 rounded bg-black/45 text-white text-[11px] font-semibold tabular-nums">
|
|
{score}<span className="opacity-60 mx-1">:</span>{oppScore}
|
|
</span>
|
|
)}
|
|
{/* Alive-Count bleibt */}
|
|
<span className="tabular-nums">{aliveCount}/{players.length}</span>
|
|
</span>
|
|
</div>
|
|
|
|
<div className="mt-2 flex-1 overflow-auto space-y-3 pr-1">
|
|
{sorted.map(p=>{
|
|
void avatarVer
|
|
const hp = clamp(p.alive === false ? 0 : p.hp ?? 100, 0, 100)
|
|
const armor = clamp(p.armor ?? 0, 0, 100)
|
|
const dead = p.alive === false || hp <= 0
|
|
const entry = avatarById[p.id] as any
|
|
const avatarUrl = isBotId(p.id)
|
|
? BOT_ICON
|
|
: (entry && !entry?.notFound && entry?.avatar ? entry.avatar : '/assets/img/avatars/default_steam_avatar.jpg')
|
|
|
|
// ---- Waffen split ----
|
|
const all = (p.weapons ?? []).filter(w => !GRENADE_SET.has(normWeaponName(w.name)))
|
|
const active = p.activeWeapon
|
|
const prim = all.find(w => PRIMARY_SET.has(normWeaponName(w.name)))
|
|
const sec = all.find(w => SECONDARY_SET.has(normWeaponName(w.name)))
|
|
const knife = all.find(w => {
|
|
const n = normWeaponName(w.name); return n === 'knife' || n === 'knife_t' || n === 'melee'
|
|
})
|
|
|
|
const primIcon = weaponIconFromName(prim?.name) ?? (prim ? equipIcon('melee.svg') : null)
|
|
const secIcon = weaponIconFromName(sec?.name) ?? (sec ? equipIcon('melee.svg') : null)
|
|
const knifeIcon = weaponIconFromName(knife?.name) ?? (knife ? equipIcon('knife.svg') : null)
|
|
|
|
const primActive = prim ? isActiveWeapon(prim.name, active, prim.state ) : false
|
|
const secActive = sec ? isActiveWeapon(sec.name, active, sec.state ) : false
|
|
const knifeActive = knife ? isActiveWeapon(knife.name, active, knife.state) : false
|
|
|
|
return (
|
|
<div
|
|
key={`player-${p.id}`}
|
|
id={`player-${p.id}`}
|
|
onMouseEnter={()=>onHoverPlayer?.(p.id)}
|
|
onMouseLeave={()=>onHoverPlayer?.(null)}
|
|
tabIndex={0}
|
|
className={`
|
|
rounded-md px-2 py-2 cursor-pointer outline-none
|
|
bg-white dark:bg-neutral-800
|
|
hover:bg-neutral-200 hover:dark:bg-neutral-700
|
|
focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-blue-500 focus-visible:ring-offset-white/10
|
|
${dead ? 'opacity-60' : ''}
|
|
`}
|
|
>
|
|
<div className={`flex ${isRight ? 'flex-row-reverse text-right' : 'flex-row'} items-center gap-3`}>
|
|
{/* Avatar mit Bomben-Glow / Dead-Desaturierung */}
|
|
<div className={`rounded-full ${p.hasBomb ? 'ring-2 ring-red-500/70 shadow-[0_0_12px_rgba(239,68,68,.35)]' : ''}`}>
|
|
<img
|
|
src={avatarUrl}
|
|
alt={p.name || p.id}
|
|
className={`w-12 h-12 rounded-full border border-white/10 ring-2 ${ringColor} bg-neutral-900 object-contain p-1 ${dead ? 'grayscale opacity-70' : ''}`}
|
|
width={48} height={48} loading="lazy"
|
|
/>
|
|
</div>
|
|
|
|
<div className={`flex-1 min-w-0 flex flex-col ${isRight ? 'items-end' : 'items-start'}`}>
|
|
{/* Kopfzeile: Name & Gear je Seite */}
|
|
{!isRight ? (
|
|
<div className="flex items-center justify-between w-full min-h-[22px] gap-2">
|
|
<span className={`truncate font-medium text-left tracking-wide [font-variant-numeric:tabular-nums]`}>
|
|
{p.name || p.id}
|
|
</span>
|
|
<span className="inline-flex items-center gap-1">
|
|
{leftGear({ armor: p.armor, helmet: p.helmet }).concat(
|
|
rightGear({ hasBomb: p.hasBomb, defuse: p.defuse, team })
|
|
).map(icon => (
|
|
icon.key === 'c4'
|
|
? <BombMaskIcon key={`G-${icon.key}`} src={icon.src} title={icon.title} className="h-5 w-5 opacity-90" />
|
|
: <img key={`G-${icon.key}`} src={icon.src} alt={icon.title} title={icon.title} className="h-5 w-5 opacity-90" />
|
|
))}
|
|
</span>
|
|
</div>
|
|
) : (
|
|
<div className="flex items-center justify-between w-full min-h-[22px] gap-2">
|
|
<span className="inline-flex items-center gap-1">
|
|
{leftGear({ armor: p.armor, helmet: p.helmet }).concat(
|
|
rightGear({ hasBomb: p.hasBomb, defuse: p.defuse, team })
|
|
).map(icon => (
|
|
icon.key === 'c4'
|
|
? <BombMaskIcon key={`G-${icon.key}`} src={icon.src} title={icon.title} className="h-5 w-5 opacity-90" />
|
|
: <img key={`G-${icon.key}`} src={icon.src} alt={icon.title} title={icon.title} className="h-5 w-5 opacity-90" />
|
|
))}
|
|
</span>
|
|
<span className={`truncate font-medium text-right tracking-wide [font-variant-numeric:tabular-nums]`}>
|
|
{p.name || p.id}
|
|
</span>
|
|
</div>
|
|
)}
|
|
|
|
{/* Waffenzeile: Primär (links/rechts je nach align) — Sekundär+Messer auf der Gegenseite */}
|
|
<div
|
|
className={[
|
|
'mt-1 w-full flex items-center',
|
|
primIcon && (secIcon || knifeIcon)
|
|
? (isRight ? 'flex-row-reverse justify-between' : 'justify-between')
|
|
: (isRight ? 'justify-end' : 'justify-start')
|
|
].join(' ')}
|
|
>
|
|
{/* Primär */}
|
|
{primIcon && (
|
|
<div className="flex items-center gap-3 shrink-0">
|
|
<img
|
|
src={primIcon}
|
|
alt={prim?.name ?? 'primary'}
|
|
title={prim?.name ?? 'primary'}
|
|
className={`h-16 w-16 transition filter ${
|
|
primActive
|
|
? 'grayscale-0 opacity-100 rounded-md border border-neutral-700/60 bg-neutral-900/30 p-2'
|
|
: 'grayscale brightness-90 contrast-75 opacity-90'
|
|
}`}
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{/* Sekundär + Messer (als Gruppe) */}
|
|
{(secIcon || knifeIcon) && (
|
|
<div
|
|
className={[
|
|
'flex items-center gap-2',
|
|
// Wenn keine Primärwaffe existiert, die Gruppe passend ausrichten
|
|
!primIcon ? (isRight ? 'justify-end' : 'justify-start') : ''
|
|
].join(' ')}
|
|
>
|
|
{secIcon && (
|
|
<img
|
|
src={secIcon}
|
|
alt={sec?.name ?? 'secondary'}
|
|
title={sec?.name ?? 'secondary'}
|
|
className={`h-10 w-10 transition filter ${
|
|
secActive ? 'grayscale-0 opacity-100 rounded-md border border-neutral-700/60 bg-neutral-900/30 p-2' : 'grayscale brightness-90 contrast-75 opacity-90'
|
|
}`}
|
|
/>
|
|
)}
|
|
{knifeIcon && (
|
|
<img
|
|
src={knifeIcon}
|
|
alt={knife?.name ?? 'knife'}
|
|
title={knife?.name ?? 'knife'}
|
|
className={`h-10 w-10 transition filter ${
|
|
knifeActive ? 'grayscale-0 opacity-100 rounded-md border border-neutral-700/60 bg-neutral-900/30 p-2' : 'grayscale brightness-90 contrast-75 opacity-90'
|
|
}`}
|
|
/>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Granaten: ohne Count; Icon mehrfach je Anzahl */}
|
|
<div className={`mt-2 flex items-center gap-1 ${isRight ? 'justify-start' : 'justify-end'}`}>
|
|
{GRENADE_DISPLAY_ORDER.flatMap(k=>{
|
|
const c = p.grenades?.[k] ?? 0
|
|
if (!c) return []
|
|
const src = grenadeIconFromKey(k)
|
|
return Array.from({ length: c }, (_,i)=>( // je Anzahl ein Icon
|
|
<img key={`${k}-${i}`} src={src} alt={k} title={k} className="h-4 w-4 opacity-90" />
|
|
))
|
|
})}
|
|
</div>
|
|
|
|
{/* HP / Armor Bars (SVG-Icons weiß) */}
|
|
<div className="mt-2 w-full space-y-2">
|
|
{/* HP */}
|
|
<div
|
|
className="relative h-4 rounded-md bg-neutral-800/80 ring-1 ring-black/40 overflow-hidden"
|
|
title={`HP: ${hp}`}
|
|
aria-label={`HP ${hp}`}
|
|
>
|
|
<div
|
|
className={[
|
|
// nur der Füllbalken bekommt ggf. das Blinken
|
|
'h-full transition-[width] duration-300 ease-out',
|
|
hp > 66 ? 'bg-green-500' : hp > 20 ? 'bg-amber-500' : 'bg-red-500',
|
|
hp > 0 && hp <= 20 ? 'animate-hpPulse' : ''
|
|
].join(' ')}
|
|
style={{ width: `${hp}%` }}
|
|
/>
|
|
{/* Ticks */}
|
|
<div className="pointer-events-none absolute inset-0 opacity-70 mix-blend-overlay bg-[repeating-linear-gradient(to_right,transparent,transparent_11px,rgba(255,255,255,0.06)_12px)]" />
|
|
{/* Label */}
|
|
<div className="absolute inset-0 flex items-center justify-between px-2 text-[11px] font-semibold text-white/95">
|
|
<span className="flex items-center gap-1 select-none"><HeartIcon /></span>
|
|
<span className="tabular-nums select-none drop-shadow-[0_1px_1px_rgba(0,0,0,0.5)]">{hp}</span>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Armor */}
|
|
<div
|
|
className="relative h-4 rounded-md bg-neutral-800/80 ring-1 ring-black/40 overflow-hidden"
|
|
title={`Kevlar: ${armor}%`}
|
|
aria-label={`Armor ${armor} Prozent`}
|
|
>
|
|
<div className={`h-full transition-[width] duration-300 ease-out ${barArmor}`} style={{ width: `${armor}%` }} />
|
|
<div className="pointer-events-none absolute inset-0 opacity-60 bg-[repeating-linear-gradient(45deg,rgba(255,255,255,0.08)_0_6px,transparent_6px_12px)]" />
|
|
<div className="absolute inset-0 flex items-center justify-between px-2 text-[10px] font-medium text-white/90">
|
|
<span className="flex items-center gap-1 select-none"><ShieldIcon /></span>
|
|
<span className="tabular-nums select-none drop-shadow-[0_1px_1px_rgba(0,0,0,0.45)]">{armor}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
})}
|
|
</div>
|
|
|
|
{/* Mini-Animation für Low-HP */}
|
|
<style jsx global>{`
|
|
@keyframes hpPulse {
|
|
0% { filter: brightness(1); }
|
|
50% { filter: brightness(1.25); }
|
|
100% { filter: brightness(1); }
|
|
}
|
|
.animate-hpPulse { animation: hpPulse 1s ease-in-out infinite; }
|
|
`}</style>
|
|
</aside>
|
|
)
|
|
}
|
|
|
|
function clamp(n: number, a: number, b: number) {
|
|
return Math.max(a, Math.min(b, n))
|
|
}
|