88 HEX_DIRECTIONS ,
99 hexVecLength ,
1010} from '../shared/hex' ;
11- import type { GameState , Ship , ShipMovement , OrdnanceMovement , SolarSystemMap , CelestialBody , CombatResult } from '../shared/types' ;
11+ import type { GameState , Ship , ShipMovement , OrdnanceMovement , MovementEvent , SolarSystemMap , CelestialBody , CombatResult } from '../shared/types' ;
1212import { MOVEMENT_ANIM_DURATION , CAMERA_LERP_SPEED , SHIP_STATS } from '../shared/constants' ;
1313import { computeCourse , predictDestination } from '../shared/movement' ;
1414import { computeOdds , computeRangeMod , computeVelocityMod , getCombatStrength , canAttack } from '../shared/combat' ;
@@ -128,6 +128,17 @@ export interface AnimationState {
128128 onComplete : ( ) => void ;
129129}
130130
131+ // --- Combat visual effects ---
132+
133+ interface CombatEffect {
134+ type : 'beam' | 'explosion' ;
135+ from : PixelCoord ;
136+ to : PixelCoord ;
137+ startTime : number ;
138+ duration : number ;
139+ color : string ;
140+ }
141+
131142// --- Planning state (controlled by input handler) ---
132143
133144export interface PlanningState {
@@ -154,6 +165,8 @@ export class Renderer {
154165 private animState : AnimationState | null = null ;
155166 planningState : PlanningState = { selectedShipId : null , burns : new Map ( ) , overloads : new Map ( ) , weakGravityChoices : new Map ( ) , torpedoAccel : null , combatTargetId : null } ;
156167 private combatResults : { results : CombatResult [ ] ; showUntil : number } | null = null ;
168+ private combatEffects : CombatEffect [ ] = [ ] ;
169+ private movementEvents : { events : MovementEvent [ ] ; showUntil : number } | null = null ;
157170 private lastTime = 0 ;
158171
159172 constructor ( canvas : HTMLCanvasElement ) {
@@ -202,7 +215,73 @@ export class Renderer {
202215 }
203216
204217 showCombatResults ( results : CombatResult [ ] ) {
205- this . combatResults = { results, showUntil : performance . now ( ) + 3000 } ;
218+ const now = performance . now ( ) ;
219+ this . combatResults = { results, showUntil : now + 3000 } ;
220+
221+ // Create visual effects for each combat result
222+ for ( const r of results ) {
223+ const target = this . gameState ?. ships . find ( s => s . id === r . targetId ) ;
224+ if ( ! target ) continue ;
225+ const targetPos = hexToPixel ( target . position , HEX_SIZE ) ;
226+
227+ // Beam from first attacker to target
228+ if ( r . attackerIds . length > 0 && ! r . attackerIds [ 0 ] . startsWith ( 'base:' ) ) {
229+ const attacker = this . gameState ?. ships . find ( s => s . id === r . attackerIds [ 0 ] ) ;
230+ if ( attacker ) {
231+ const attackerPos = hexToPixel ( attacker . position , HEX_SIZE ) ;
232+ this . combatEffects . push ( {
233+ type : 'beam' ,
234+ from : attackerPos ,
235+ to : targetPos ,
236+ startTime : now ,
237+ duration : 600 ,
238+ color : r . damageType === 'eliminated' ? '#ff4444' : r . damageType === 'disabled' ? '#ffaa00' : '#4fc3f7' ,
239+ } ) ;
240+ }
241+ }
242+
243+ // Explosion at target for damage
244+ if ( r . damageType !== 'none' ) {
245+ this . combatEffects . push ( {
246+ type : 'explosion' ,
247+ from : targetPos ,
248+ to : targetPos ,
249+ startTime : now + 300 , // Delay for beam to reach
250+ duration : 800 ,
251+ color : r . damageType === 'eliminated' ? '#ff4444' : '#ffaa00' ,
252+ } ) ;
253+ }
254+
255+ // Same for counterattack
256+ if ( r . counterattack && r . counterattack . damageType !== 'none' ) {
257+ const counterTarget = this . gameState ?. ships . find ( s => s . id === r . counterattack ! . targetId ) ;
258+ if ( counterTarget ) {
259+ const counterPos = hexToPixel ( counterTarget . position , HEX_SIZE ) ;
260+ this . combatEffects . push ( {
261+ type : 'beam' ,
262+ from : targetPos ,
263+ to : counterPos ,
264+ startTime : now + 500 ,
265+ duration : 600 ,
266+ color : r . counterattack . damageType === 'eliminated' ? '#ff4444' : '#ffaa00' ,
267+ } ) ;
268+ this . combatEffects . push ( {
269+ type : 'explosion' ,
270+ from : counterPos ,
271+ to : counterPos ,
272+ startTime : now + 800 ,
273+ duration : 800 ,
274+ color : r . counterattack . damageType === 'eliminated' ? '#ff4444' : '#ffaa00' ,
275+ } ) ;
276+ }
277+ }
278+ }
279+ }
280+
281+ showMovementEvents ( events : MovementEvent [ ] ) {
282+ if ( events . length > 0 ) {
283+ this . movementEvents = { events, showUntil : performance . now ( ) + 4000 } ;
284+ }
206285 }
207286
208287 isAnimating ( ) : boolean {
@@ -292,6 +371,7 @@ export class Renderer {
292371 this . renderTorpedoGuidance ( ctx , this . gameState , now ) ;
293372 this . renderCombatOverlay ( ctx , this . gameState , now ) ;
294373 this . renderShips ( ctx , this . gameState , now ) ;
374+ this . renderCombatEffects ( ctx , now ) ;
295375 }
296376
297377 ctx . restore ( ) ;
@@ -304,6 +384,15 @@ export class Renderer {
304384 this . renderCombatResultsToast ( ctx , this . combatResults . results , now , w ) ;
305385 }
306386 }
387+
388+ // Movement events toast (screen-space)
389+ if ( this . movementEvents && this . gameState ) {
390+ if ( now > this . movementEvents . showUntil ) {
391+ this . movementEvents = null ;
392+ } else {
393+ this . renderMovementEventsToast ( ctx , this . movementEvents . events , now , w ) ;
394+ }
395+ }
307396 }
308397
309398 // --- Render layers ---
@@ -942,6 +1031,122 @@ export class Renderer {
9421031 }
9431032 }
9441033
1034+ private renderCombatEffects ( ctx : CanvasRenderingContext2D , now : number ) {
1035+ // Clean up expired effects
1036+ this . combatEffects = this . combatEffects . filter ( e => now < e . startTime + e . duration ) ;
1037+
1038+ for ( const effect of this . combatEffects ) {
1039+ if ( now < effect . startTime ) continue ; // Not yet started
1040+ const progress = ( now - effect . startTime ) / effect . duration ;
1041+
1042+ if ( effect . type === 'beam' ) {
1043+ // Beam line from attacker to target
1044+ const beamAlpha = 1 - progress ;
1045+ const beamProgress = Math . min ( progress * 3 , 1 ) ; // Beam reaches target at 1/3 duration
1046+
1047+ ctx . strokeStyle = effect . color ;
1048+ ctx . globalAlpha = beamAlpha * 0.8 ;
1049+ ctx . lineWidth = 2 * ( 1 - progress ) ;
1050+ ctx . beginPath ( ) ;
1051+ ctx . moveTo ( effect . from . x , effect . from . y ) ;
1052+ ctx . lineTo (
1053+ effect . from . x + ( effect . to . x - effect . from . x ) * beamProgress ,
1054+ effect . from . y + ( effect . to . y - effect . from . y ) * beamProgress ,
1055+ ) ;
1056+ ctx . stroke ( ) ;
1057+
1058+ // Glow line
1059+ ctx . globalAlpha = beamAlpha * 0.3 ;
1060+ ctx . lineWidth = 6 * ( 1 - progress ) ;
1061+ ctx . beginPath ( ) ;
1062+ ctx . moveTo ( effect . from . x , effect . from . y ) ;
1063+ ctx . lineTo (
1064+ effect . from . x + ( effect . to . x - effect . from . x ) * beamProgress ,
1065+ effect . from . y + ( effect . to . y - effect . from . y ) * beamProgress ,
1066+ ) ;
1067+ ctx . stroke ( ) ;
1068+ ctx . globalAlpha = 1 ;
1069+ } else if ( effect . type === 'explosion' ) {
1070+ // Expanding ring explosion
1071+ const maxRadius = 20 ;
1072+ const radius = maxRadius * progress ;
1073+ const alpha = 1 - progress ;
1074+
1075+ ctx . strokeStyle = effect . color ;
1076+ ctx . lineWidth = 3 * ( 1 - progress ) ;
1077+ ctx . globalAlpha = alpha * 0.8 ;
1078+ ctx . beginPath ( ) ;
1079+ ctx . arc ( effect . from . x , effect . from . y , radius , 0 , Math . PI * 2 ) ;
1080+ ctx . stroke ( ) ;
1081+
1082+ // Inner flash
1083+ if ( progress < 0.3 ) {
1084+ ctx . fillStyle = effect . color ;
1085+ ctx . globalAlpha = ( 1 - progress / 0.3 ) * 0.6 ;
1086+ ctx . beginPath ( ) ;
1087+ ctx . arc ( effect . from . x , effect . from . y , radius * 0.5 , 0 , Math . PI * 2 ) ;
1088+ ctx . fill ( ) ;
1089+ }
1090+ ctx . globalAlpha = 1 ;
1091+ }
1092+ }
1093+ }
1094+
1095+ private renderMovementEventsToast ( ctx : CanvasRenderingContext2D , events : MovementEvent [ ] , now : number , screenW : number ) {
1096+ if ( events . length === 0 ) return ;
1097+ const fadeStart = this . movementEvents ! . showUntil - 1000 ;
1098+ const alpha = now > fadeStart ? Math . max ( 0 , ( this . movementEvents ! . showUntil - now ) / 1000 ) : 1 ;
1099+
1100+ ctx . save ( ) ;
1101+ ctx . globalAlpha = alpha ;
1102+
1103+ let y = 60 ;
1104+ for ( const ev of events ) {
1105+ const ship = this . gameState ?. ships . find ( s => s . id === ev . shipId ) ;
1106+ const shipName = ship ? ship . type : ev . shipId ;
1107+ let text : string ;
1108+ let color : string ;
1109+
1110+ switch ( ev . type ) {
1111+ case 'crash' :
1112+ text = `${ shipName } : CRASHED` ;
1113+ color = '#ff4444' ;
1114+ break ;
1115+ case 'asteroidHit' :
1116+ text = `${ shipName } : Asteroid hit [${ ev . dieRoll } ] — ${ ev . damageType === 'eliminated' ? 'ELIMINATED' : ev . damageType === 'disabled' ? `DISABLED ${ ev . disabledTurns } T` : 'MISS' } ` ;
1117+ color = ev . damageType === 'eliminated' ? '#ff4444' : ev . damageType === 'disabled' ? '#ffaa00' : '#88ff88' ;
1118+ break ;
1119+ case 'mineDetonation' :
1120+ text = `Mine hit ${ shipName } [${ ev . dieRoll } ] — ${ ev . damageType === 'eliminated' ? 'ELIMINATED' : ev . damageType === 'disabled' ? `DISABLED ${ ev . disabledTurns } T` : 'NO EFFECT' } ` ;
1121+ color = ev . damageType === 'eliminated' ? '#ff4444' : ev . damageType === 'disabled' ? '#ffaa00' : '#88ff88' ;
1122+ break ;
1123+ case 'torpedoHit' :
1124+ text = `Torpedo hit ${ shipName } [${ ev . dieRoll } ] — ${ ev . damageType === 'eliminated' ? 'ELIMINATED' : ev . damageType === 'disabled' ? `DISABLED ${ ev . disabledTurns } T` : 'NO EFFECT' } ` ;
1125+ color = ev . damageType === 'eliminated' ? '#ff4444' : ev . damageType === 'disabled' ? '#ffaa00' : '#88ff88' ;
1126+ break ;
1127+ case 'nukeDetonation' :
1128+ text = `NUKE hit ${ shipName } [${ ev . dieRoll } ] — ${ ev . damageType === 'eliminated' ? 'ELIMINATED' : ev . damageType === 'disabled' ? `DISABLED ${ ev . disabledTurns } T` : 'NO EFFECT' } ` ;
1129+ color = ev . damageType === 'eliminated' ? '#ff4444' : ev . damageType === 'disabled' ? '#ffaa00' : '#88ff88' ;
1130+ break ;
1131+ default :
1132+ continue ;
1133+ }
1134+
1135+ ctx . font = 'bold 12px monospace' ;
1136+ const w = ctx . measureText ( text ) . width ;
1137+ const x = screenW / 2 ;
1138+
1139+ ctx . fillStyle = 'rgba(0, 0, 0, 0.75)' ;
1140+ ctx . fillRect ( x - w / 2 - 8 , y - 12 , w + 16 , 20 ) ;
1141+ ctx . fillStyle = color ;
1142+ ctx . textAlign = 'center' ;
1143+ ctx . fillText ( text , x , y + 2 ) ;
1144+ y += 26 ;
1145+ }
1146+
1147+ ctx . restore ( ) ;
1148+ }
1149+
9451150 private renderCombatResultsToast ( ctx : CanvasRenderingContext2D , results : CombatResult [ ] , now : number , screenW : number ) {
9461151 if ( results . length === 0 ) return ;
9471152 const fadeStart = this . combatResults ! . showUntil - 1000 ;
0 commit comments