diff --git a/backend/internal/game/engine.go b/backend/internal/game/engine.go index a417c98..9808769 100644 --- a/backend/internal/game/engine.go +++ b/backend/internal/game/engine.go @@ -1238,6 +1238,18 @@ func (e *Engine) processHeroAttack(cs *model.CombatState, now time.Time) { EnemyHP: combatEvt.EnemyHP, DebuffApplied: combatEvt.DebuffApplied, }) + if combatEvt.DebuffApplied != "" { + if dt, ok := model.ValidDebuffType(combatEvt.DebuffApplied); ok { + if def, ok := model.DebuffDefinition(dt); ok { + e.sender.SendToHero(cs.HeroID, "debuff_applied", model.DebuffAppliedPayload{ + DebuffType: string(dt), + DurationMs: def.Duration.Milliseconds(), + Magnitude: def.Magnitude, + ExpiresAt: now.Add(def.Duration), + }) + } + } + } } if !cs.Enemy.IsAlive() { diff --git a/backend/internal/model/ws_message.go b/backend/internal/model/ws_message.go index f9beb69..e745736 100644 --- a/backend/internal/model/ws_message.go +++ b/backend/internal/model/ws_message.go @@ -1,6 +1,9 @@ package model -import "encoding/json" +import ( + "encoding/json" + "time" +) // WSEnvelope is the wire format for all WebSocket messages (both directions). type WSEnvelope struct { @@ -200,6 +203,14 @@ type BuffAppliedPayload struct { Magnitude float64 `json:"magnitude"` } +// DebuffAppliedPayload is sent when a debuff is applied to the hero. +type DebuffAppliedPayload struct { + DebuffType string `json:"debuffType"` + DurationMs int64 `json:"durationMs"` + Magnitude float64 `json:"magnitude,omitempty"` + ExpiresAt time.Time `json:"expiresAt"` +} + // ErrorPayload is sent when a client command fails validation. type ErrorPayload struct { Code string `json:"code"` diff --git a/frontend/src/game/engine.ts b/frontend/src/game/engine.ts index 64a7e88..e79d60d 100644 --- a/frontend/src/game/engine.ts +++ b/frontend/src/game/engine.ts @@ -9,6 +9,8 @@ import type { NearbyHeroData, NPCData, BuildingData, + ActiveDebuff, + DebuffType, } from './types'; import { GamePhase } from './types'; import { GameRenderer, worldToScreen } from './renderer'; @@ -620,6 +622,38 @@ export class GameEngine { this._notifyStateChange(); } + /** Apply or refresh a single debuff from WS. */ + applyDebuffApplied(type: DebuffType, durationMs: number, expiresAtMs?: number): void { + const hero = this._gameState.hero; + if (!hero) return; + const nowMs = Date.now(); + const expMs = Number.isFinite(expiresAtMs) + ? (expiresAtMs as number) + : nowMs + Math.max(0, durationMs); + const remainingMs = Math.max(0, expMs - nowMs); + + const next: ActiveDebuff = { + type, + remainingMs, + durationMs: Math.max(0, durationMs), + expiresAtMs: expMs, + }; + + const debuffs = hero.debuffs + .filter((d) => d.type !== type) + .filter((d) => { + const exp = d.expiresAtMs ?? nowMs + d.remainingMs; + return exp > nowMs && d.remainingMs > 0; + }); + + debuffs.push(next); + this._gameState = { + ...this._gameState, + hero: { ...hero, debuffs }, + }; + this._notifyStateChange(); + } + /** Apply a full server state override (used for backward compat). */ applyServerState(state: GameState): void { this._gameState = { diff --git a/frontend/src/game/types.ts b/frontend/src/game/types.ts index cea8d99..2ad4567 100644 --- a/frontend/src/game/types.ts +++ b/frontend/src/game/types.ts @@ -30,6 +30,7 @@ export enum DebuffType { Stun = 'stun', // no attacks (2 sec) Slow = 'slow', // -40% movement Weaken = 'weaken', // -30% outgoing damage + IceSlow = 'ice_slow', // -20% attack speed (Ice Guardian) } export interface BuffChargeState { @@ -494,6 +495,13 @@ export interface BuffAppliedPayload { magnitude?: number; } +export interface DebuffAppliedPayload { + debuffType: string; + durationMs?: number; + magnitude?: number; + expiresAt?: string; +} + export interface TownEnterPayload { townId: number; townName: string; diff --git a/frontend/src/game/ws-handler.ts b/frontend/src/game/ws-handler.ts index d9d0b67..c0ba8be 100644 --- a/frontend/src/game/ws-handler.ts +++ b/frontend/src/game/ws-handler.ts @@ -25,8 +25,10 @@ import type { EnemyState, LootDrop, MerchantLootPayload, + DebuffAppliedPayload, } from './types'; -import { EnemyType, Rarity } from './types'; +import { DebuffType, EnemyType, Rarity } from './types'; +import { DEBUFF_DURATION_DEFAULTS } from '../shared/constants'; // ---- Callback types for UI layer (App.tsx) ---- @@ -64,6 +66,9 @@ export function wireWSHandler( engine: GameEngine, callbacks: WSHandlerCallbacks, ): void { + const isDebuffType = (value: string): value is DebuffType => ( + Object.values(DebuffType).includes(value as DebuffType) + ); // ---- Server -> Client: Movement ---- ws.on('hero_move', (msg: ServerMessage) => { @@ -111,6 +116,16 @@ export function wireWSHandler( engine.applyAttack(p.source, p.damage, p.isCrit, p.heroHp, p.enemyHp); }); + ws.on('debuff_applied', (msg: ServerMessage) => { + const p = msg.payload as DebuffAppliedPayload; + if (!p?.debuffType || !isDebuffType(p.debuffType)) return; + const nowMs = Date.now(); + const fallbackMs = DEBUFF_DURATION_DEFAULTS[p.debuffType] ?? 0; + const durationMs = Number.isFinite(p.durationMs) ? Math.max(0, p.durationMs as number) : fallbackMs; + const expiresAtMs = p.expiresAt ? Date.parse(p.expiresAt) : nowMs + durationMs; + engine.applyDebuffApplied(p.debuffType, durationMs, expiresAtMs); + }); + ws.on('combat_end', (msg: ServerMessage) => { const p = msg.payload as CombatEndPayload; engine.applyCombatEnd(); diff --git a/frontend/src/i18n/en.ts b/frontend/src/i18n/en.ts index b8d2282..9d12afc 100644 --- a/frontend/src/i18n/en.ts +++ b/frontend/src/i18n/en.ts @@ -87,6 +87,7 @@ export const en = { debuffStun: 'Stun', debuffSlow: 'Slow', debuffWeaken: 'Weaken', + debuffIceSlow: 'Ice Slow', // Quest system questLog: 'Quest Log', diff --git a/frontend/src/i18n/ru.ts b/frontend/src/i18n/ru.ts index 8a847d3..bb351a2 100644 --- a/frontend/src/i18n/ru.ts +++ b/frontend/src/i18n/ru.ts @@ -89,6 +89,7 @@ export const ru: Translations = { debuffStun: '\u041e\u0433\u043b\u0443\u0448\u0435\u043d\u0438\u0435', debuffSlow: '\u0417\u0430\u043c\u0435\u0434\u043b\u0435\u043d\u0438\u0435', debuffWeaken: '\u041e\u0441\u043b\u0430\u0431\u043b\u0435\u043d\u0438\u0435', + debuffIceSlow: '\u041b\u0435\u0434\u044f\u043d\u043e\u0435 \u0437\u0430\u043c\u0435\u0434\u043b\u0435\u043d\u0438\u0435', // Quest system questLog: '\u0416\u0443\u0440\u043d\u0430\u043b \u0437\u0430\u0434\u0430\u043d\u0438\u0439', diff --git a/frontend/src/shared/constants.ts b/frontend/src/shared/constants.ts index 4b6a9c0..0e5454f 100644 --- a/frontend/src/shared/constants.ts +++ b/frontend/src/shared/constants.ts @@ -96,6 +96,7 @@ export const DEBUFF_COLORS: Record = { stun: '#ffdd44', slow: '#4488ff', weaken: '#aa44dd', + ice_slow: '#66aaff', }; // ---- Debuff Default Durations (ms) ---- @@ -107,6 +108,7 @@ export const DEBUFF_DURATION_DEFAULTS: Record = { stun: 2000, slow: 4000, weaken: 5000, + ice_slow: 4000, }; /** Loot popup display duration in milliseconds */ diff --git a/frontend/src/ui/DebuffBar.tsx b/frontend/src/ui/DebuffBar.tsx index 024a7cb..89f63f1 100644 --- a/frontend/src/ui/DebuffBar.tsx +++ b/frontend/src/ui/DebuffBar.tsx @@ -11,6 +11,7 @@ const DEBUFF_META: Record = { [DebuffType.Stun]: { icon: '\uD83D\uDCAB', label: 'Stun' }, [DebuffType.Slow]: { icon: '\uD83D\uDC22', label: 'Slow' }, [DebuffType.Weaken]: { icon: '\uD83D\uDCC9', label: 'Weaken' }, + [DebuffType.IceSlow]: { icon: '\u2744\uFE0F', label: 'Ice Slow' }, }; // ---- Types ---- diff --git a/frontend/src/ui/HeroPanel.tsx b/frontend/src/ui/HeroPanel.tsx index ab81f3b..229cac2 100644 --- a/frontend/src/ui/HeroPanel.tsx +++ b/frontend/src/ui/HeroPanel.tsx @@ -27,6 +27,7 @@ function debuffLabel(tr: Translations, type: DebuffType): string { [DebuffType.Stun]: tr.debuffStun, [DebuffType.Slow]: tr.debuffSlow, [DebuffType.Weaken]: tr.debuffWeaken, + [DebuffType.IceSlow]: tr.debuffIceSlow, }; return map[type] ?? type; } @@ -130,7 +131,8 @@ export function HeroStatsContent({ hero, nowMs }: HeroPanelProps) { || hasActiveBuff(hero.activeBuffs, BuffType.PowerPotion, nowMs); const atkNerfed = hasActiveDebuff(hero.debuffs, DebuffType.Weaken, nowMs); const spdBuffed = hasActiveBuff(hero.activeBuffs, BuffType.WarCry, nowMs); - const spdNerfed = hasActiveDebuff(hero.debuffs, DebuffType.Freeze, nowMs); + const spdNerfed = hasActiveDebuff(hero.debuffs, DebuffType.Freeze, nowMs) + || hasActiveDebuff(hero.debuffs, DebuffType.IceSlow, nowMs); const defBuffed = hasActiveBuff(hero.activeBuffs, BuffType.Shield, nowMs); return (