|
9 | 9 | hexVecLength, |
10 | 10 | } from '../shared/hex'; |
11 | 11 | import type { GameState, Ship, ShipMovement, OrdnanceMovement, MovementEvent, SolarSystemMap, CelestialBody, CombatResult, PlayerState } from '../shared/types'; |
12 | | -import { MOVEMENT_ANIM_DURATION, CAMERA_LERP_SPEED, SHIP_STATS } from '../shared/constants'; |
| 12 | +import { MOVEMENT_ANIM_DURATION, CAMERA_LERP_SPEED, SHIP_STATS, SHIP_DETECTION_RANGE, BASE_DETECTION_RANGE } from '../shared/constants'; |
13 | 13 | import { computeCourse, predictDestination } from '../shared/movement'; |
14 | 14 | import { computeOdds, computeRangeMod, computeVelocityMod, getCombatStrength, canAttack } from '../shared/combat'; |
15 | 15 |
|
@@ -436,6 +436,7 @@ export class Renderer { |
436 | 436 | } |
437 | 437 | } |
438 | 438 | if (this.gameState && this.map) { |
| 439 | + this.renderDetectionRanges(ctx, this.gameState, this.map); |
439 | 440 | this.renderCourseVectors(ctx, this.gameState, this.map, now); |
440 | 441 | this.renderOrdnance(ctx, this.gameState, now); |
441 | 442 | this.renderTorpedoGuidance(ctx, this.gameState, now); |
@@ -690,6 +691,56 @@ export class Renderer { |
690 | 691 | ctx.fillText('▼ TARGET', p.x, p.y + r + 24); |
691 | 692 | } |
692 | 693 |
|
| 694 | + private renderDetectionRanges(ctx: CanvasRenderingContext2D, state: GameState, map: SolarSystemMap) { |
| 695 | + if (this.animState) return; |
| 696 | + |
| 697 | + // Show detection range for selected own ship |
| 698 | + const selectedId = this.planningState.selectedShipId; |
| 699 | + for (const ship of state.ships) { |
| 700 | + if (ship.owner !== this.playerId || ship.destroyed) continue; |
| 701 | + const isSelected = ship.id === selectedId; |
| 702 | + if (!isSelected) continue; // Only show for selected ship to avoid clutter |
| 703 | + |
| 704 | + const p = hexToPixel(ship.position, HEX_SIZE); |
| 705 | + const detRange = SHIP_DETECTION_RANGE; |
| 706 | + // Approximate circle radius from hex distance |
| 707 | + const radius = detRange * HEX_SIZE * 1.73; // sqrt(3) * hex_size * range |
| 708 | + |
| 709 | + ctx.strokeStyle = 'rgba(79, 195, 247, 0.08)'; |
| 710 | + ctx.lineWidth = 1; |
| 711 | + ctx.setLineDash([4, 6]); |
| 712 | + ctx.beginPath(); |
| 713 | + ctx.arc(p.x, p.y, radius, 0, Math.PI * 2); |
| 714 | + ctx.stroke(); |
| 715 | + ctx.setLineDash([]); |
| 716 | + |
| 717 | + // Subtle label |
| 718 | + ctx.fillStyle = 'rgba(79, 195, 247, 0.15)'; |
| 719 | + ctx.font = '7px monospace'; |
| 720 | + ctx.textAlign = 'center'; |
| 721 | + ctx.fillText('DETECTION', p.x, p.y - radius - 4); |
| 722 | + } |
| 723 | + |
| 724 | + // Show base detection ranges for own bases |
| 725 | + const player = state.players[this.playerId]; |
| 726 | + if (player?.homeBody) { |
| 727 | + for (const [key, hex] of map.hexes) { |
| 728 | + if (!hex.base || hex.base.bodyName !== player.homeBody) continue; |
| 729 | + const [q, r] = key.split(',').map(Number); |
| 730 | + const p = hexToPixel({ q, r }, HEX_SIZE); |
| 731 | + const radius = BASE_DETECTION_RANGE * HEX_SIZE * 1.73; |
| 732 | + |
| 733 | + ctx.strokeStyle = 'rgba(79, 195, 247, 0.05)'; |
| 734 | + ctx.lineWidth = 1; |
| 735 | + ctx.setLineDash([3, 8]); |
| 736 | + ctx.beginPath(); |
| 737 | + ctx.arc(p.x, p.y, radius, 0, Math.PI * 2); |
| 738 | + ctx.stroke(); |
| 739 | + ctx.setLineDash([]); |
| 740 | + } |
| 741 | + } |
| 742 | + } |
| 743 | + |
693 | 744 | private renderCourseVectors(ctx: CanvasRenderingContext2D, state: GameState, map: SolarSystemMap, now: number) { |
694 | 745 | // During animation, don't show planning vectors |
695 | 746 | if (this.animState) return; |
@@ -976,7 +1027,7 @@ export class Renderer { |
976 | 1027 | // Disabled ships shown dimmer |
977 | 1028 | const isDisabled = ship.damage.disabledTurns > 0; |
978 | 1029 | const alpha = isDisabled ? 0.5 : 1.0; |
979 | | - this.drawShipIcon(ctx, pos.x, pos.y, ship.owner, alpha, heading); |
| 1030 | + this.drawShipIcon(ctx, pos.x, pos.y, ship.owner, alpha, heading, ship.damage.disabledTurns, ship.type); |
980 | 1031 |
|
981 | 1032 | // Disabled indicator |
982 | 1033 | if (isDisabled && !this.animState) { |
@@ -1008,12 +1059,28 @@ export class Renderer { |
1008 | 1059 | } |
1009 | 1060 | } |
1010 | 1061 |
|
1011 | | - private drawShipIcon(ctx: CanvasRenderingContext2D, x: number, y: number, owner: number, alpha: number, heading: number) { |
| 1062 | + private drawShipIcon(ctx: CanvasRenderingContext2D, x: number, y: number, owner: number, alpha: number, heading: number, disabledTurns = 0, shipType = '') { |
1012 | 1063 | const color = owner === 0 ? `rgba(79, 195, 247, ${alpha})` : `rgba(255, 152, 0, ${alpha})`; |
1013 | | - const size = 8; |
| 1064 | + // Size based on ship type combat value |
| 1065 | + const stats = SHIP_STATS[shipType]; |
| 1066 | + const combat = stats?.combat ?? 2; |
| 1067 | + const size = combat >= 15 ? 12 : combat >= 8 ? 10 : combat >= 4 ? 9 : 8; |
1014 | 1068 |
|
1015 | 1069 | ctx.save(); |
1016 | 1070 | ctx.translate(x, y); |
| 1071 | + |
| 1072 | + // Damage glow for disabled ships (flickering red/orange) |
| 1073 | + if (disabledTurns > 0) { |
| 1074 | + const flickerPhase = performance.now() / 200 + x * 0.1; // unique per ship |
| 1075 | + const intensity = 0.3 + 0.2 * Math.sin(flickerPhase) + 0.1 * Math.sin(flickerPhase * 2.7); |
| 1076 | + const glowColor = disabledTurns >= 4 ? `rgba(255, 50, 50, ${intensity})` : `rgba(255, 150, 50, ${intensity})`; |
| 1077 | + const glowRadius = 10 + disabledTurns; |
| 1078 | + ctx.fillStyle = glowColor; |
| 1079 | + ctx.beginPath(); |
| 1080 | + ctx.arc(0, 0, glowRadius, 0, Math.PI * 2); |
| 1081 | + ctx.fill(); |
| 1082 | + } |
| 1083 | + |
1017 | 1084 | ctx.rotate(heading); |
1018 | 1085 | ctx.fillStyle = color; |
1019 | 1086 | ctx.beginPath(); |
@@ -1299,16 +1366,27 @@ export class Renderer { |
1299 | 1366 | const odds = computeOdds(attackStr, defendStr); |
1300 | 1367 | const rangeMod = computeRangeMod(myAttackers[0], target); |
1301 | 1368 | const velMod = computeVelocityMod(myAttackers[0], target); |
| 1369 | + const totalMod = -(rangeMod + velMod); |
1302 | 1370 |
|
1303 | 1371 | // Background box |
1304 | | - const label = `${odds} R-${rangeMod} V-${velMod}`; |
| 1372 | + const modStr = totalMod >= 0 ? `+${totalMod}` : `${totalMod}`; |
| 1373 | + const label = `${odds} R-${rangeMod} V-${velMod} (${modStr})`; |
1305 | 1374 | ctx.font = 'bold 10px monospace'; |
1306 | 1375 | const textW = ctx.measureText(label).width; |
1307 | 1376 | ctx.fillStyle = 'rgba(0, 0, 0, 0.7)'; |
1308 | 1377 | ctx.fillRect(targetPos.x - textW / 2 - 4, targetPos.y - 32, textW + 8, 16); |
1309 | | - ctx.fillStyle = '#ff6666'; |
| 1378 | + // Color code: green if favorable, red if unfavorable, yellow if neutral |
| 1379 | + ctx.fillStyle = totalMod > 0 ? '#88ff88' : totalMod < 0 ? '#ff6666' : '#ffdd57'; |
1310 | 1380 | ctx.textAlign = 'center'; |
1311 | 1381 | ctx.fillText(label, targetPos.x, targetPos.y - 20); |
| 1382 | + |
| 1383 | + // Show counterattack warning if target can counterattack |
| 1384 | + const targetStats = SHIP_STATS[target.type]; |
| 1385 | + if (targetStats && !targetStats.defensiveOnly && target.damage.disabledTurns === 0) { |
| 1386 | + ctx.fillStyle = 'rgba(255, 170, 0, 0.7)'; |
| 1387 | + ctx.font = '7px monospace'; |
| 1388 | + ctx.fillText('CAN COUNTER', targetPos.x, targetPos.y - 38); |
| 1389 | + } |
1312 | 1390 | } |
1313 | 1391 | } |
1314 | 1392 |
|
|
0 commit comments