@@ -8,9 +8,10 @@ import {
88 HEX_DIRECTIONS ,
99 hexVecLength ,
1010} from '../shared/hex' ;
11- import type { GameState , Ship , ShipMovement , SolarSystemMap , CelestialBody } from '../shared/types' ;
11+ import type { GameState , Ship , ShipMovement , SolarSystemMap , CelestialBody , CombatResult } from '../shared/types' ;
1212import { MOVEMENT_ANIM_DURATION , CAMERA_LERP_SPEED } from '../shared/constants' ;
1313import { computeCourse , predictDestination } from '../shared/movement' ;
14+ import { computeOdds , computeRangeMod , computeVelocityMod , getCombatStrength , canAttack } from '../shared/combat' ;
1415
1516// --- Camera ---
1617
@@ -131,6 +132,7 @@ export interface AnimationState {
131132export interface PlanningState {
132133 selectedShipId : string | null ;
133134 burns : Map < string , number | null > ; // shipId -> burn direction (or null for no burn)
135+ combatTargetId : string | null ; // enemy ship targeted for combat
134136}
135137
136138// --- Renderer ---
@@ -146,7 +148,8 @@ export class Renderer {
146148 private gameState : GameState | null = null ;
147149 private playerId = - 1 ;
148150 private animState : AnimationState | null = null ;
149- planningState : PlanningState = { selectedShipId : null , burns : new Map ( ) } ;
151+ planningState : PlanningState = { selectedShipId : null , burns : new Map ( ) , combatTargetId : null } ;
152+ private combatResults : { results : CombatResult [ ] ; showUntil : number } | null = null ;
150153 private lastTime = 0 ;
151154
152155 constructor ( canvas : HTMLCanvasElement ) {
@@ -192,6 +195,10 @@ export class Renderer {
192195 }
193196 }
194197
198+ showCombatResults ( results : CombatResult [ ] ) {
199+ this . combatResults = { results, showUntil : performance . now ( ) + 3000 } ;
200+ }
201+
195202 isAnimating ( ) : boolean {
196203 return this . animState !== null ;
197204 }
@@ -269,10 +276,20 @@ export class Renderer {
269276 }
270277 if ( this . gameState && this . map ) {
271278 this . renderCourseVectors ( ctx , this . gameState , this . map , now ) ;
279+ this . renderCombatOverlay ( ctx , this . gameState , now ) ;
272280 this . renderShips ( ctx , this . gameState , now ) ;
273281 }
274282
275283 ctx . restore ( ) ;
284+
285+ // Combat results toast (screen-space)
286+ if ( this . combatResults && this . gameState ) {
287+ if ( now > this . combatResults . showUntil ) {
288+ this . combatResults = null ;
289+ } else {
290+ this . renderCombatResultsToast ( ctx , this . combatResults . results , now , w ) ;
291+ }
292+ }
276293 }
277294
278295 // --- Render layers ---
@@ -586,6 +603,120 @@ export class Renderer {
586603 y : from . y + ( to . y - from . y ) * segT ,
587604 } ;
588605 }
606+ private renderCombatOverlay ( ctx : CanvasRenderingContext2D , state : GameState , now : number ) {
607+ if ( state . phase !== 'combat' || state . activePlayer !== this . playerId ) return ;
608+ if ( this . animState ) return ;
609+
610+ const targetId = this . planningState . combatTargetId ;
611+ const target = targetId ? state . ships . find ( s => s . id === targetId ) : null ;
612+
613+ // Highlight valid enemy targets
614+ for ( const ship of state . ships ) {
615+ if ( ship . owner === this . playerId || ship . destroyed ) continue ;
616+ const p = hexToPixel ( ship . position , HEX_SIZE ) ;
617+ const isTarget = ship . id === targetId ;
618+
619+ // Pulsing ring on enemies
620+ const pulse = 0.5 + 0.3 * Math . sin ( now / 300 ) ;
621+ ctx . strokeStyle = isTarget
622+ ? `rgba(255, 80, 80, ${ 0.8 + pulse * 0.2 } )`
623+ : `rgba(255, 80, 80, ${ 0.2 + pulse * 0.15 } )` ;
624+ ctx . lineWidth = isTarget ? 2.5 : 1.5 ;
625+ ctx . beginPath ( ) ;
626+ ctx . arc ( p . x , p . y , isTarget ? 16 : 13 , 0 , Math . PI * 2 ) ;
627+ ctx . stroke ( ) ;
628+ }
629+
630+ // Draw attack line and odds preview
631+ if ( target && ! target . destroyed ) {
632+ const myAttackers = state . ships . filter (
633+ s => s . owner === this . playerId && ! s . destroyed && canAttack ( s ) ,
634+ ) ;
635+ if ( myAttackers . length === 0 ) return ;
636+
637+ const targetPos = hexToPixel ( target . position , HEX_SIZE ) ;
638+
639+ // Attack lines from each attacker
640+ for ( const attacker of myAttackers ) {
641+ const attackerPos = hexToPixel ( attacker . position , HEX_SIZE ) ;
642+ ctx . strokeStyle = 'rgba(255, 80, 80, 0.4)' ;
643+ ctx . lineWidth = 1.5 ;
644+ ctx . setLineDash ( [ 6 , 4 ] ) ;
645+ ctx . beginPath ( ) ;
646+ ctx . moveTo ( attackerPos . x , attackerPos . y ) ;
647+ ctx . lineTo ( targetPos . x , targetPos . y ) ;
648+ ctx . stroke ( ) ;
649+ ctx . setLineDash ( [ ] ) ;
650+ }
651+
652+ // Odds preview at target
653+ const attackStr = getCombatStrength ( myAttackers ) ;
654+ const defendStr = getCombatStrength ( [ target ] ) ;
655+ const odds = computeOdds ( attackStr , defendStr ) ;
656+ const rangeMod = computeRangeMod ( myAttackers [ 0 ] , target ) ;
657+ const velMod = computeVelocityMod ( myAttackers [ 0 ] , target ) ;
658+
659+ // Background box
660+ const label = `${ odds } R-${ rangeMod } V-${ velMod } ` ;
661+ ctx . font = 'bold 10px monospace' ;
662+ const textW = ctx . measureText ( label ) . width ;
663+ ctx . fillStyle = 'rgba(0, 0, 0, 0.7)' ;
664+ ctx . fillRect ( targetPos . x - textW / 2 - 4 , targetPos . y - 32 , textW + 8 , 16 ) ;
665+ ctx . fillStyle = '#ff6666' ;
666+ ctx . textAlign = 'center' ;
667+ ctx . fillText ( label , targetPos . x , targetPos . y - 20 ) ;
668+ }
669+ }
670+
671+ private renderCombatResultsToast ( ctx : CanvasRenderingContext2D , results : CombatResult [ ] , now : number , screenW : number ) {
672+ if ( results . length === 0 ) return ;
673+ const fadeStart = this . combatResults ! . showUntil - 1000 ;
674+ const alpha = now > fadeStart ? Math . max ( 0 , ( this . combatResults ! . showUntil - now ) / 1000 ) : 1 ;
675+
676+ ctx . save ( ) ;
677+ ctx . globalAlpha = alpha ;
678+
679+ let y = 60 ;
680+ for ( const r of results ) {
681+ const text = formatCombatResult ( r , this . gameState ! ) ;
682+ ctx . font = 'bold 12px monospace' ;
683+ const w = ctx . measureText ( text ) . width ;
684+ const x = screenW / 2 ;
685+
686+ ctx . fillStyle = 'rgba(0, 0, 0, 0.75)' ;
687+ ctx . fillRect ( x - w / 2 - 8 , y - 12 , w + 16 , 20 ) ;
688+ ctx . fillStyle = r . damageType === 'eliminated' ? '#ff4444'
689+ : r . damageType === 'disabled' ? '#ffaa00'
690+ : '#88ff88' ;
691+ ctx . textAlign = 'center' ;
692+ ctx . fillText ( text , x , y + 2 ) ;
693+ y += 26 ;
694+
695+ if ( r . counterattack ) {
696+ const cText = formatCombatResult ( r . counterattack , this . gameState ! ) ;
697+ ctx . font = '11px monospace' ;
698+ const cw = ctx . measureText ( cText ) . width ;
699+ ctx . fillStyle = 'rgba(0, 0, 0, 0.65)' ;
700+ ctx . fillRect ( x - cw / 2 - 8 , y - 12 , cw + 16 , 18 ) ;
701+ ctx . fillStyle = r . counterattack . damageType === 'eliminated' ? '#ff4444'
702+ : r . counterattack . damageType === 'disabled' ? '#ffaa00'
703+ : '#88ff88' ;
704+ ctx . fillText ( cText , x , y + 2 ) ;
705+ y += 24 ;
706+ }
707+ }
708+
709+ ctx . restore ( ) ;
710+ }
711+ }
712+
713+ function formatCombatResult ( r : CombatResult , state : GameState ) : string {
714+ const targetShip = state . ships . find ( s => s . id === r . targetId ) ;
715+ const targetName = targetShip ? `${ targetShip . type } ` : r . targetId ;
716+ const result = r . damageType === 'eliminated' ? 'ELIMINATED'
717+ : r . damageType === 'disabled' ? `DISABLED ${ r . disabledTurns } T`
718+ : 'MISS' ;
719+ return `${ r . odds } [${ r . dieRoll } →${ r . modifiedRoll } ] ${ targetName } : ${ result } ` ;
589720}
590721
591722// --- Utility ---
0 commit comments