Skip to content

Commit 1548a57

Browse files
mf4633claude
andcommitted
Apoapsis: visible engine flames + KSP-layout + smoother sim
Engine / SRB / abort motor / radial-booster flame cones: 3-layer cones (white core, orange mid, red outer) with additive blending and no depth test so the glow reads through terrain. Length scales with sqrt(thrust) * 0.08 * throttle, width and length jitter per frame for fire flicker. Sound: layered sine/triangle oscillators (42/63/98 Hz) + pink noise into lowpass + tanh waveshaper → deep chest-rumble instead of buzzy sawtooth. Volume and cutoff ramp with atmosphere density and throttle; vacuum is muffled (cinematic convention). Jumpy fix: physics step halved to 10ms (100 Hz) so each 60 fps frame covers ~1-2 ticks evenly. Screen shake threshold raised to >2g + high throttle (was constant at launch). Camera lerp softened 0.15 → 0.08. Layout: navball moved to bottom-right corner at 170px (was 220px center, covering the rocket base). Throttle returned to left edge. HUD-BR repositioned left of the navball so it no longer collides with SAS panel. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 7f5a015 commit 1548a57

1 file changed

Lines changed: 125 additions & 33 deletions

File tree

Apoapsis.html

Lines changed: 125 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -63,18 +63,18 @@
6363
.hud-tl { top: 44px; left: 8px; min-width: 140px; }
6464
.hud-tr { top: 44px; right: 8px; text-align: right; min-width: 140px; }
6565
.hud-bl { bottom: 20px; left: 8px; min-width: 140px; }
66-
.hud-br { bottom: 20px; right: 8px; text-align: right; min-width: 140px; }
66+
.hud-br { bottom: 28px; right: 210px; text-align: right; min-width: 140px; }
6767
.hud b { color: #4090E0; font-size: 0.85em; letter-spacing: 1px; }
6868
.hud .val { color: #40E090; font-weight: bold; }
6969
.hud .warn { color: #FFD040; }
7070
.hud .q-warn { color: #FF6040; font-weight: bold; }
7171

72-
.navball { position: absolute; bottom: 30px; left: 50%; transform: translateX(-50%); width: 220px; height: 220px; z-index: 10; pointer-events: none; border-radius: 50%; overflow: hidden; box-shadow: 0 0 0 3px #304060, inset 0 0 24px rgba(0,0,0,0.6), 0 6px 24px rgba(0,0,0,0.7); }
73-
.navball canvas { display: block; width: 220px; height: 220px; }
74-
.navball::before { content: ''; position: absolute; top: 50%; left: 50%; width: 26px; height: 26px; margin: -13px 0 0 -13px; border: 2.5px solid #40E090; border-radius: 50%; box-sizing: border-box; z-index: 2; box-shadow: 0 0 4px rgba(64,224,144,0.6); }
75-
.navball::after { content: ''; position: absolute; top: 50%; left: 50%; width: 90px; height: 2.5px; margin: -1px 0 0 -45px; background: linear-gradient(to right, #40E090 0, #40E090 18%, transparent 18%, transparent 82%, #40E090 82%, #40E090 100%); z-index: 2; box-shadow: 0 0 4px rgba(64,224,144,0.5); }
72+
.navball { position: absolute; bottom: 22px; right: 22px; width: 170px; height: 170px; z-index: 10; pointer-events: none; border-radius: 50%; overflow: hidden; box-shadow: 0 0 0 3px #304060, inset 0 0 18px rgba(0,0,0,0.5), 0 4px 18px rgba(0,0,0,0.6); }
73+
.navball canvas { display: block; width: 170px; height: 170px; }
74+
.navball::before { content: ''; position: absolute; top: 50%; left: 50%; width: 22px; height: 22px; margin: -11px 0 0 -11px; border: 2px solid #40E090; border-radius: 50%; box-sizing: border-box; z-index: 2; box-shadow: 0 0 3px rgba(64,224,144,0.6); }
75+
.navball::after { content: ''; position: absolute; top: 50%; left: 50%; width: 70px; height: 2px; margin: -1px 0 0 -35px; background: linear-gradient(to right, #40E090 0, #40E090 18%, transparent 18%, transparent 82%, #40E090 82%, #40E090 100%); z-index: 2; box-shadow: 0 0 3px rgba(64,224,144,0.5); }
7676

77-
.throttle-bar { position: absolute; bottom: 30px; left: calc(50% - 155px); width: 28px; height: 220px; background: rgba(5,10,25,0.7); border: 2px solid #304060; border-radius: 6px; z-index: 10; box-shadow: 0 4px 16px rgba(0,0,0,0.6); }
77+
.throttle-bar { position: absolute; bottom: 30px; left: 20px; width: 24px; height: 200px; background: rgba(5,10,25,0.7); border: 2px solid #304060; border-radius: 6px; z-index: 10; box-shadow: 0 4px 16px rgba(0,0,0,0.6); }
7878
.throttle-fill { position: absolute; bottom: 2px; left: 2px; right: 2px; background: linear-gradient(to top, #D06020 0%, #E0A040 40%, #40E080 100%); border-radius: 0 0 4px 4px; transition: height 0.1s; }
7979
.throttle-bar::before { content: 'THR'; position: absolute; top: -20px; left: 50%; transform: translateX(-50%); font-size: 0.7em; color: #6090C0; letter-spacing: 2px; font-family: 'Courier New', monospace; font-weight: bold; }
8080

@@ -227,7 +227,7 @@ <h3>First mission</h3>
227227
<div class="hud hud-bl" id="hudBL"></div>
228228
<div class="hud hud-br" id="hudBR"></div>
229229
<div class="throttle-bar" id="throttleBar"><div class="throttle-fill" id="throttleFill"></div></div>
230-
<div class="navball" id="navball"><canvas id="navballCanvas" width="220" height="220"></canvas></div>
230+
<div class="navball" id="navball"><canvas id="navballCanvas" width="170" height="170"></canvas></div>
231231
<div class="sas-panel" id="sasPanel">
232232
<h4>SAS</h4>
233233
<button class="sas-btn active" data-sas="off" onclick="setSAS('off')">OFF<span class="sas-key">0</span></button>
@@ -331,7 +331,7 @@ <h2 id="resultTitle">Orbit Achieved!</h2>
331331
var timeWarp = 1;
332332
var lastTime = 0;
333333
var accumulator = 0;
334-
var PHYSICS_DT = 0.02;
334+
var PHYSICS_DT = 0.01;
335335
var viewMode = 'flight';
336336
var sasMode = 'off';
337337
var SAS_KEYS = { '0':'off', '1':'stability', '2':'prograde', '3':'retrograde', '4':'radial-out', '5':'radial-in', '6':'normal', '7':'antinormal', '8':'maneuver', '9':'target' };
@@ -1104,6 +1104,30 @@ <h2 id="resultTitle">Orbit Achieved!</h2>
11041104
if (stageFuel(s.parts, s.currentStage) <= 0.001 && computeThrust(s.parts, s.currentStage, 1).thrust <= 0) doStage(s);
11051105
}
11061106

1107+
function buildFlameGroup(nominalThrust, radius) {
1108+
var grp = new THREE.Group();
1109+
var r = radius * PART_SCALE;
1110+
// Outer plume (red)
1111+
var outer = new THREE.Mesh(new THREE.ConeGeometry(r * 1.8, 8, 12, 1, true), new THREE.MeshBasicMaterial({ color: 0xFF5020, transparent: true, opacity: 0.45, blending: THREE.AdditiveBlending, depthWrite: false, depthTest: false, side: THREE.DoubleSide }));
1112+
outer.rotation.x = Math.PI;
1113+
outer.position.y = -4;
1114+
grp.add(outer);
1115+
// Mid plume (orange)
1116+
var mid = new THREE.Mesh(new THREE.ConeGeometry(r * 1.25, 6, 12, 1, true), new THREE.MeshBasicMaterial({ color: 0xFFAA30, transparent: true, opacity: 0.7, blending: THREE.AdditiveBlending, depthWrite: false, depthTest: false, side: THREE.DoubleSide }));
1117+
mid.rotation.x = Math.PI;
1118+
mid.position.y = -3;
1119+
grp.add(mid);
1120+
// Inner core (white-yellow)
1121+
var inner = new THREE.Mesh(new THREE.ConeGeometry(r * 0.85, 4, 10, 1, true), new THREE.MeshBasicMaterial({ color: 0xFFFFC0, transparent: true, opacity: 0.9, blending: THREE.AdditiveBlending, depthWrite: false, depthTest: false, side: THREE.DoubleSide }));
1122+
inner.rotation.x = Math.PI;
1123+
inner.position.y = -2;
1124+
grp.add(inner);
1125+
// Flame length scales linearly with thrust magnitude
1126+
grp.userData = { isFlame: true, nominalThrust: nominalThrust, baseLen: Math.max(0.6, Math.sqrt(nominalThrust) * 0.08) };
1127+
grp.visible = false;
1128+
return grp;
1129+
}
1130+
11071131
/* ===== 3D ROCKET MESH ===== */
11081132
function buildRocketMeshes(parts) {
11091133
while (rocketGroup.children.length > 0) {
@@ -1123,18 +1147,23 @@ <h2 id="resultTitle">Orbit Achieved!</h2>
11231147
var N = def.radial;
11241148
var boosterR = def.w/2 * PART_SCALE;
11251149
var offset = STACK_R + boosterR * 1.15;
1150+
var perUnitThrust = def.thrust / N;
11261151
for (var j = 0; j < N; j++) {
11271152
var ang = (j / N) * Math.PI * 2;
11281153
var bm = new THREE.Mesh(new THREE.CylinderGeometry(boosterR, boosterR, h, 8), new THREE.MeshPhongMaterial({ color: def.color }));
11291154
bm.position.set(offset * Math.cos(ang), y + h / 2, offset * Math.sin(ang));
11301155
rocketGroup.add(bm);
1131-
// Tiny nozzle cone
11321156
var noz = new THREE.Mesh(new THREE.ConeGeometry(boosterR * 0.8, boosterR, 8), new THREE.MeshPhongMaterial({ color: 0x303030 }));
1133-
noz.rotation.x = Math.PI; // cone apex down
1157+
noz.rotation.x = Math.PI;
11341158
noz.position.set(offset * Math.cos(ang), y - boosterR * 0.5, offset * Math.sin(ang));
11351159
rocketGroup.add(noz);
1160+
var flame = buildFlameGroup(perUnitThrust, def.w/2);
1161+
flame.position.set(offset * Math.cos(ang), y - boosterR, offset * Math.sin(ang));
1162+
flame.userData.stage = parts[i].stage;
1163+
flame.userData.partIdx = i;
1164+
rocketGroup.add(flame);
11361165
}
1137-
continue; // radials don't advance y
1166+
continue;
11381167
}
11391168
if (def.type === 'leg') {
11401169
var legLen = def.h * PART_SCALE;
@@ -1155,23 +1184,26 @@ <h2 id="resultTitle">Orbit Achieved!</h2>
11551184
continue;
11561185
}
11571186
if (def.type === 'abort') {
1158-
// Central tapered spire
11591187
var spireH = h, spireR = def.w/2 * PART_SCALE;
11601188
var spire = new THREE.Mesh(new THREE.CylinderGeometry(spireR*0.2, spireR*0.9, spireH*0.55, 8), new THREE.MeshPhongMaterial({ color: def.color }));
11611189
spire.position.y = y + spireH * 0.725;
11621190
rocketGroup.add(spire);
1163-
// Thruster block at base
1164-
var base = new THREE.Mesh(new THREE.CylinderGeometry(spireR*0.95, spireR*1.05, spireH*0.35, 8), new THREE.MeshPhongMaterial({ color: 0x803030 }));
1165-
base.position.y = y + spireH * 0.25;
1166-
rocketGroup.add(base);
1167-
// 4 radial nozzles canted downward (pulling pod up)
1191+
var basePart = new THREE.Mesh(new THREE.CylinderGeometry(spireR*0.95, spireR*1.05, spireH*0.35, 8), new THREE.MeshPhongMaterial({ color: 0x803030 }));
1192+
basePart.position.y = y + spireH * 0.25;
1193+
rocketGroup.add(basePart);
11681194
for (var j = 0; j < 4; j++) {
11691195
var ang = j * Math.PI/2;
11701196
var noz = new THREE.Mesh(new THREE.ConeGeometry(spireR*0.3, spireR*0.9, 6), new THREE.MeshPhongMaterial({ color: 0x202428 }));
11711197
noz.position.set(spireR*1.1 * Math.cos(ang), y + spireH*0.1, spireR*1.1 * Math.sin(ang));
11721198
noz.rotation.z = Math.cos(ang) * 0.5;
11731199
noz.rotation.x = Math.sin(ang) * 0.5;
11741200
rocketGroup.add(noz);
1201+
var fl = buildFlameGroup(def.thrust/4, def.w/4);
1202+
fl.position.set(spireR*1.3 * Math.cos(ang), y + spireH*0.05, spireR*1.3 * Math.sin(ang));
1203+
// Nozzles are canted outward; flame along their axis but simplification: point downward
1204+
fl.userData.stage = parts[i].stage;
1205+
fl.userData.partIdx = i;
1206+
rocketGroup.add(fl);
11751207
}
11761208
y += spireH;
11771209
continue;
@@ -1235,6 +1267,14 @@ <h2 id="resultTitle">Orbit Achieved!</h2>
12351267
mesh.position.y = y + h / 2;
12361268
}
12371269
rocketGroup.add(mesh);
1270+
// Attach a flame to engines and SRBs (stack-mounted)
1271+
if (def.type === 'engine' || def.type === 'srb') {
1272+
var fl2 = buildFlameGroup(def.thrust, def.w/2);
1273+
fl2.position.y = y;
1274+
fl2.userData.stage = parts[i].stage;
1275+
fl2.userData.partIdx = i;
1276+
rocketGroup.add(fl2);
1277+
}
12381278
y += h;
12391279
}
12401280
flameLight.position.y = -2;
@@ -1683,6 +1723,23 @@ <h2 id="resultTitle">Orbit Achieved!</h2>
16831723

16841724
// Flame light
16851725
flameLight.intensity = sim.thrustN > 0 ? sim.throttle * 3 : 0;
1726+
// Engine flame animation: scale length by throttle, jitter for life
1727+
for (var fi = 0; fi < rocketGroup.children.length; fi++) {
1728+
var ch = rocketGroup.children[fi];
1729+
if (!ch.userData || !ch.userData.isFlame) continue;
1730+
var isCurStage = ch.userData.stage === sim.currentStage;
1731+
var firing = isCurStage && sim.throttle > 0 && sim.thrustN > 0;
1732+
if (firing) {
1733+
var throttle = sim.throttle;
1734+
var jitter = 0.85 + Math.random() * 0.3;
1735+
var lenScale = ch.userData.baseLen * throttle * jitter;
1736+
var widthScale = (0.7 + throttle * 0.4) * (0.9 + Math.random() * 0.2);
1737+
ch.scale.set(widthScale, lenScale, widthScale);
1738+
ch.visible = true;
1739+
} else {
1740+
ch.visible = false;
1741+
}
1742+
}
16861743

16871744
// Exhaust particles
16881745
if (sim.thrustN > 0) {
@@ -1762,18 +1819,18 @@ <h2 id="resultTitle">Orbit Achieved!</h2>
17621819
var offsetDir = rotated.multiplyScalar(cp).add(radial.clone().multiplyScalar(sp));
17631820
var camTarget = focusPoint.clone().add(offsetDir.multiplyScalar(camDist));
17641821

1765-
// Screen shake
1822+
// Screen shake only at real g-loads (> 2g) and high throttle — prevents constant jitter at launch
17661823
var gForce = sim.accel / ENV.g0;
1767-
if (sim.thrustN > 0 && gForce > 1) {
1768-
var shake = Math.min(gForce * 0.3, 3);
1824+
if (sim.thrustN > 0 && gForce > 2 && sim.throttle > 0.5) {
1825+
var shake = Math.min((gForce - 2) * 0.25, 1.5);
17691826
camTarget.add(new THREE.Vector3((Math.random() - 0.5) * shake, (Math.random() - 0.5) * shake, (Math.random() - 0.5) * shake));
17701827
}
17711828

17721829
if (!camera._initialized) {
17731830
camera.position.copy(camTarget);
17741831
camera._initialized = true;
17751832
} else {
1776-
camera.position.lerp(camTarget, 0.15);
1833+
camera.position.lerp(camTarget, 0.08);
17771834
}
17781835
camera.up.copy(radial);
17791836
camera.lookAt(focusPoint);
@@ -2707,26 +2764,58 @@ <h2 id="resultTitle">Orbit Achieved!</h2>
27072764
try {
27082765
audio.ctx = new (window.AudioContext || window.webkitAudioContext)();
27092766
} catch (e) { return; }
2767+
// Layered sine oscillators for a deep chest-rumble instead of a buzzy sawtooth
27102768
audio.engineOsc = audio.ctx.createOscillator();
2711-
audio.engineOsc.type = 'sawtooth';
2712-
audio.engineOsc.frequency.value = 48;
2713-
var noiseBuf = audio.ctx.createBuffer(1, audio.ctx.sampleRate * 2, audio.ctx.sampleRate);
2769+
audio.engineOsc.type = 'sine';
2770+
audio.engineOsc.frequency.value = 42;
2771+
audio.engineOsc2 = audio.ctx.createOscillator();
2772+
audio.engineOsc2.type = 'sine';
2773+
audio.engineOsc2.frequency.value = 63;
2774+
audio.engineOsc3 = audio.ctx.createOscillator();
2775+
audio.engineOsc3.type = 'triangle';
2776+
audio.engineOsc3.frequency.value = 98;
2777+
// Pink noise for natural turbulence
2778+
var noiseBuf = audio.ctx.createBuffer(1, audio.ctx.sampleRate * 3, audio.ctx.sampleRate);
27142779
var nd = noiseBuf.getChannelData(0);
2715-
for (var i = 0; i < nd.length; i++) nd[i] = (Math.random() * 2 - 1) * 0.4;
2780+
var b0 = 0, b1 = 0, b2 = 0, b3 = 0, b4 = 0, b5 = 0, b6 = 0;
2781+
for (var i = 0; i < nd.length; i++) {
2782+
var w = Math.random() * 2 - 1;
2783+
b0 = 0.99886 * b0 + w * 0.0555179;
2784+
b1 = 0.99332 * b1 + w * 0.0750759;
2785+
b2 = 0.96900 * b2 + w * 0.1538520;
2786+
b3 = 0.86650 * b3 + w * 0.3104856;
2787+
b4 = 0.55000 * b4 + w * 0.5329522;
2788+
b5 = -0.7616 * b5 - w * 0.0168980;
2789+
nd[i] = (b0 + b1 + b2 + b3 + b4 + b5 + b6 + w * 0.5362) * 0.09;
2790+
b6 = w * 0.115926;
2791+
}
27162792
audio.noiseSrc = audio.ctx.createBufferSource();
27172793
audio.noiseSrc.buffer = noiseBuf;
27182794
audio.noiseSrc.loop = true;
27192795
audio.engineFilter = audio.ctx.createBiquadFilter();
27202796
audio.engineFilter.type = 'lowpass';
27212797
audio.engineFilter.frequency.value = 300;
2722-
audio.engineFilter.Q.value = 1;
2798+
audio.engineFilter.Q.value = 0.7;
2799+
// A touch of distortion via waveshaper at the tail
2800+
audio.engineShaper = audio.ctx.createWaveShaper();
2801+
var curve = new Float32Array(1024);
2802+
for (var si = 0; si < 1024; si++) {
2803+
var x = si / 512 - 1;
2804+
curve[si] = Math.tanh(x * 1.4);
2805+
}
2806+
audio.engineShaper.curve = curve;
27232807
audio.engineGain = audio.ctx.createGain();
27242808
audio.engineGain.gain.value = 0;
27252809
audio.noiseSrc.connect(audio.engineFilter);
27262810
audio.engineOsc.connect(audio.engineFilter);
2727-
audio.engineFilter.connect(audio.engineGain);
2811+
audio.engineOsc2.connect(audio.engineFilter);
2812+
audio.engineOsc3.connect(audio.engineFilter);
2813+
audio.engineFilter.connect(audio.engineShaper);
2814+
audio.engineShaper.connect(audio.engineGain);
27282815
audio.engineGain.connect(audio.ctx.destination);
27292816
audio.engineOsc.start();
2817+
audio.engineOsc2.start();
2818+
audio.engineOsc3.start();
27302819
audio.noiseSrc.start();
27312820
// Reentry wind: separate noise + bandpass chain, driven by Q
27322821
audio.windSrc = audio.ctx.createBufferSource();
@@ -2753,11 +2842,14 @@ <h2 id="resultTitle">Orbit Achieved!</h2>
27532842
}
27542843
var thrFrac = s.thrustN > 0 ? s.throttle : 0;
27552844
var rho = s.parent === 'earth' ? atmDensity(s.altitude) : 0;
2756-
var atmFactor = Math.min(1, rho / 0.4 + 0.15);
2757-
var vol = thrFrac * 0.35 * atmFactor;
2758-
audio.engineGain.gain.setTargetAtTime(vol, audio.ctx.currentTime, 0.05);
2759-
audio.engineFilter.frequency.setTargetAtTime(100 + atmFactor * 450 + thrFrac * 250, audio.ctx.currentTime, 0.05);
2760-
// Reentry wind: Q-driven, pitched by speed
2845+
var atmFactor = Math.min(1, rho / 0.4);
2846+
// Audible but muffled in vacuum (cinematic convention) — 30% volume, hard low-pass
2847+
var vacFactor = 0.3 + atmFactor * 0.7;
2848+
var vol = thrFrac * 0.3 * vacFactor;
2849+
audio.engineGain.gain.setTargetAtTime(vol, audio.ctx.currentTime, 0.08);
2850+
// Cutoff: vacuum muffled (~200Hz), atm full range (~1800Hz). Throttle adds brightness.
2851+
var cutoff = 180 + atmFactor * 1500 + thrFrac * 350;
2852+
audio.engineFilter.frequency.setTargetAtTime(cutoff, audio.ctx.currentTime, 0.08);
27612853
if (audio.windGain && audio.windFilter) {
27622854
var q = s.q || 0;
27632855
var windVol = Math.min(0.35, q / 80000);
@@ -2862,7 +2954,7 @@ <h2 id="resultTitle">Orbit Achieved!</h2>
28622954
var canvas = document.getElementById('navballCanvas');
28632955
if (!canvas) return;
28642956
navRenderer = new THREE.WebGLRenderer({ canvas: canvas, alpha: true, antialias: true });
2865-
navRenderer.setSize(220, 220);
2957+
navRenderer.setSize(170, 170);
28662958
navRenderer.setPixelRatio(window.devicePixelRatio || 1);
28672959
navScene = new THREE.Scene();
28682960
navCamera = new THREE.PerspectiveCamera(65, 1, 0.01, 10);

0 commit comments

Comments
 (0)