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 {
healed = 1
}
before := enemy.HP
enemy.HP += healed
if 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.

@ -1170,9 +1170,15 @@ func (e *Engine) processCombatTick(now time.Time) {
}
ProcessDebuffDamage(cs.Hero, tickDur, now)
ProcessEnemyRegen(&cs.Enemy, tickDur)
regenHealed := ProcessEnemyRegen(&cs.Enemy, tickDur)
ProcessSummonDamage(cs.Hero, &cs.Enemy, cs.StartedAt, 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) {
e.emitEvent(model.CombatEvent{

@ -92,6 +92,12 @@ type AttackPayload struct {
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.
type CombatEndPayload struct {
XPGained int64 `json:"xpGained"`

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

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

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

@ -6,6 +6,7 @@ import type {
RouteAssignedPayload,
CombatStartPayload,
AttackPayload,
EnemyRegenPayload,
CombatEndPayload,
HeroDiedPayload,
HeroRevivedPayload,
@ -113,7 +114,12 @@ export function wireWSHandler(
ws.on('attack', (msg: ServerMessage) => {
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) => {

@ -47,7 +47,7 @@ export const WS_HEARTBEAT_TIMEOUT_MS = 5000;
export const MAX_ACCUMULATED_MS = 250;
/** 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 */
export const DAMAGE_NUMBER_RISE_PX = 60;

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

Loading…
Cancel
Save