combat ui reworked

master
Denis Ranneft 1 month ago
parent dc5fc9b82e
commit 2d336bfdcd

@ -372,12 +372,16 @@ func ProcessEnemyRegen(enemy *model.Enemy, tickDuration time.Duration) int {
if healed < 1 { if healed < 1 {
healed = 1 healed = 1
} }
before := enemy.HP
enemy.HP += healed enemy.HP += healed
if enemy.HP > enemy.MaxHP { if enemy.HP > enemy.MaxHP {
enemy.HP = enemy.MaxHP enemy.HP = enemy.MaxHP
} }
return healed if enemy.HP <= before {
return 0
}
return enemy.HP - before
} }
// CheckDeath checks if the hero is dead and attempts resurrection if a buff is active. // CheckDeath checks if the hero is dead and attempts resurrection if a buff is active.

@ -1170,9 +1170,15 @@ func (e *Engine) processCombatTick(now time.Time) {
} }
ProcessDebuffDamage(cs.Hero, tickDur, now) ProcessDebuffDamage(cs.Hero, tickDur, now)
ProcessEnemyRegen(&cs.Enemy, tickDur) regenHealed := ProcessEnemyRegen(&cs.Enemy, tickDur)
ProcessSummonDamage(cs.Hero, &cs.Enemy, cs.StartedAt, cs.LastTickAt, now) ProcessSummonDamage(cs.Hero, &cs.Enemy, cs.StartedAt, cs.LastTickAt, now)
cs.LastTickAt = now cs.LastTickAt = now
if regenHealed > 0 && e.sender != nil {
e.sender.SendToHero(heroID, "enemy_regen", model.EnemyRegenPayload{
Amount: regenHealed,
EnemyHP: cs.Enemy.HP,
})
}
if CheckDeath(cs.Hero, now) { if CheckDeath(cs.Hero, now) {
e.emitEvent(model.CombatEvent{ e.emitEvent(model.CombatEvent{

@ -92,6 +92,12 @@ type AttackPayload struct {
DebuffApplied string `json:"debuffApplied,omitempty"` DebuffApplied string `json:"debuffApplied,omitempty"`
} }
// EnemyRegenPayload is sent when an enemy regenerates HP during combat.
type EnemyRegenPayload struct {
Amount int `json:"amount"`
EnemyHP int `json:"enemyHp"`
}
// CombatEndPayload is sent when the hero wins a fight. // CombatEndPayload is sent when the hero wins a fight.
type CombatEndPayload struct { type CombatEndPayload struct {
XPGained int64 `json:"xpGained"` XPGained int64 `json:"xpGained"`

@ -414,12 +414,14 @@ export function App() {
// Wire up damage events from the engine // Wire up damage events from the engine
engine.onDamage((dmg: FloatingDamageData) => { engine.onDamage((dmg: FloatingDamageData) => {
setDamages((prev) => [...prev, dmg]); setDamages((prev) => [...prev, dmg]);
if (dmg.kind === 'damage') {
if (dmg.isCrit) { if (dmg.isCrit) {
hapticImpact('heavy'); hapticImpact('heavy');
} else { } else {
hapticImpact('light'); hapticImpact('light');
} }
engine.camera.shake(dmg.isCrit ? 8 : 4, dmg.isCrit ? 250 : 150); engine.camera.shake(dmg.isCrit ? 8 : 4, dmg.isCrit ? 250 : 150);
}
}); });
engine.init(container).then(async () => { engine.init(container).then(async () => {

@ -4,6 +4,8 @@ import type {
EnemyState, EnemyState,
HeroState, HeroState,
FloatingDamageData, FloatingDamageData,
FloatingDamageKind,
FloatingDamageTarget,
LootDrop, LootDrop,
TownData, TownData,
NearbyHeroData, NearbyHeroData,
@ -420,6 +422,7 @@ export class GameEngine {
isCrit: boolean, isCrit: boolean,
heroHp: number, heroHp: number,
enemyHp: number, enemyHp: number,
outcome?: 'hit' | 'dodge' | 'block' | 'stun',
): void { ): void {
if (this._gameState.hero) { if (this._gameState.hero) {
this._gameState.hero.hp = heroHp; this._gameState.hero.hp = heroHp;
@ -430,28 +433,56 @@ export class GameEngine {
// Emit floating damage at appropriate screen position // Emit floating damage at appropriate screen position
const viewport = getViewport(); const viewport = getViewport();
if (source === 'hero') { const isBlocked = outcome === 'block';
// Damage on enemy (right side of screen) const isEvaded = outcome === 'dodge';
const defender: FloatingDamageTarget = source === 'enemy' ? 'hero' : 'enemy';
if (source === 'hero' || source === 'enemy') {
if (isBlocked || isEvaded) {
this._emitDamage( this._emitDamage(
damage, 0,
viewport.width / 2 + 60, defender === 'enemy' ? viewport.width / 2 + 60 : viewport.width / 2 - 60,
viewport.height / 2 - 30, viewport.height / 2 - 30,
isCrit, false,
isBlocked ? 'blocked' : 'evaded',
defender,
); );
} else if (source === 'enemy') { } else {
// Damage on hero (left side of screen)
this._emitDamage( this._emitDamage(
damage, damage,
viewport.width / 2 - 60, defender === 'enemy' ? viewport.width / 2 + 60 : viewport.width / 2 - 60,
viewport.height / 2 - 30, viewport.height / 2 - 30,
false, source === 'hero' ? isCrit : false,
'damage',
defender,
); );
} }
}
// potion source: no floating damage // potion source: no floating damage
this._notifyStateChange(); this._notifyStateChange();
} }
/**
* Called when server sends enemy_regen.
* Updates enemy HP and emits floating regen numbers.
*/
applyEnemyRegen(amount: number, enemyHp: number): void {
if (!this._gameState.enemy) return;
this._gameState.enemy.hp = enemyHp;
if (amount > 0) {
const viewport = getViewport();
this._emitDamage(
amount,
viewport.width / 2 + 60,
viewport.height / 2 - 30,
false,
'regen',
'enemy',
);
}
this._notifyStateChange();
}
/** /**
* Called when server sends combat_end. * Called when server sends combat_end.
* Transitions back to walking phase. * Transitions back to walking phase.
@ -914,6 +945,8 @@ export class GameEngine {
x: number, x: number,
y: number, y: number,
isCrit: boolean, isCrit: boolean,
kind: FloatingDamageKind,
target: FloatingDamageTarget,
): void { ): void {
if (!this._onDamage) return; if (!this._onDamage) return;
this._onDamage({ this._onDamage({
@ -923,6 +956,8 @@ export class GameEngine {
y, y,
isCrit, isCrit,
createdAt: performance.now(), createdAt: performance.now(),
kind,
target,
}); });
} }

@ -380,6 +380,9 @@ export interface NearbyHeroData {
// ---- Floating Damage ---- // ---- Floating Damage ----
export type FloatingDamageKind = 'damage' | 'blocked' | 'evaded' | 'regen';
export type FloatingDamageTarget = 'hero' | 'enemy';
export interface FloatingDamageData { export interface FloatingDamageData {
id: number; id: number;
value: number; value: number;
@ -387,6 +390,8 @@ export interface FloatingDamageData {
y: number; y: number;
isCrit: boolean; isCrit: boolean;
createdAt: number; createdAt: number;
kind: FloatingDamageKind;
target: FloatingDamageTarget;
} }
// ---- Server -> Client Message Payloads ---- // ---- Server -> Client Message Payloads ----
@ -398,6 +403,7 @@ export type ServerMessageType =
| 'route_assigned' | 'route_assigned'
| 'combat_start' | 'combat_start'
| 'attack' | 'attack'
| 'enemy_regen'
| 'combat_end' | 'combat_end'
| 'hero_died' | 'hero_died'
| 'hero_revived' | 'hero_revived'
@ -464,6 +470,11 @@ export interface AttackPayload {
debuffApplied?: string; debuffApplied?: string;
} }
export interface EnemyRegenPayload {
amount: number;
enemyHp: number;
}
export interface CombatEndPayload { export interface CombatEndPayload {
xpGained: number; xpGained: number;
goldGained: number; goldGained: number;

@ -6,6 +6,7 @@ import type {
RouteAssignedPayload, RouteAssignedPayload,
CombatStartPayload, CombatStartPayload,
AttackPayload, AttackPayload,
EnemyRegenPayload,
CombatEndPayload, CombatEndPayload,
HeroDiedPayload, HeroDiedPayload,
HeroRevivedPayload, HeroRevivedPayload,
@ -113,7 +114,12 @@ export function wireWSHandler(
ws.on('attack', (msg: ServerMessage) => { ws.on('attack', (msg: ServerMessage) => {
const p = msg.payload as AttackPayload; const p = msg.payload as AttackPayload;
engine.applyAttack(p.source, p.damage, p.isCrit, p.heroHp, p.enemyHp); engine.applyAttack(p.source, p.damage, p.isCrit, p.heroHp, p.enemyHp, p.outcome);
});
ws.on('enemy_regen', (msg: ServerMessage) => {
const p = msg.payload as EnemyRegenPayload;
engine.applyEnemyRegen(p.amount, p.enemyHp);
}); });
ws.on('debuff_applied', (msg: ServerMessage) => { ws.on('debuff_applied', (msg: ServerMessage) => {

@ -47,7 +47,7 @@ export const WS_HEARTBEAT_TIMEOUT_MS = 5000;
export const MAX_ACCUMULATED_MS = 250; export const MAX_ACCUMULATED_MS = 250;
/** Floating damage number duration in milliseconds */ /** Floating damage number duration in milliseconds */
export const DAMAGE_NUMBER_DURATION_MS = 1200; export const DAMAGE_NUMBER_DURATION_MS = 1800;
/** Floating damage rise distance in pixels */ /** Floating damage rise distance in pixels */
export const DAMAGE_NUMBER_RISE_PX = 60; export const DAMAGE_NUMBER_RISE_PX = 60;

@ -36,7 +36,14 @@ function DamageNumber({ data, onExpire }: DamageNumberProps) {
const offsetY = -progress * DAMAGE_NUMBER_RISE_PX; const offsetY = -progress * DAMAGE_NUMBER_RISE_PX;
const opacity = 1 - progress * progress; // ease-out fade const opacity = 1 - progress * progress; // ease-out fade
const scale = data.isCrit ? 1.4 - progress * 0.4 : 1; const scale = data.isCrit && data.kind === 'damage' ? 1.4 - progress * 0.4 : 1;
const isOutcomeText = data.kind === 'blocked' || data.kind === 'evaded';
const color = data.kind === 'regen'
? '#44dd66'
: isOutcomeText
? (data.target === 'hero' ? '#44dd66' : '#ff5566')
: (data.isCrit ? '#ffdd44' : '#ffffff');
const fontSize = isOutcomeText ? 16 : (data.isCrit ? 24 : 18);
const style: CSSProperties = { const style: CSSProperties = {
position: 'absolute', position: 'absolute',
@ -44,8 +51,8 @@ function DamageNumber({ data, onExpire }: DamageNumberProps) {
top: data.y + offsetY, top: data.y + offsetY,
transform: `translate(-50%, -50%) scale(${scale})`, transform: `translate(-50%, -50%) scale(${scale})`,
opacity, opacity,
color: data.isCrit ? '#ffdd44' : '#ffffff', color,
fontSize: data.isCrit ? 24 : 18, fontSize,
fontWeight: 900, fontWeight: 900,
textShadow: '0 2px 4px rgba(0,0,0,0.9), 0 0 8px rgba(0,0,0,0.5)', textShadow: '0 2px 4px rgba(0,0,0,0.9), 0 0 8px rgba(0,0,0,0.5)',
pointerEvents: 'none', pointerEvents: 'none',
@ -54,8 +61,15 @@ function DamageNumber({ data, onExpire }: DamageNumberProps) {
return ( return (
<div style={style}> <div style={style}>
{data.kind === 'damage' && (
<>
{data.isCrit && 'CRIT '} {data.isCrit && 'CRIT '}
{Math.round(data.value)} {Math.round(data.value)}
</>
)}
{data.kind === 'regen' && `+${Math.round(data.value)}`}
{data.kind === 'blocked' && 'BLOCKED'}
{data.kind === 'evaded' && 'EVADED'}
</div> </div>
); );
} }

Loading…
Cancel
Save