|
24 | 24 | import { palette, parseRgba } from './shared/theme'; |
25 | 25 | import { initHiDPICanvas } from './shared/canvas'; |
26 | 26 |
|
| 27 | + // Sweep line animation — phosphor sim runs at fixed tick rate |
| 28 | + // All original values (0.014 sweep, 0.028 decay) were tuned at 240fps |
| 29 | + const PHOSPHOR_HZ = 240; |
| 30 | + const PHOSPHOR_TICK = 1 / PHOSPHOR_HZ; |
| 31 | + const PHOSPHOR_MAX_TICKS = Math.ceil(PHOSPHOR_HZ / 15); |
| 32 | + const SWEEP_PER_TICK = 0.014 * (240 / PHOSPHOR_HZ); |
| 33 | + const DECAY_ALPHA = 1 - Math.pow(1 - 0.028, 240 / PHOSPHOR_HZ); |
| 34 | +
|
27 | 35 | // ── CRT phosphor palette (derived from theme radar colors) ── |
28 | 36 | // Rebuilt when CRT canvases init or theme changes; avoids per-frame parsing |
29 | 37 | let crt = { |
30 | | - decayFill: 'rgba(5,10,5,0.028)', |
| 38 | + decayFill: `rgba(5,10,5,${DECAY_ALPHA})`, |
31 | 39 | sweepLine: 'rgba(60,200,60,0.2)', |
32 | 40 | sweepTrail: 'rgba(50,180,50,0.07)', |
33 | 41 | flashSelected: 'rgba(200,255,200,0.95)', |
|
60 | 68 | const hg = Math.min(255, bg_ + Math.round((255 - bg_) * 0.6)); |
61 | 69 | const hb = Math.min(255, bb + Math.round((255 - bb) * 0.6)); |
62 | 70 | crt = { |
63 | | - decayFill: `rgba(${bgr},${bgg},${bgb},0.028)`, |
| 71 | + decayFill: `rgba(${bgr},${bgg},${bgb},${DECAY_ALPHA})`, |
64 | 72 | sweepLine: `rgba(${br},${bg_},${bb},0.2)`, |
65 | 73 | sweepTrail: `rgba(${br},${bg_},${bb},0.07)`, |
66 | 74 | flashSelected: `rgba(${hr},${hg},${hb},0.95)`, |
|
114 | 122 | let ctx: CanvasRenderingContext2D | null = null; |
115 | 123 | let animFrameId = 0; |
116 | 124 |
|
117 | | - // Sweep line animation |
118 | 125 | let sweepAngle = 0; |
| 126 | + let lastFrameTime = 0; |
| 127 | + let phosphorAccum = 0; |
119 | 128 |
|
120 | 129 | // Info bar state (HTML overlay) |
121 | 130 | let infoCount = $state(0); |
|
334 | 343 | if (zoom <= 1.01) { panX = 0; panY = 0; zoom = 1; } |
335 | 344 | } |
336 | 345 |
|
337 | | - function drawFrame() { |
| 346 | + function drawFrame(now: number) { |
338 | 347 | if (!ctx || !canvasEl) { animFrameId = requestAnimationFrame(drawFrame); return; } |
| 348 | + const dt = lastFrameTime ? (now - lastFrameTime) / 1000 : 0; |
| 349 | + lastFrameTime = now; |
339 | 350 |
|
340 | 351 | const dpr = window.devicePixelRatio || 1; |
341 | 352 | ctx.clearRect(0, 0, canvasEl.width, canvasEl.height); |
|
374 | 385 | const count = uiStore.radarBlipCount; |
375 | 386 | const bAz = beamStore.aimAz, bEl = beamStore.aimEl, bW = beamStore.beamWidth; |
376 | 387 |
|
377 | | - // ═══ VFX: phosphor buffer approach ═══ |
| 388 | + // ═══ VFX: phosphor buffer approach (fixed 60Hz tick) ═══ |
378 | 389 | if (vfx && phosphorCtx && phosphorCanvas) { |
379 | 390 | const pc = phosphorCtx; |
380 | 391 |
|
381 | | - // Advance sweep |
382 | | - sweepAngle += 0.014; |
383 | | - if (sweepAngle > TWO_PI) sweepAngle -= TWO_PI; |
384 | | -
|
385 | | - // Fade the phosphor buffer — this creates the persistence decay |
386 | | - pc.fillStyle = crt.decayFill; |
387 | | - pc.fillRect(0, 0, SIZE, SIZE); |
388 | | -
|
389 | | - // Draw bright sweep line onto phosphor |
390 | | - const edgeA = sweepAngle - Math.PI / 2; |
391 | | - const sx = CX + sweepLen * Math.cos(edgeA); |
392 | | - const sy = CY + sweepLen * Math.sin(edgeA); |
393 | | - pc.strokeStyle = crt.sweepLine; |
394 | | - pc.lineWidth = 1.5; |
395 | | - pc.beginPath(); |
396 | | - pc.moveTo(CX, CY); |
397 | | - pc.lineTo(sx, sy); |
398 | | - pc.stroke(); |
399 | | - // Dimmer trailing line |
400 | | - const trailA = sweepAngle - 0.04 - Math.PI / 2; |
401 | | - pc.strokeStyle = crt.sweepTrail; |
402 | | - pc.lineWidth = 1; |
403 | | - pc.beginPath(); |
404 | | - pc.moveTo(CX, CY); |
405 | | - pc.lineTo(CX + sweepLen * Math.cos(trailA), CY + sweepLen * Math.sin(trailA)); |
406 | | - pc.stroke(); |
407 | | -
|
408 | | - // Draw blips onto phosphor ONLY when sweep passes them |
409 | | - for (let i = 0; i < count; i++) { |
410 | | - const off = i * 4; |
411 | | - const az = blips[off]; |
412 | | - const el = blips[off + 1]; |
413 | | - const flags = blips[off + 3]; |
414 | | - const isSelected = (flags & 1) !== 0; |
415 | | - const inBeam = isInsideBeam(az, el, bAz, bEl, bW); |
416 | | -
|
417 | | - // How far behind the sweep is this blip? |
418 | | - const blipAngle = az * Math.PI / 180 - headingRad; |
419 | | - const angDist = ((sweepAngle - blipAngle) % TWO_PI + TWO_PI) % TWO_PI; |
420 | | - // Only paint when sweep is within ~3° of the blip |
421 | | - if (angDist < 0.06 || angDist > TWO_PI - 0.02) { |
422 | | - const r = R_MAX * (90 - Math.max(0, el)) / 90; |
423 | | - const bx = r * Math.sin(blipAngle) + CX; |
424 | | - const by = -r * Math.cos(blipAngle) + CY; |
425 | | - const radius = isSelected ? 4 : inBeam ? 3 : 2.5; |
426 | | -
|
427 | | - // Bright white-green flash |
428 | | - pc.fillStyle = isSelected ? crt.flashSelected |
429 | | - : inBeam ? crt.flashBeam |
430 | | - : crt.flashNormal; |
431 | | - pc.beginPath(); |
432 | | - pc.arc(bx, by, radius, 0, TWO_PI); |
433 | | - pc.fill(); |
434 | | -
|
435 | | - // Bloom glow |
436 | | - if (isSelected || inBeam) { |
437 | | - pc.fillStyle = isSelected ? crt.bloomBright : crt.bloomDim; |
| 392 | + // Run phosphor simulation at fixed 60Hz — accumulate real time, consume in ticks |
| 393 | + phosphorAccum += dt; |
| 394 | + const maxTicks = PHOSPHOR_MAX_TICKS; |
| 395 | + let ticks = Math.floor(phosphorAccum / PHOSPHOR_TICK); |
| 396 | + if (ticks > maxTicks) ticks = maxTicks; |
| 397 | + phosphorAccum -= ticks * PHOSPHOR_TICK; |
| 398 | +
|
| 399 | + for (let t = 0; t < ticks; t++) { |
| 400 | + // Advance sweep |
| 401 | + // Advance sweep |
| 402 | + sweepAngle += SWEEP_PER_TICK; |
| 403 | + if (sweepAngle > TWO_PI) sweepAngle -= TWO_PI; |
| 404 | +
|
| 405 | + // Fade the phosphor buffer — this creates the persistence decay |
| 406 | + pc.fillStyle = crt.decayFill; |
| 407 | + pc.fillRect(0, 0, SIZE, SIZE); |
| 408 | +
|
| 409 | + // Draw bright sweep line onto phosphor |
| 410 | + const edgeA = sweepAngle - Math.PI / 2; |
| 411 | + const sx = CX + sweepLen * Math.cos(edgeA); |
| 412 | + const sy = CY + sweepLen * Math.sin(edgeA); |
| 413 | + pc.strokeStyle = crt.sweepLine; |
| 414 | + pc.lineWidth = 1.5; |
| 415 | + pc.beginPath(); |
| 416 | + pc.moveTo(CX, CY); |
| 417 | + pc.lineTo(sx, sy); |
| 418 | + pc.stroke(); |
| 419 | + // Dimmer trailing line |
| 420 | + const trailA = sweepAngle - 0.04 - Math.PI / 2; |
| 421 | + pc.strokeStyle = crt.sweepTrail; |
| 422 | + pc.lineWidth = 1; |
| 423 | + pc.beginPath(); |
| 424 | + pc.moveTo(CX, CY); |
| 425 | + pc.lineTo(CX + sweepLen * Math.cos(trailA), CY + sweepLen * Math.sin(trailA)); |
| 426 | + pc.stroke(); |
| 427 | +
|
| 428 | + // Draw blips onto phosphor ONLY when sweep passes them |
| 429 | + for (let i = 0; i < count; i++) { |
| 430 | + const off = i * 4; |
| 431 | + const az = blips[off]; |
| 432 | + const el = blips[off + 1]; |
| 433 | + const flags = blips[off + 3]; |
| 434 | + const isSelected = (flags & 1) !== 0; |
| 435 | + const inBeam = isInsideBeam(az, el, bAz, bEl, bW); |
| 436 | +
|
| 437 | + // How far behind the sweep is this blip? |
| 438 | + const blipAngle = az * Math.PI / 180 - headingRad; |
| 439 | + const angDist = ((sweepAngle - blipAngle) % TWO_PI + TWO_PI) % TWO_PI; |
| 440 | + // Only paint when sweep is within ~3° of the blip |
| 441 | + if (angDist < 0.06 || angDist > TWO_PI - 0.02) { |
| 442 | + const r = R_MAX * (90 - Math.max(0, el)) / 90; |
| 443 | + const bx = r * Math.sin(blipAngle) + CX; |
| 444 | + const by = -r * Math.cos(blipAngle) + CY; |
| 445 | + const radius = isSelected ? 4 : inBeam ? 3 : 2.5; |
| 446 | +
|
| 447 | + // Bright white-green flash |
| 448 | + pc.fillStyle = isSelected ? crt.flashSelected |
| 449 | + : inBeam ? crt.flashBeam |
| 450 | + : crt.flashNormal; |
438 | 451 | pc.beginPath(); |
439 | | - pc.arc(bx, by, radius + 4, 0, TWO_PI); |
| 452 | + pc.arc(bx, by, radius, 0, TWO_PI); |
440 | 453 | pc.fill(); |
| 454 | +
|
| 455 | + // Bloom glow |
| 456 | + if (isSelected || inBeam) { |
| 457 | + pc.fillStyle = isSelected ? crt.bloomBright : crt.bloomDim; |
| 458 | + pc.beginPath(); |
| 459 | + pc.arc(bx, by, radius + 4, 0, TWO_PI); |
| 460 | + pc.fill(); |
| 461 | + } |
441 | 462 | } |
442 | 463 | } |
443 | 464 | } |
|
0 commit comments