mirror of
https://github.com/SK8-ENGINE/skate-3-rust-engine.git
synced 2026-10-27 19:54:40 +00:00
2018 lines
86 KiB
Lua
2018 lines
86 KiB
Lua
-- Model collision repair 4.3.1: collision cooked from the SAME render GLB; no sidecar assets.
|
|
-- Driving edition 1: latest working vehicle + unchanged audio; render-rate cameras,
|
|
-- retained dashboard, progressive driver-input mapping, explicit optional aero.
|
|
-- Skyline Physics v4 -- Lua 5.4 / supplied Skate SDK API 2.
|
|
-- Reduced-order vehicle: one 6-DOF chassis, four rotating tire DOFs,
|
|
-- passive clutch/differential/brakes, and a combined-slip brush tire law.
|
|
-- No yaw targets, countersteer assist, slide-recovery state, lateral-velocity
|
|
-- deletion, handbrake grip multiplier, or upright torque. Aero is an explicit load.
|
|
-- See README.md for the collision/API contract and validation limits.
|
|
|
|
local BODY, MODEL = "chassis", "skyline.glb"
|
|
local C = {
|
|
mass = 1400, gravity = 9.81,
|
|
-- At nominal ride height: CG is 0.400 m above a level road.
|
|
-- This is an explicitly tuned chassis, NOT measured factory Skyline data.
|
|
center = {0, -0.27160, 0.16},
|
|
inertia_half = {0.92, 0.55, 2.10},
|
|
-- Physics uses the existing rendered body node, cooked into multiple
|
|
-- convex solids by the host. No separate collision GLB/JSON or point list.
|
|
entry_radius = 1.20, reentry_delay = 5.0,
|
|
radius = 0.341,
|
|
ride_length = 0.20, droop_length = 0.37,
|
|
front_spring = 37000, rear_spring = 32000,
|
|
damping_ratio = 0.85,
|
|
front_antiroll = 8000, rear_antiroll = 6800,
|
|
antiroll_damping = 450,
|
|
bump_length = 0.055, bump_stiffness = 110000,
|
|
maximum_load = 22000,
|
|
wheel_inertia = 1.85,
|
|
-- Cornering/longitudinal stiffness in N per unit normalized slip, not N/(m/s).
|
|
front_stiffness_per_load = 23.0, rear_stiffness_per_load = 24.0,
|
|
load_exponent = 0.90,
|
|
front_drive = 0.15,
|
|
brake_torque_total = 6800, front_brake_fraction = 0.72,
|
|
handbrake_torque = 3800, rolling_coefficient = 0.012,
|
|
rear_diff_preload = 12, rear_diff_ramp = 0.18,
|
|
steer_limit = math.rad(40), steer_rate = math.rad(170),
|
|
steering_exponent = 1.70,
|
|
final_drive = 3.545, reverse_ratio = 3.280,
|
|
gear_ratios = {3.827, 2.360, 1.685, 1.312, 1.000, 0.793},
|
|
idle_rpm = 900, redline_rpm = 8000, shift_time = 0.18,
|
|
engine_inertia = 0.30, clutch_capacity = 620,
|
|
iterations = 8, tire_root_iterations = 10,
|
|
aero_drag = 0.42,
|
|
}
|
|
-- Model's named left wheels are +X; model forward is +Z.
|
|
-- A right input therefore requests NEGATIVE yaw, matching a rear chase view.
|
|
local wheels = {
|
|
{name="FL", key="wheel_fl", pos={ 0.794235,-0.330559, 1.443641}, front=true, mate=2},
|
|
{name="FR", key="wheel_fr", pos={-0.796149,-0.330652, 1.443556}, front=true, mate=1},
|
|
{name="RL", key="wheel_rl", pos={ 0.795353,-0.330587,-1.347033}, front=false,mate=4},
|
|
{name="RR", key="wheel_rr", pos={-0.796153,-0.330635,-1.347024}, front=false,mate=3},
|
|
}
|
|
local wheelbase = 1.44360 + 1.34703
|
|
local track = 1.591
|
|
local I = {
|
|
C.mass/3 * (C.inertia_half[2]^2 + C.inertia_half[3]^2),
|
|
C.mass/3 * (C.inertia_half[1]^2 + C.inertia_half[3]^2),
|
|
C.mass/3 * (C.inertia_half[1]^2 + C.inertia_half[2]^2),
|
|
}
|
|
local state = {
|
|
spawned=false, occupied=false, pending=nil, spawn_confirm=nil, gear=1,
|
|
rpm=C.idle_rpm, engine_omega=C.idle_rpm*math.pi/30, clutch=0,
|
|
steer=0, shift=0, time=0, enter_after=0,
|
|
keys={}, buttons=0, hud_time=0, mass_audit=nil, keyboard_axis=0, aero_load=0,
|
|
enter_requested=false, exit_requested=false, reenter_on_ready=false,
|
|
hull_debug=false, interaction_text="", mp_hint_time=0,
|
|
}
|
|
|
|
local function clamp(x,a,b) return math.max(a,math.min(b,x)) end
|
|
local function finite(x) return type(x)=="number" and x==x and math.abs(x)<math.huge end
|
|
local function number(x, fallback) return finite(x) and x or fallback end
|
|
local function vec(x) return type(x)=="table" and finite(x[1]) and finite(x[2]) and finite(x[3]) end
|
|
local function add(a,b) return {a[1]+b[1],a[2]+b[2],a[3]+b[3]} end
|
|
local function sub(a,b) return {a[1]-b[1],a[2]-b[2],a[3]-b[3]} end
|
|
local function mul(a,s) return {a[1]*s,a[2]*s,a[3]*s} end
|
|
local function dot(a,b)
|
|
if not vec(a) or not vec(b) then return 0 end
|
|
return a[1]*b[1]+a[2]*b[2]+a[3]*b[3]
|
|
end
|
|
local function cross(a,b) return {a[2]*b[3]-a[3]*b[2],a[3]*b[1]-a[1]*b[3],a[1]*b[2]-a[2]*b[1]} end
|
|
local function norm(a)
|
|
if not vec(a) then return nil end
|
|
local l=math.sqrt(dot(a,a))
|
|
return l>1e-8 and mul(a,1/l) or nil
|
|
end
|
|
local function rotate(q,v)
|
|
local t=mul(cross({q[1],q[2],q[3]},v),2)
|
|
return add(v,add(mul(t,q[4]),cross({q[1],q[2],q[3]},t)))
|
|
end
|
|
local function unrotate(q,v) return rotate({-q[1],-q[2],-q[3],q[4]},v) end
|
|
local function inertia(q,v,inverse)
|
|
local a=unrotate(q,v)
|
|
for i=1,3 do a[i]=inverse and a[i]/I[i] or a[i]*I[i] end
|
|
return rotate(q,a)
|
|
end
|
|
local function yaw(q) return math.atan(2*(q[4]*q[2]+q[1]*q[3]),1-2*(q[2]^2+q[3]^2)) end
|
|
local function yaw_rotation(a) return {0,math.sin(a/2),0,math.cos(a/2)} end
|
|
local function approach(x,target,step) return x+clamp(target-x,-step,step) end
|
|
local function deadzone(x,threshold)
|
|
if math.abs(x)<=threshold then return 0 end
|
|
return (x<0 and -1 or 1)*(math.abs(x)-threshold)/(1-threshold)
|
|
end
|
|
local function gear_ratio()
|
|
if state.gear==0 then return 0 end
|
|
return (state.gear<0 and -C.reverse_ratio or C.gear_ratios[state.gear])*C.final_drive
|
|
end
|
|
local function gear_label() return state.gear<0 and "R" or (state.gear==0 and "N" or tostring(state.gear)) end
|
|
local function down(key) return sdk.input.down(key)==true end
|
|
local function edge(key)
|
|
local held=down(key)
|
|
local yes=held and not state.keys[key]
|
|
state.keys[key]=held
|
|
return yes
|
|
end
|
|
local function pressed_button(buttons,mask)
|
|
return (buttons & mask)~=0 and (state.buttons & mask)==0
|
|
end
|
|
-- AUDIO EXTENSION: single engine sample, pitch-shifted by RPM.
|
|
local sound = {
|
|
running=false, supported=false, prepared=false, clock=0, cooldown=0,
|
|
rpm=900, throttle=0, shift=0, previous_throttle=0,
|
|
overrun_remaining=0, pop_slot=0, random_state=73641,
|
|
gain=0, shift_gain=1, load=0, log_pitch=nil,
|
|
turbo_gain=0, turbo_pitch=1, brake=0, brake_volume=0,
|
|
drift_slip=0, drift_slip_smooth=0, drift_volume=0, brake_pitch=1,
|
|
}
|
|
local DRIFT_SLIP_MIN = 0.28
|
|
local ENGINE_PATH = "audio/engine.wav"
|
|
local ENGINE_BASE_RPM = 3400
|
|
local BRAKE_PATH = "audio/brake.wav"
|
|
local function smooth_toward(current,target,dt,tau)
|
|
return current+(target-current)*(1-math.exp(-dt/math.max(tau,1e-4)))
|
|
end
|
|
local function smooth_rpm_toward(current,target,dt)
|
|
local delta=target-current
|
|
local tau=0.20+0.55*clamp(math.abs(delta)/2800,0,1)
|
|
return smooth_toward(current,target,dt,tau)
|
|
end
|
|
local function sound_random()
|
|
sound.random_state=(sound.random_state*48271)%2147483647
|
|
return sound.random_state/2147483647
|
|
end
|
|
local function stop_audio()
|
|
if sound.supported then sdk.audio.stop_all() end
|
|
sound.running=false; sound.cooldown=0; sound.overrun_remaining=0
|
|
sound.previous_throttle=0
|
|
sound.gain=0; sound.shift_gain=1; sound.load=0; sound.log_pitch=nil
|
|
sound.turbo_gain=0; sound.turbo_pitch=1
|
|
sound.brake=0; sound.brake_volume=0
|
|
sound.drift_slip=0; sound.drift_slip_smooth=0
|
|
sound.drift_volume=0; sound.brake_pitch=1
|
|
end
|
|
local function prepare_audio()
|
|
sound.supported=type(sdk.audio)=="table" and number(sdk.audio.version,0)>=1
|
|
if not sound.supported then
|
|
sdk.log("Skyline audio unavailable: install the native Audio API update and rebuild the game.")
|
|
sdk.log("Skyline: sound requires Audio API extension 1.")
|
|
return
|
|
end
|
|
if sound.prepared then return end
|
|
sdk.audio.preload(ENGINE_PATH)
|
|
sdk.audio.preload("audio/turbo_loop.wav")
|
|
sdk.audio.preload("audio/lift.wav")
|
|
sdk.audio.preload("audio/pop.wav")
|
|
sdk.audio.preload(BRAKE_PATH)
|
|
sound.prepared=true
|
|
sdk.ui.text("skyline_audio","")
|
|
end
|
|
local function start_audio()
|
|
if not sound.supported or sdk.settings.audio_enabled==false or sound.running then return end
|
|
sound.clock=0; sound.cooldown=0
|
|
sound.rpm=math.max(state.rpm,200)
|
|
sound.throttle=0; sound.previous_throttle=0; sound.overrun_remaining=0
|
|
sound.gain=0; sound.shift_gain=1; sound.load=0
|
|
sound.log_pitch=math.log(math.max(sound.rpm,200)/ENGINE_BASE_RPM)
|
|
sound.turbo_gain=0; sound.turbo_pitch=1
|
|
local pitch=clamp(math.exp(sound.log_pitch),0.25,3)
|
|
local pitch_b=clamp(pitch*1.005,0.25,3)
|
|
sdk.audio.play("engine_main",{
|
|
path=ENGINE_PATH,body=BODY,offset={0,-0.1,0.45},loop=true,volume=0,
|
|
pitch=pitch,spatial=true,spatial_scale=0.10,fade_in=0.08,
|
|
})
|
|
sdk.audio.play("engine_main_b",{
|
|
path=ENGINE_PATH,body=BODY,offset={0,-0.1,0.45},loop=true,volume=0,
|
|
pitch=pitch_b,spatial=true,spatial_scale=0.10,fade_in=0.08,
|
|
})
|
|
sdk.audio.play("turbo",{
|
|
path="audio/turbo_loop.wav",body=BODY,offset={0,0,0.8},
|
|
loop=true,volume=0,pitch=1,spatial=true,spatial_scale=0.1,fade_in=0.08,
|
|
})
|
|
sdk.audio.play("brake",{
|
|
path=BRAKE_PATH,body=BODY,offset={0,-0.25,-0.9},
|
|
loop=true,volume=0,pitch=1,spatial=true,spatial_scale=0.12,fade_in=0.10,
|
|
})
|
|
sdk.audio.play("brake_b",{
|
|
path=BRAKE_PATH,body=BODY,offset={0,-0.25,-0.9},
|
|
loop=true,volume=0,pitch=1.028,spatial=true,spatial_scale=0.12,fade_in=0.10,
|
|
})
|
|
sound.running=true
|
|
end
|
|
local function exhaust_pop(intensity)
|
|
if not sound.running then return end
|
|
local master=clamp(number(sdk.settings.engine_volume,0.70),0,1)
|
|
local volume=clamp(number(sdk.settings.pop_volume,0.65),0,1)
|
|
if master*volume<=0 then return end
|
|
sound.pop_slot=sound.pop_slot%6+1
|
|
sdk.audio.play("exhaust_pop_"..sound.pop_slot,{
|
|
path="audio/pop.wav",
|
|
body=BODY,offset={-0.5,-0.22,-1.95},loop=false,
|
|
volume=clamp(master*volume*intensity,0,1),
|
|
pitch=0.90+0.22*sound_random(),spatial=true,spatial_scale=0.1,fade_in=0,
|
|
})
|
|
end
|
|
local function update_audio(event)
|
|
if not sound.supported then return end
|
|
if not state.spawned or sdk.settings.audio_enabled==false then
|
|
if sound.running then stop_audio() end
|
|
return
|
|
end
|
|
if not sound.running then start_audio() end
|
|
if sdk.snapshot.paused or sdk.snapshot.replay then return end
|
|
local dt=clamp(number(event and event.dt,1/60),0,1/30)
|
|
sound.clock=sound.clock+dt
|
|
local actual_rpm=clamp(number(state.rpm,0),0,12000)
|
|
sound.rpm=smooth_rpm_toward(sound.rpm,actual_rpm,dt)
|
|
local throttle=clamp(number(sound.throttle,0),0,1)
|
|
local master=clamp(number(sdk.settings.engine_volume,0.70),0,1)
|
|
sound.cooldown=math.max(0,sound.cooldown-dt)
|
|
local limiter=actual_rpm>=C.redline_rpm-160 and throttle>0.55 and sound.shift<=0
|
|
if limiter and sdk.settings.redline_pops~=false then
|
|
sound.overrun_remaining=0
|
|
if sound.cooldown<=0 then
|
|
exhaust_pop(0.78+0.20*sound_random())
|
|
sound.cooldown=0.095+0.075*sound_random()
|
|
end
|
|
else
|
|
local lifted=sound.previous_throttle>0.6 and throttle<0.2 and actual_rpm>4000
|
|
if lifted then
|
|
if sdk.settings.overrun_pops~=false then
|
|
sound.overrun_remaining=2+math.floor(sound_random()*2)
|
|
sound.cooldown=0.04+0.06*sound_random()
|
|
end
|
|
if sdk.settings.turbo_audio~=false and master>0 then
|
|
sdk.audio.play("turbo_lift",{
|
|
path="audio/lift.wav",body=BODY,offset={0,0,0.8},
|
|
volume=master*0.55,pitch=0.85+0.2*sound_random(),
|
|
spatial=true,spatial_scale=0.1,fade_in=0,
|
|
})
|
|
end
|
|
end
|
|
if throttle>0.3 or actual_rpm<3000 or sdk.settings.overrun_pops==false then
|
|
sound.overrun_remaining=0
|
|
end
|
|
if sound.overrun_remaining>0 and sound.cooldown<=0 then
|
|
exhaust_pop(0.36+0.24*sound_random())
|
|
sound.overrun_remaining=sound.overrun_remaining-1
|
|
sound.cooldown=0.11+0.12*sound_random()
|
|
end
|
|
end
|
|
sound.previous_throttle=throttle
|
|
local brake_in=clamp(number(sound.brake,0),0,1)
|
|
local speed=clamp(number(state.speed,0),0,200)
|
|
local speed_factor=clamp((speed-0.5)/12,0,1)
|
|
local brake_master=clamp(number(sdk.settings.brake_volume,0.55),0,1)
|
|
sound.drift_slip_smooth=smooth_toward(sound.drift_slip_smooth,number(sound.drift_slip,0),dt,0.14)
|
|
local slip=sound.drift_slip_smooth
|
|
local drift_mix=0
|
|
if slip>DRIFT_SLIP_MIN then
|
|
local norm=clamp((slip-DRIFT_SLIP_MIN)/2.6,0,1)
|
|
drift_mix=norm*norm*norm
|
|
end
|
|
local pedal_brake=brake_in*speed_factor
|
|
local drift_target=drift_mix*speed_factor*0.68
|
|
local drift_tau=drift_target>sound.drift_volume and 0.22 or 0.14
|
|
sound.drift_volume=smooth_toward(sound.drift_volume,drift_target,dt,drift_tau)
|
|
local brake_mix=math.max(pedal_brake,sound.drift_volume)
|
|
local brake_target=(sdk.settings.brake_audio==false) and 0 or brake_master*brake_mix
|
|
sound.brake_volume=smooth_toward(sound.brake_volume,brake_target,dt,0.16)
|
|
local norm_pitch=clamp((slip-DRIFT_SLIP_MIN)/3.5,0,1)
|
|
local wobble=0.012*math.sin(sound.clock*4.3)+0.008*math.sin(sound.clock*7.1)
|
|
sound.brake_pitch=smooth_toward(sound.brake_pitch,0.98+0.08*norm_pitch+wobble,dt,0.22)
|
|
local brake_vol=sound.brake_volume
|
|
sdk.audio.update("brake",{
|
|
volume=brake_vol*0.72,
|
|
pitch=clamp(sound.brake_pitch,0.25,4),
|
|
})
|
|
sdk.audio.update("brake_b",{
|
|
volume=brake_vol*0.28,
|
|
pitch=clamp(sound.brake_pitch*1.028,0.25,4),
|
|
})
|
|
local rpm=math.max(sound.rpm,200)
|
|
sound.load=smooth_toward(sound.load,throttle^0.70,dt,0.22)
|
|
local load=sound.load
|
|
local alive=clamp((rpm-150)/600,0,1)
|
|
sound.shift_gain=smooth_toward(sound.shift_gain,sound.shift>0 and 0.82 or 1.0,dt,0.18)
|
|
local drift_gain=1.0+0.07*drift_mix
|
|
local target=master*0.50*alive*(0.70+0.30*load)*sound.shift_gain*drift_gain
|
|
sound.gain=smooth_toward(sound.gain,target,dt,0.20)
|
|
local gain=sound.gain
|
|
local target_log=math.log(rpm/ENGINE_BASE_RPM)
|
|
sound.log_pitch=smooth_toward(sound.log_pitch or target_log,target_log,dt,0.26)
|
|
local pitch=clamp(math.exp(sound.log_pitch),0.25,3)
|
|
local pitch_b=clamp(pitch*1.005,0.25,3)
|
|
sdk.audio.update("engine_main",{
|
|
volume=gain*0.70,pitch=pitch,
|
|
})
|
|
sdk.audio.update("engine_main_b",{
|
|
volume=gain*0.30,pitch=pitch_b,
|
|
})
|
|
local boost_target=clamp((rpm-2000)/4500,0,1)*load
|
|
sound.turbo_gain=smooth_toward(sound.turbo_gain,boost_target,dt,0.24)
|
|
sound.turbo_pitch=smooth_toward(sound.turbo_pitch,clamp(0.65+rpm/9000,0.25,4),dt,0.22)
|
|
sdk.audio.update("turbo",{
|
|
volume=sdk.settings.turbo_audio==false and 0 or master*0.16*sound.turbo_gain,
|
|
pitch=sound.turbo_pitch,
|
|
})
|
|
end
|
|
-- END AUDIO EXTENSION
|
|
|
|
-- DRIVING PRESENTATION: no writes to physical body state.
|
|
local presentation = {
|
|
mode="chase", supported=false, debug=false, hud_timer=0,
|
|
speed=0, handbrake=false, active_hud=false,
|
|
}
|
|
local function clear_debug_text()
|
|
for _,key in ipairs({"skyline_help","skyline_status","skyline_handling","skyline_wheels"}) do
|
|
sdk.ui.text(key,"")
|
|
end
|
|
end
|
|
local function set_debug_visible(visible)
|
|
presentation.debug=visible
|
|
clear_debug_text()
|
|
if visible then
|
|
sdk.ui.text("skyline_help","F10 spawn | E/Y enter | RT/W gas | LT/S brake | B/Shift handbrake | RB/X up, LB/Z down | N neutral | Xbox X/C camera | H debug | R/R3 reset")
|
|
end
|
|
end
|
|
local function configure_camera()
|
|
if not state.occupied then return end
|
|
presentation.hud_timer=0
|
|
if not presentation.supported then
|
|
sdk.camera.follow(BODY,{0,2.1,-5.8})
|
|
return
|
|
end
|
|
local hood=presentation.mode=="hood"
|
|
sdk.camera.rig(BODY,{
|
|
mode=presentation.mode,
|
|
distance=clamp(number(sdk.settings.camera_distance,5.8),3.5,10),
|
|
distance_gain=1.8,height=1.9,height_gain=0.25,target_height=0.78,
|
|
look_ahead=0.12,velocity_heading=0.18,
|
|
spring_hz=clamp(number(sdk.settings.camera_spring,2.8),1,6),
|
|
heading_half_life=0.14,acceleration_lag=0.018,speed_reference=55.556,
|
|
fov=hood and 60 or clamp(number(sdk.settings.camera_fov,55),40,80),
|
|
fov_gain=hood and 0 or 8,near=hood and 0.04 or 0.07,
|
|
collision=true,collision_radius=0.28,
|
|
hood_offset={0,clamp(number(sdk.settings.hood_height,0.48),0.30,0.9),1.05},
|
|
})
|
|
end
|
|
local function clear_presentation()
|
|
sdk.camera.clear_follow()
|
|
if presentation.supported then sdk.ui.remove("driving_dashboard") end
|
|
presentation.active_hud=false;presentation.hud_timer=0
|
|
clear_debug_text()
|
|
end
|
|
-- GTA-style VFX: textured camera billboards (smoke) + persistent rubber decals (skids).
|
|
-- Transparent sort is ascending distance: lower bias draws behind, higher draws in front.
|
|
local SKID_BUFFER_OPTS={blend=true,unlit=true,texture="textures/skid_tread.png",depth_bias=-3.5,tint={0.05,0.05,0.055}}
|
|
local SMOKE_BUFFER_OPTS={blend=true,unlit=true,texture="textures/smoke_soft.png",depth_bias=4.5,tint={0.82,0.82,0.85}}
|
|
local SKID_CHUNK_MAX_VERTS=8192
|
|
local SKID_POINTS_PER_UPLOAD=24
|
|
local SKID_PTS_TAIL=32
|
|
-- Host allows 32 mesh buffers/mod; reserve smoke + remote VFX headroom.
|
|
local MAX_LOCAL_SKID_BUFFERS=12
|
|
local MAX_REMOTE_SKID_BUFFERS=6
|
|
local MAX_REMOTE_VFX_PEERS=4
|
|
local SKID_ALPHA_MIN=0.10
|
|
local SKID_ALPHA_MAX=0.72
|
|
local MAX_SMOKE_SPAWN_PER_TICK=1
|
|
local MAX_SMOKE_PARTICLES=40
|
|
local SMOKE_UPLOAD_INTERVAL=2
|
|
local REMOTE_VFX_STALE_TICKS=10
|
|
local vfx={
|
|
supported=false,skid_kappa=0.10,skid_alpha=5.0,
|
|
skid_width=0.32,min_skid_dist=0.05,skid_lift=0.012,
|
|
smoke_lifetime=1.35,smoke_size={0.40,1.45},smoke_slip_min=0.30,
|
|
smoke_mesh_live=false,smoke_dirty=false,smoke_buffers_live=false,
|
|
smoke_buffer_bound={},upload_tick=0,
|
|
remote_wheels={},remote_smoke={},remote_smoke_dirty=false,
|
|
remote_smoke_mesh_live=false,remote_vfx_cache={},
|
|
}
|
|
local REMOTE_WHEEL_KEYS={rr="wheel_rr",rl="wheel_rl"}
|
|
local function vfx_net_active()
|
|
return sdk.net and type(sdk.net.info)=="function" and (sdk.net.info().active==true)
|
|
end
|
|
local multiplayer_debug_timer=0
|
|
local function refresh_multiplayer_debug(dt)
|
|
if not sdk.capabilities or (sdk.capabilities.multiplayer_debug or 0)<1 then return end
|
|
multiplayer_debug_timer=multiplayer_debug_timer-dt
|
|
if multiplayer_debug_timer>0 then return end
|
|
multiplayer_debug_timer=0.5
|
|
local active=vfx_net_active()
|
|
local lines={
|
|
active and "Multiplayer active" or "Multiplayer offline",
|
|
state.spawned and "Local car: spawned" or "Local car: not spawned (F10)",
|
|
state.occupied and "Driver: in car" or "Driver: on foot",
|
|
vfx.supported and (vfx.mesh and "VFX: buffers ready" or "VFX: buffers pending") or "VFX: unsupported",
|
|
"Vehicle replication requires matching enabled Skyline packages on each player.",
|
|
}
|
|
sdk.ui.multiplayer_debug("replication",table.concat(lines,"\n"))
|
|
end
|
|
local function clear_mesh(mesh)
|
|
mesh.positions={};mesh.normals={};mesh.colors={};mesh.uvs={};mesh.indices={}
|
|
end
|
|
local function camera_position()
|
|
local snap=sdk.snapshot
|
|
return snap and snap.camera and vec(snap.camera.position) and snap.camera.position
|
|
end
|
|
local function billboard_axes(center,cam)
|
|
local to=sub(cam,center)
|
|
local dist=math.sqrt(dot(to,to))
|
|
if dist<1e-4 then return {1,0,0},{0,1,0},{0,0,-1} end
|
|
to=mul(to,1/dist)
|
|
local right=cross({0,1,0},to)
|
|
local rlen=math.sqrt(dot(right,right))
|
|
if rlen<1e-4 then right={1,0,0} else right=mul(right,1/rlen) end
|
|
local up=cross(to,right)
|
|
return right,up,to
|
|
end
|
|
local function push_rgba(mesh,a)
|
|
mesh.colors[#mesh.colors+1]={1,1,1,a}
|
|
end
|
|
local function append_smoke_sprite(mesh,center,right,up,fwd,size,alpha)
|
|
local hs=size*0.5
|
|
local r=mul(right,hs); local u=mul(up,hs)
|
|
local base=#mesh.positions
|
|
local n={-fwd[1],-fwd[2],-fwd[3]}
|
|
mesh.positions[#mesh.positions+1]=sub(sub(center,r),u)
|
|
mesh.positions[#mesh.positions+1]=add(sub(center,r),u)
|
|
mesh.positions[#mesh.positions+1]=add(add(center,r),u)
|
|
mesh.positions[#mesh.positions+1]=sub(add(center,r),u)
|
|
mesh.uvs[#mesh.uvs+1]={0,1}; mesh.uvs[#mesh.uvs+1]={1,1}
|
|
mesh.uvs[#mesh.uvs+1]={1,0}; mesh.uvs[#mesh.uvs+1]={0,0}
|
|
for _=1,4 do mesh.normals[#mesh.normals+1]=n; push_rgba(mesh,alpha) end
|
|
mesh.indices[#mesh.indices+1]=base; mesh.indices[#mesh.indices+1]=base+1; mesh.indices[#mesh.indices+1]=base+2
|
|
mesh.indices[#mesh.indices+1]=base; mesh.indices[#mesh.indices+1]=base+2; mesh.indices[#mesh.indices+1]=base+3
|
|
end
|
|
local function retire_skid_chunk(chunk)
|
|
if not chunk then return end
|
|
if chunk.live then sdk.graphics.remove(chunk.key) end
|
|
chunk.live=false
|
|
end
|
|
local function clear_session_skids()
|
|
for _,w in ipairs(wheels) do
|
|
if w.skid_chunks then
|
|
for _,chunk in ipairs(w.skid_chunks) do retire_skid_chunk(chunk) end
|
|
end
|
|
w.skid_chunks=nil; w.skid_pts=nil; w.skid_meshed=nil; w.skid_u=nil
|
|
w.skid_active=nil
|
|
w.smoke_accum=nil
|
|
end
|
|
vfx.skid_dirty=false
|
|
end
|
|
local function clear_smoke()
|
|
vfx.smoke=nil
|
|
vfx.smoke_dirty=false
|
|
if vfx.mesh then vfx.mesh.smoke={positions={},normals={},colors={},uvs={},indices={}} end
|
|
if vfx.smoke_mesh_live then
|
|
sdk.graphics.set_visible("vfx_smoke",false)
|
|
vfx.smoke_mesh_live=false
|
|
end
|
|
end
|
|
local function clear_remote_vfx_cache(peer)
|
|
if vfx.remote_vfx_cache then vfx.remote_vfx_cache[peer]=nil end
|
|
end
|
|
local function clear_remote_vfx(peer)
|
|
if not vfx.supported then return end
|
|
local wheels_list=vfx.remote_wheels and vfx.remote_wheels[peer]
|
|
if wheels_list then
|
|
for _,w in ipairs(wheels_list) do
|
|
if w.skid_chunks then
|
|
for _,chunk in ipairs(w.skid_chunks) do retire_skid_chunk(chunk) end
|
|
end
|
|
end
|
|
vfx.remote_wheels[peer]=nil
|
|
end
|
|
if vfx.remote_smoke then vfx.remote_smoke[peer]=nil end
|
|
clear_remote_vfx_cache(peer)
|
|
vfx.remote_smoke_dirty=true
|
|
end
|
|
local function clear_all_remote_vfx()
|
|
if not vfx.remote_wheels then return end
|
|
for peer in pairs(vfx.remote_wheels) do clear_remote_vfx(peer) end
|
|
vfx.remote_wheels={}
|
|
vfx.remote_smoke={}
|
|
vfx.remote_vfx_cache={}
|
|
vfx.remote_smoke_dirty=false
|
|
vfx.remote_smoke_mesh_live=false
|
|
if vfx.mesh then vfx.mesh.remote_smoke={positions={},normals={},colors={},uvs={},indices={}} end
|
|
end
|
|
local function invalidate_smoke_buffers()
|
|
vfx.smoke_buffer_bound={}
|
|
vfx.smoke_buffers_live=false
|
|
end
|
|
local function destroy_smoke_buffers()
|
|
if not vfx.smoke_buffers_live and not vfx.smoke_buffer_bound.vfx_smoke
|
|
and not vfx.smoke_buffer_bound.vfx_smoke_remote then return end
|
|
if vfx.supported then
|
|
sdk.graphics.remove("vfx_smoke")
|
|
sdk.graphics.remove("vfx_smoke_remote")
|
|
end
|
|
invalidate_smoke_buffers()
|
|
vfx.smoke_mesh_live=false
|
|
vfx.remote_smoke_mesh_live=false
|
|
vfx.mesh=nil
|
|
end
|
|
local function clear_effects()
|
|
clear_smoke(); clear_all_remote_vfx()
|
|
end
|
|
local function flatten_forward(fwd,normal)
|
|
if not vec(fwd) or not vec(normal) then return fwd end
|
|
local flat=sub(fwd,mul(normal,dot(fwd,normal)))
|
|
return norm(flat) or fwd
|
|
end
|
|
local function segment_forward(pt_a,pt_b)
|
|
if not pt_a or not pt_b or not vec(pt_a.pos) or not vec(pt_b.pos) then return nil end
|
|
return flatten_forward(sub(pt_b.pos,pt_a.pos),pt_b.normal or pt_a.normal)
|
|
end
|
|
local function new_skid_delta()
|
|
return {positions={},uvs={},indices={}}
|
|
end
|
|
local SKID_TEX_REPEAT=1.8
|
|
local function push_skid_vertex_color(delta,alpha)
|
|
delta.colors=delta.colors or {}
|
|
local a=clamp(number(alpha,SKID_ALPHA_MIN),SKID_ALPHA_MIN,SKID_ALPHA_MAX)
|
|
delta.colors[#delta.colors+1]=1
|
|
delta.colors[#delta.colors+1]=1
|
|
delta.colors[#delta.colors+1]=1
|
|
delta.colors[#delta.colors+1]=a
|
|
end
|
|
local function sane_point(p)
|
|
if not vec(p) then return false end
|
|
for i=1,3 do
|
|
local v=p[i]
|
|
if v~=v or v<-100000 or v>100000 then return false end
|
|
end
|
|
return true
|
|
end
|
|
local function skid_uv_u(u)
|
|
-- Host rejects UVs outside [-1000, 1000]; wrap so long drifts stay valid.
|
|
local v=number(u,0)%64.0
|
|
if v<-1000 then v=-1000 elseif v>1000 then v=1000 end
|
|
return v
|
|
end
|
|
local function push_cross_section(delta,pt,forward,width)
|
|
local lateral=norm(cross(forward,pt.normal)); if not lateral then return false end
|
|
if not sane_point(pt.pos) then return false end
|
|
local half=width*0.5
|
|
local u=skid_uv_u(pt.u)
|
|
local a=pt.intensity or SKID_ALPHA_MIN
|
|
delta.positions[#delta.positions+1]=add(pt.pos,mul(lateral,half))
|
|
push_skid_vertex_color(delta,a)
|
|
delta.uvs[#delta.uvs+1]=u; delta.uvs[#delta.uvs+1]=0
|
|
delta.positions[#delta.positions+1]=sub(pt.pos,mul(lateral,half))
|
|
push_skid_vertex_color(delta,a)
|
|
delta.uvs[#delta.uvs+1]=u; delta.uvs[#delta.uvs+1]=1
|
|
return true
|
|
end
|
|
local function active_skid_chunk(w)
|
|
local chunks=w.skid_chunks
|
|
if not chunks then return nil end
|
|
local chunk=chunks[#chunks]
|
|
if chunk and not chunk.sealed then return chunk end
|
|
return nil
|
|
end
|
|
local function reset_skid_pending(w)
|
|
local chunk=active_skid_chunk(w)
|
|
if chunk then chunk.pending=new_skid_delta() end
|
|
end
|
|
local function skid_delta_valid(chunk,delta)
|
|
if not delta or #delta.positions==0 then return true end
|
|
if #delta.indices<3 then return false end
|
|
if delta.colors and #delta.colors~=#delta.positions*4 then return false end
|
|
if delta.uvs and #delta.uvs~=#delta.positions*2 then return false end
|
|
local total=chunk.verts+#delta.positions
|
|
for _,idx in ipairs(delta.indices) do
|
|
if idx<chunk.verts or idx>=total then return false end
|
|
end
|
|
return total<=SKID_CHUNK_MAX_VERTS
|
|
end
|
|
local function flush_skid_delta(chunk)
|
|
local delta=chunk.pending
|
|
if not delta or #delta.positions==0 then return true end
|
|
if #delta.indices<3 then
|
|
chunk.pending=new_skid_delta()
|
|
return true
|
|
end
|
|
if not skid_delta_valid(chunk,delta) then
|
|
chunk.pending=new_skid_delta()
|
|
return false
|
|
end
|
|
if not chunk.live then
|
|
sdk.graphics.mesh_buffer(chunk.key,SKID_BUFFER_OPTS)
|
|
chunk.live=true
|
|
vfx.skid_dirty=true
|
|
end
|
|
sdk.graphics.mesh_buffer_append(chunk.key,delta)
|
|
chunk.verts=chunk.verts+#delta.positions
|
|
chunk.pending=new_skid_delta()
|
|
return true
|
|
end
|
|
local function iter_skid_wheels(remote)
|
|
local list={}
|
|
if remote then
|
|
for _,wheels_list in pairs(vfx.remote_wheels or {}) do
|
|
for _,w in ipairs(wheels_list) do list[#list+1]=w end
|
|
end
|
|
else
|
|
for _,w in ipairs(wheels) do list[#list+1]=w end
|
|
end
|
|
return list
|
|
end
|
|
local function count_skid_buffers(remote)
|
|
local n=0
|
|
for _,w in ipairs(iter_skid_wheels(remote)) do
|
|
if w.skid_chunks then
|
|
for _,chunk in ipairs(w.skid_chunks) do
|
|
if chunk.live then n=n+1 end
|
|
end
|
|
end
|
|
end
|
|
return n
|
|
end
|
|
local function evict_oldest_sealed_skid(remote)
|
|
local victim_wheel,victim_idx=nil,nil
|
|
local oldest=math.huge
|
|
for _,w in ipairs(iter_skid_wheels(remote)) do
|
|
local chunks=w.skid_chunks
|
|
if chunks then
|
|
for i,chunk in ipairs(chunks) do
|
|
if chunk.sealed and (chunk.serial or 0)<oldest then
|
|
oldest=chunk.serial or 0
|
|
victim_wheel=w
|
|
victim_idx=i
|
|
end
|
|
end
|
|
end
|
|
end
|
|
if not victim_wheel then return false end
|
|
local chunk=victim_wheel.skid_chunks[victim_idx]
|
|
retire_skid_chunk(chunk)
|
|
table.remove(victim_wheel.skid_chunks,victim_idx)
|
|
return true
|
|
end
|
|
local function recover_skid_chunk(w)
|
|
local chunk=active_skid_chunk(w)
|
|
if not chunk then return nil end
|
|
reset_skid_pending(w)
|
|
seal_skid_chunk(chunk)
|
|
return ensure_wheel_skid_chunk(w)
|
|
end
|
|
local function append_skid_segment(chunk,pt_a,pt_b,width)
|
|
local fwd=segment_forward(pt_a,pt_b); if not fwd then return false end
|
|
local base=chunk.verts
|
|
chunk.pending=new_skid_delta()
|
|
if not push_cross_section(chunk.pending,pt_a,fwd,width) then return false end
|
|
if not push_cross_section(chunk.pending,pt_b,fwd,width) then return false end
|
|
chunk.pending.indices={base,base+1,base+2,base+1,base+3,base+2}
|
|
return flush_skid_delta(chunk)
|
|
end
|
|
local function seal_skid_chunk(chunk)
|
|
if chunk.sealed then return end
|
|
chunk.pending=new_skid_delta()
|
|
chunk.pending=nil
|
|
chunk.sealed=true
|
|
end
|
|
local function end_skid_stroke(w)
|
|
w.skid_active=false
|
|
w.skid_pts={}
|
|
w.skid_meshed=0
|
|
w.skid_u=0
|
|
reset_skid_pending(w)
|
|
end
|
|
local function ensure_wheel_skid_chunk(w)
|
|
w.skid_chunks=w.skid_chunks or {}
|
|
local chunk=w.skid_chunks[#w.skid_chunks]
|
|
if chunk and not chunk.sealed then return chunk end
|
|
local remote=w.remote_peer~=nil
|
|
local cap=remote and MAX_REMOTE_SKID_BUFFERS or MAX_LOCAL_SKID_BUFFERS
|
|
while count_skid_buffers(remote)>=cap do
|
|
if not evict_oldest_sealed_skid(remote) then return nil end
|
|
end
|
|
w.skid_chunk_serial=(w.skid_chunk_serial or 0)+1
|
|
local serial=w.skid_chunk_serial
|
|
local key=serial==1 and ("vfx_skid_"..w.key) or ("vfx_skid_"..w.key.."_"..serial)
|
|
vfx.skid_buffer_serial=(vfx.skid_buffer_serial or 0)+1
|
|
chunk={
|
|
key=key,verts=0,pending=new_skid_delta(),sealed=false,live=false,
|
|
serial=vfx.skid_buffer_serial,
|
|
}
|
|
w.skid_chunks[#w.skid_chunks+1]=chunk
|
|
return chunk
|
|
end
|
|
local function trim_meshed_skid_points(w)
|
|
local pts=w.skid_pts
|
|
if not pts then return end
|
|
local meshed=w.skid_meshed or 0
|
|
while meshed>2 and #pts>SKID_PTS_TAIL do
|
|
table.remove(pts,1)
|
|
meshed=meshed-1
|
|
end
|
|
w.skid_meshed=meshed
|
|
end
|
|
local function append_wheel_skids(w)
|
|
local pts=w.skid_pts
|
|
if not pts or #pts<2 then return end
|
|
local meshed=w.skid_meshed or 0
|
|
if meshed<1 then meshed=1 end
|
|
local budget=SKID_POINTS_PER_UPLOAD
|
|
while meshed<#pts and budget>0 do
|
|
local chunk=ensure_wheel_skid_chunk(w)
|
|
if not chunk then break end
|
|
if chunk.verts+4>SKID_CHUNK_MAX_VERTS then
|
|
reset_skid_pending(w)
|
|
seal_skid_chunk(chunk)
|
|
chunk=ensure_wheel_skid_chunk(w)
|
|
if not chunk then break end
|
|
end
|
|
if not append_skid_segment(chunk,pts[meshed],pts[meshed+1],vfx.skid_width) then
|
|
reset_skid_pending(w)
|
|
chunk=recover_skid_chunk(w)
|
|
if not chunk then break end
|
|
if not append_skid_segment(chunk,pts[meshed],pts[meshed+1],vfx.skid_width) then break end
|
|
end
|
|
meshed=meshed+1
|
|
w.skid_meshed=meshed
|
|
budget=budget-1
|
|
end
|
|
trim_meshed_skid_points(w)
|
|
if meshed<#pts then vfx.skid_dirty=true end
|
|
end
|
|
local function maybe_upload_skids()
|
|
if not vfx.skid_dirty or not vfx.supported then return end
|
|
for _,w in ipairs(wheels) do append_wheel_skids(w) end
|
|
for _,wheels_list in pairs(vfx.remote_wheels or {}) do
|
|
for _,w in ipairs(wheels_list) do append_wheel_skids(w) end
|
|
end
|
|
local pending=false
|
|
local function pending_wheel(w)
|
|
local pts=w.skid_pts
|
|
return pts and (w.skid_meshed or 0)<#pts
|
|
end
|
|
for _,w in ipairs(wheels) do
|
|
if pending_wheel(w) then pending=true; break end
|
|
end
|
|
if not pending then
|
|
for _,wheels_list in pairs(vfx.remote_wheels or {}) do
|
|
for _,w in ipairs(wheels_list) do
|
|
if pending_wheel(w) then pending=true; break end
|
|
end
|
|
if pending then break end
|
|
end
|
|
end
|
|
vfx.skid_dirty=pending
|
|
end
|
|
local function fill_smoke_list(mesh,smoke,cam)
|
|
if not smoke or #smoke==0 then return false end
|
|
local right,up,fwd=billboard_axes(smoke[#smoke].pos,cam)
|
|
local count=0
|
|
for i=1,#smoke do
|
|
local p=smoke[i]
|
|
local t=clamp(p.age/p.life,0,1)
|
|
local fade=(1-t)^2.85
|
|
local strength=clamp(p.intensity or 0.5,0,1)
|
|
local birth=0.28+0.72*t
|
|
local alpha=fade*strength*birth
|
|
if t>=1 or alpha<=0 then goto next end
|
|
local s=p.size*(0.60+2.05*(t^0.72))
|
|
append_smoke_sprite(mesh,p.pos,right,up,fwd,s,clamp(alpha*0.95,0,0.68))
|
|
count=count+1
|
|
::next::
|
|
end
|
|
return count>0
|
|
end
|
|
local function fill_smoke_mesh(mesh,cam)
|
|
clear_mesh(mesh)
|
|
return fill_smoke_list(mesh,vfx.smoke,cam)
|
|
end
|
|
local function fill_remote_smoke_mesh(mesh,cam)
|
|
clear_mesh(mesh)
|
|
local count=0
|
|
for _,smoke in pairs(vfx.remote_smoke or {}) do
|
|
if fill_smoke_list(mesh,smoke,cam) then count=count+1 end
|
|
end
|
|
return count>0
|
|
end
|
|
local function remote_smoke_intensity(slip)
|
|
if slip<(vfx.smoke_slip_min or 0.30) then return 0 end
|
|
local t=clamp((slip-(vfx.smoke_slip_min or 0.30))/(4.0-(vfx.smoke_slip_min or 0.30)),0,1)
|
|
return clamp(t*t*14,0,14)
|
|
end
|
|
local function remote_skid_intensity(slip)
|
|
if slip<0.08 then return SKID_ALPHA_MIN end
|
|
local t=clamp(slip/4.0,0,1)
|
|
return SKID_ALPHA_MIN+(SKID_ALPHA_MAX-SKID_ALPHA_MIN)*t*t
|
|
end
|
|
local function wheel_slip(kappa,alpha,usage)
|
|
return math.max(kappa,alpha*0.06,math.max(0,usage-0.62)*1.8)
|
|
end
|
|
local function wheel_skid_intensity(kappa,alpha,usage)
|
|
local slip=wheel_slip(kappa,alpha,usage)
|
|
if slip<0.08 then return SKID_ALPHA_MIN end
|
|
local t=clamp(slip/4.0,0,1)
|
|
return SKID_ALPHA_MIN+(SKID_ALPHA_MAX-SKID_ALPHA_MIN)*t*t
|
|
end
|
|
local function push_skid_point(w,pos,normal,intensity)
|
|
if not sane_point(pos) or not vec(normal) then return end
|
|
local pts=w.skid_pts or {}
|
|
w.skid_pts=pts
|
|
local u=w.skid_u or 0
|
|
local last=pts[#pts]
|
|
intensity=clamp(number(intensity,SKID_ALPHA_MIN),SKID_ALPHA_MIN,SKID_ALPHA_MAX)
|
|
if last and vec(last.pos) then
|
|
local d=sub(pos,last.pos)
|
|
local dist_sq=dot(d,d)
|
|
local min=vfx.min_skid_dist
|
|
if dist_sq<min*min then return end
|
|
local dist=math.sqrt(dist_sq)
|
|
local du=dist*SKID_TEX_REPEAT
|
|
local i0=last.intensity or SKID_ALPHA_MIN
|
|
if dist>min*2 then
|
|
local steps=math.min(math.floor(dist/min)-1,6)
|
|
for s=1,steps do
|
|
local t=s/(steps+1)
|
|
pts[#pts+1]={
|
|
pos=add(last.pos,mul(d,t)),normal=normal,u=u+du*t,
|
|
intensity=i0+(intensity-i0)*t,
|
|
}
|
|
end
|
|
end
|
|
u=u+du
|
|
end
|
|
w.skid_u=u
|
|
pts[#pts+1]={pos=pos,normal=normal,u=u,intensity=intensity}
|
|
vfx.skid_dirty=true
|
|
end
|
|
local function wheel_smoke_intensity(kappa,alpha,usage)
|
|
local slip=wheel_slip(kappa,alpha,usage)
|
|
local min_slip=vfx.smoke_slip_min or 0.30
|
|
if slip<min_slip then return 0 end
|
|
local t=clamp((slip-min_slip)/(4.0-min_slip),0,1)
|
|
return clamp(t*t*14,0,14)
|
|
end
|
|
local function smoke_bucket(w)
|
|
if w.remote_peer then
|
|
vfx.remote_smoke=vfx.remote_smoke or {}
|
|
local bucket=vfx.remote_smoke[w.remote_peer]
|
|
if not bucket then
|
|
bucket={}
|
|
vfx.remote_smoke[w.remote_peer]=bucket
|
|
end
|
|
return bucket
|
|
end
|
|
vfx.smoke=vfx.smoke or {}
|
|
return vfx.smoke
|
|
end
|
|
local function trim_smoke_bucket(bucket)
|
|
while bucket and #bucket>MAX_SMOKE_PARTICLES do
|
|
table.remove(bucket,1)
|
|
end
|
|
end
|
|
local function spawn_smoke(w,pos,intensity,dt)
|
|
if intensity<=0 or not sane_point(pos) then return end
|
|
w.smoke_accum=(w.smoke_accum or 0)+intensity*dt*8
|
|
local spawned=0
|
|
local bucket=smoke_bucket(w)
|
|
while w.smoke_accum>=0.32 and spawned<MAX_SMOKE_SPAWN_PER_TICK do
|
|
w.smoke_accum=w.smoke_accum-0.24
|
|
spawned=spawned+1
|
|
local t=clamp(intensity/14,0.12,1)
|
|
local sz=vfx.smoke_size[1]+(vfx.smoke_size[2]-vfx.smoke_size[1])*t
|
|
local spread=0.02*t
|
|
bucket[#bucket+1]={
|
|
pos=add(pos,{
|
|
(math.random()-0.5)*spread,
|
|
(math.random()-0.5)*spread*0.25,
|
|
(math.random()-0.5)*spread,
|
|
}),
|
|
vel={0,0.48+0.62*t,0},
|
|
age=0,life=vfx.smoke_lifetime,
|
|
size=sz*(0.80+0.20*math.random())*(0.76+0.30*t),
|
|
intensity=t,
|
|
}
|
|
if w.remote_peer then vfx.remote_smoke_dirty=true else vfx.smoke_dirty=true end
|
|
end
|
|
trim_smoke_bucket(bucket)
|
|
end
|
|
local function tick_smoke_list(smoke,dt)
|
|
if not smoke or #smoke==0 then return false end
|
|
local removed=false
|
|
for i=#smoke,1,-1 do
|
|
local p=smoke[i]
|
|
p.age=p.age+dt
|
|
if p.age>=p.life then
|
|
table.remove(smoke,i)
|
|
removed=true
|
|
else
|
|
local lift=0.55+(p.intensity or 0.5)*0.85
|
|
p.vel[2]=p.vel[2]+lift*dt
|
|
p.pos=add(p.pos,mul(p.vel,dt))
|
|
end
|
|
end
|
|
return removed
|
|
end
|
|
local function smoke_active()
|
|
if vfx.smoke and #vfx.smoke>0 then return true end
|
|
for _,smoke in pairs(vfx.remote_smoke or {}) do
|
|
if smoke and #smoke>0 then return true end
|
|
end
|
|
return false
|
|
end
|
|
local function tick_all_smoke(dt)
|
|
if tick_smoke_list(vfx.smoke,dt) then vfx.smoke_dirty=true end
|
|
for _,smoke in pairs(vfx.remote_smoke or {}) do
|
|
if tick_smoke_list(smoke,dt) then vfx.remote_smoke_dirty=true end
|
|
end
|
|
vfx.upload_tick=(vfx.upload_tick or 0)+1
|
|
if smoke_active() and vfx.upload_tick%SMOKE_UPLOAD_INTERVAL==0 then
|
|
vfx.smoke_dirty=true
|
|
vfx.remote_smoke_dirty=true
|
|
end
|
|
end
|
|
local function hide_smoke_mesh()
|
|
if not vfx.smoke_mesh_live then
|
|
vfx.smoke_dirty=false
|
|
return
|
|
end
|
|
sdk.graphics.set_visible("vfx_smoke",false)
|
|
vfx.smoke_mesh_live=false
|
|
vfx.smoke_dirty=false
|
|
end
|
|
local function hide_remote_smoke_mesh()
|
|
if not vfx.remote_smoke_mesh_live then
|
|
vfx.remote_smoke_dirty=false
|
|
return
|
|
end
|
|
sdk.graphics.set_visible("vfx_smoke_remote",false)
|
|
vfx.remote_smoke_mesh_live=false
|
|
vfx.remote_smoke_dirty=false
|
|
end
|
|
local function new_remote_wheel(peer,short)
|
|
return {
|
|
key="net_"..peer.."_"..short,remote_peer=peer,front=false,
|
|
skid_chunks={},skid_pts={},skid_meshed=0,skid_u=0,
|
|
skid_active=false,skid_off_acc=0,skid_chunk_serial=0,smoke_accum=0,
|
|
}
|
|
end
|
|
local function ensure_remote_wheels(peer)
|
|
vfx.remote_wheels=vfx.remote_wheels or {}
|
|
local list=vfx.remote_wheels[peer]
|
|
if list then return list end
|
|
list={new_remote_wheel(peer,"rr"),new_remote_wheel(peer,"rl")}
|
|
vfx.remote_wheels[peer]=list
|
|
return list
|
|
end
|
|
local function apply_wheel_vfx(w,kappa,alpha,usage,tread,gn,dt)
|
|
local skidding=kappa>vfx.skid_kappa or alpha>vfx.skid_alpha or usage>0.68
|
|
if not skidding or not vec(tread) or not vec(gn) then return end
|
|
w.skidding_now=true
|
|
if not w.skid_active then
|
|
w.skid_active=true
|
|
w.skid_pts={}
|
|
w.skid_meshed=0
|
|
reset_skid_pending(w)
|
|
end
|
|
push_skid_point(w,add(tread,mul(gn,vfx.skid_lift)),gn,wheel_skid_intensity(kappa,alpha,usage))
|
|
if not w.front then
|
|
spawn_smoke(w,add(tread,mul(gn,0.004)),wheel_smoke_intensity(kappa,alpha,usage),dt)
|
|
end
|
|
end
|
|
local function apply_remote_wheel_vfx(w,slip,tread,gn,dt)
|
|
local smoke_i=remote_smoke_intensity(slip)
|
|
if smoke_i<=0 or not vec(tread) or not vec(gn) then return end
|
|
w.skidding_now=true
|
|
if not w.skid_active then
|
|
w.skid_active=true
|
|
w.skid_pts={}
|
|
w.skid_meshed=0
|
|
reset_skid_pending(w)
|
|
end
|
|
push_skid_point(w,add(tread,mul(gn,vfx.skid_lift)),gn,remote_skid_intensity(slip))
|
|
spawn_smoke(w,add(tread,mul(gn,0.004)),smoke_i,dt)
|
|
end
|
|
local function remote_vfx_sample_key(sample)
|
|
if type(sample)~="table" or #sample<7 then return "" end
|
|
return string.format("%.2f|%.2f|%.2f",sample[2],sample[3],sample[4])
|
|
end
|
|
local function remote_vfx_data_key(data)
|
|
local parts={}
|
|
for short in pairs(REMOTE_WHEEL_KEYS) do
|
|
parts[#parts+1]=short.."="..remote_vfx_sample_key(data[short])
|
|
end
|
|
table.sort(parts)
|
|
return table.concat(parts,";")
|
|
end
|
|
local function remote_vfx_stale(peer,data)
|
|
local key=remote_vfx_data_key(data)
|
|
local cache=vfx.remote_vfx_cache[peer]
|
|
if not cache or cache.key~=key then
|
|
vfx.remote_vfx_cache[peer]={key=key,ticks=0}
|
|
return false
|
|
end
|
|
cache.ticks=cache.ticks+1
|
|
return cache.ticks>REMOTE_VFX_STALE_TICKS
|
|
end
|
|
local function publish_net_vfx(contacts)
|
|
if not vfx_net_active() or not state.spawned or not sdk.net.publish then return end
|
|
local packet={}
|
|
for _,c in ipairs(contacts) do
|
|
local w=c.wheel
|
|
if w and not w.front then
|
|
local short=w.key=="wheel_rr" and "rr" or (w.key=="wheel_rl" and "rl" or nil)
|
|
if short then
|
|
local slip=wheel_slip(math.abs(w.kappa or 0),math.abs(w.alpha or 0),w.usage or 0)
|
|
if slip>=(vfx.smoke_slip_min or 0.30)*0.85 then
|
|
local tread=c.point
|
|
local gn=c.ground_n or (c.n and c.n.axis)
|
|
if sane_point(tread) and vec(gn) then
|
|
packet[short]={slip,tread[1],tread[2],tread[3],gn[1],gn[2],gn[3]}
|
|
end
|
|
end
|
|
end
|
|
end
|
|
end
|
|
if next(packet) then sdk.net.publish("vfx",packet) else sdk.net.publish("vfx",nil) end
|
|
end
|
|
local function tick_remote_vfx(dt)
|
|
if not vfx_net_active() or not vfx.supported then
|
|
if vfx.remote_wheels and next(vfx.remote_wheels) then clear_all_remote_vfx() end
|
|
return
|
|
end
|
|
local info=sdk.net.info()
|
|
local states=((info.states or {})[sdk.mod_id] or {})
|
|
local live={}
|
|
local peer_count=0
|
|
for peer,peer_states in pairs(states) do
|
|
if tostring(peer)~=tostring(info.local_id) and type(peer_states)=="table" then
|
|
local data=peer_states.vfx
|
|
if type(data)=="table" and next(data) then
|
|
peer_count=peer_count+1
|
|
if peer_count>MAX_REMOTE_VFX_PEERS then break end
|
|
live[peer]=true
|
|
local wheels_list=ensure_remote_wheels(peer)
|
|
local stale=remote_vfx_stale(peer,data)
|
|
for _,w in ipairs(wheels_list) do w.skidding_now=false end
|
|
if not stale then
|
|
for short,wheel_key in pairs(REMOTE_WHEEL_KEYS) do
|
|
local sample=data[short]
|
|
if type(sample)=="table" and #sample>=7 then
|
|
local slip=number(sample[1],0)
|
|
local tread={sample[2],sample[3],sample[4]}
|
|
local gn={sample[5],sample[6],sample[7]}
|
|
local w=nil
|
|
for _,candidate in ipairs(wheels_list) do
|
|
if candidate.key=="net_"..peer.."_"..short then w=candidate; break end
|
|
end
|
|
if w then apply_remote_wheel_vfx(w,slip,tread,gn,dt) end
|
|
end
|
|
end
|
|
end
|
|
for _,w in ipairs(wheels_list) do
|
|
if w.skidding_now then
|
|
w.skid_off_acc=0
|
|
elseif w.skid_active then
|
|
w.skid_off_acc=(w.skid_off_acc or 0)+dt
|
|
if w.skid_off_acc>=0.15 then end_skid_stroke(w) end
|
|
end
|
|
end
|
|
end
|
|
end
|
|
end
|
|
for peer in pairs(vfx.remote_wheels or {}) do
|
|
if not live[peer] then clear_remote_vfx(peer) end
|
|
end
|
|
end
|
|
local function refresh_vfx_support()
|
|
vfx.supported=sdk.graphics and (sdk.graphics.version or 0)>=4
|
|
and type(sdk.graphics.mesh_buffer)=="function"
|
|
and type(sdk.graphics.mesh_buffer_append)=="function"
|
|
return vfx.supported
|
|
end
|
|
local function bind_smoke_buffer(key)
|
|
vfx.smoke_buffer_bound=vfx.smoke_buffer_bound or {}
|
|
if vfx.smoke_buffer_bound[key] then return end
|
|
sdk.graphics.mesh_buffer(key,SMOKE_BUFFER_OPTS)
|
|
vfx.smoke_buffer_bound[key]=true
|
|
vfx.smoke_buffers_live=true
|
|
end
|
|
local function write_smoke_mesh(key,mesh)
|
|
bind_smoke_buffer(key)
|
|
sdk.graphics.mesh_buffer_write(key,mesh)
|
|
end
|
|
local function ensure_vfx_mesh()
|
|
if not refresh_vfx_support() then return false end
|
|
vfx.smoke=vfx.smoke or {}
|
|
vfx.remote_smoke=vfx.remote_smoke or {}
|
|
vfx.remote_wheels=vfx.remote_wheels or {}
|
|
if not vfx.mesh then
|
|
vfx.mesh={
|
|
smoke={positions={},normals={},colors={},uvs={},indices={}},
|
|
remote_smoke={positions={},normals={},colors={},uvs={},indices={}},
|
|
}
|
|
end
|
|
return true
|
|
end
|
|
local function prepare_effects()
|
|
if not refresh_vfx_support() then return end
|
|
clear_session_skids()
|
|
vfx.skid_buffer_serial=0
|
|
vfx.smoke={}; vfx.smoke_mesh_live=false; vfx.smoke_dirty=false
|
|
vfx.remote_wheels={}; vfx.remote_smoke={}; vfx.remote_vfx_cache={}
|
|
vfx.remote_smoke_dirty=false; vfx.remote_smoke_mesh_live=false; vfx.upload_tick=0
|
|
invalidate_smoke_buffers()
|
|
vfx.mesh={
|
|
smoke={positions={},normals={},colors={},uvs={},indices={}},
|
|
remote_smoke={positions={},normals={},colors={},uvs={},indices={}},
|
|
}
|
|
for _,w in ipairs(wheels) do
|
|
w.skid_chunks={}
|
|
w.skid_pts={}
|
|
w.skid_meshed=0
|
|
w.skid_u=0; w.skid_active=false; w.skid_off_acc=0
|
|
w.skid_chunk_serial=0
|
|
w.smoke_accum=0
|
|
end
|
|
end
|
|
local function tick_wheel_vfx(contacts,dt)
|
|
if not vfx.supported then return end
|
|
dt=clamp(number(dt,1/60),0,0.1)
|
|
for _,w in ipairs(wheels) do w.skidding_now=false end
|
|
for _,c in ipairs(contacts) do
|
|
local w=c.wheel
|
|
local kappa=math.abs(w.kappa or 0)
|
|
local alpha=math.abs(w.alpha or 0)
|
|
local usage=w.usage or 0
|
|
local tread=c.point
|
|
local gn=c.ground_n or (c.n and c.n.axis)
|
|
if not w.front and vec(tread) then
|
|
sound.drift_slip=math.max(sound.drift_slip,wheel_slip(kappa,alpha,usage))
|
|
end
|
|
apply_wheel_vfx(w,kappa,alpha,usage,tread,gn,dt)
|
|
end
|
|
for _,w in ipairs(wheels) do
|
|
if w.skidding_now then
|
|
w.skid_off_acc=0
|
|
elseif w.skid_active then
|
|
w.skid_off_acc=(w.skid_off_acc or 0)+dt
|
|
if w.skid_off_acc>=0.15 then end_skid_stroke(w) end
|
|
end
|
|
end
|
|
maybe_upload_skids()
|
|
publish_net_vfx(contacts)
|
|
end
|
|
local function upload_wheel_vfx(event)
|
|
if not ensure_vfx_mesh() then return end
|
|
if sdk.snapshot.paused or sdk.snapshot.replay then return end
|
|
local dt=clamp(number(event and event.dt,1/60),0,0.1)
|
|
tick_remote_vfx(dt)
|
|
tick_all_smoke(dt)
|
|
local cam=camera_position(); if not cam then return end
|
|
if state.spawned then
|
|
local smoke=vfx.smoke
|
|
if not smoke or #smoke==0 then
|
|
hide_smoke_mesh()
|
|
elseif vfx.smoke_dirty then
|
|
if fill_smoke_mesh(vfx.mesh.smoke,cam) then
|
|
write_smoke_mesh("vfx_smoke",vfx.mesh.smoke)
|
|
sdk.graphics.set_visible("vfx_smoke",true)
|
|
vfx.smoke_mesh_live=true
|
|
vfx.smoke_dirty=false
|
|
elseif vfx.smoke_mesh_live then
|
|
hide_smoke_mesh()
|
|
end
|
|
end
|
|
else
|
|
hide_smoke_mesh()
|
|
end
|
|
if vfx_net_active() then
|
|
local has_remote=false
|
|
for _,smoke in pairs(vfx.remote_smoke or {}) do
|
|
if smoke and #smoke>0 then has_remote=true; break end
|
|
end
|
|
if not has_remote then
|
|
hide_remote_smoke_mesh()
|
|
elseif vfx.remote_smoke_dirty then
|
|
if fill_remote_smoke_mesh(vfx.mesh.remote_smoke,cam) then
|
|
write_smoke_mesh("vfx_smoke_remote",vfx.mesh.remote_smoke)
|
|
sdk.graphics.set_visible("vfx_smoke_remote",true)
|
|
vfx.remote_smoke_mesh_live=true
|
|
vfx.remote_smoke_dirty=false
|
|
elseif vfx.remote_smoke_mesh_live then
|
|
hide_remote_smoke_mesh()
|
|
end
|
|
end
|
|
else
|
|
hide_remote_smoke_mesh()
|
|
clear_all_remote_vfx()
|
|
end
|
|
end
|
|
local function prepare_presentation()
|
|
presentation.supported=type(sdk.camera.rig)=="function" and type(sdk.ui.canvas)=="function"
|
|
set_debug_visible(sdk.settings.show_driving_debug==true)
|
|
if not presentation.supported then
|
|
sdk.log("Skyline: camera/canvas extension missing. Rebuild with the supplied Driving API source patch.")
|
|
sdk.ui.text("skyline_presentation", "")
|
|
else sdk.ui.text("skyline_presentation","") end
|
|
end
|
|
local function update_dashboard(event)
|
|
if not presentation.supported then return end
|
|
if not state.occupied or not state.spawned or sdk.settings.show_speedometer==false then
|
|
if presentation.active_hud then sdk.ui.remove("driving_dashboard");presentation.active_hud=false end
|
|
return
|
|
end
|
|
if sdk.snapshot.paused or sdk.snapshot.replay then return end
|
|
local dt=clamp(number(event and event.dt,1/60),0,0.1)
|
|
presentation.hud_timer=presentation.hud_timer-dt
|
|
if presentation.hud_timer>0 then return end
|
|
presentation.hud_timer=0.05
|
|
local mph=sdk.settings.speed_mph==true
|
|
local speed=math.floor(math.max(0,presentation.speed)*(mph and 2.236936292 or 3.6)+0.5)
|
|
local rpm=clamp(number(state.rpm,0),0,12000)
|
|
local amount=clamp(rpm/C.redline_rpm,0,1)
|
|
local hot=amount>0.94
|
|
local accent=hot and {1.0,0.27,0.18,1} or {0.23,0.79,0.98,1}
|
|
local white={0.96,0.98,1.0,1};local muted={0.60,0.67,0.75,1}
|
|
local items={}
|
|
local function rect(key,x,y,w,h,color)
|
|
items[#items+1]={key=key,type="rect",position={x,y},size={w,h},color=color}
|
|
end
|
|
local function text(key,x,y,w,h,value,size,color)
|
|
items[#items+1]={key=key,type="text",position={x,y},size={w,h},text=value,font_size=size,color=color}
|
|
end
|
|
rect("back",0,0,344,180,{0.018,0.026,0.041,0.88})
|
|
rect("accent",0,0,344,3,accent)
|
|
text("title",16,12,180,20,"SKYLINE",13,muted)
|
|
text("view",250,12,78,20,presentation.mode=="hood" and "HOOD" or "CHASE",13,muted)
|
|
text("speed",14,31,200,78,tostring(speed),64,white)
|
|
text("unit",180,82,68,22,mph and "MPH" or "KM/H",13,muted)
|
|
rect("gear_box",258,39,70,68,{0.075,0.105,0.15,1})
|
|
text("gear",277,39,47,65,gear_label(),50,white)
|
|
text("gear_label",267,109,61,17,"GEAR",10,muted)
|
|
text("rpm",16,115,158,22,string.format("%d RPM",math.floor(rpm/10+0.5)*10),15,white)
|
|
text("brake",170,115,82,22,presentation.handbrake and "BRAKE" or (hot and "SHIFT" or ""),13,
|
|
{1,0.39,0.25,1})
|
|
rect("rpm_bg",16,144,312,9,{0.13,0.18,0.23,1})
|
|
rect("rpm_fill",16,144,312*amount,9,accent)
|
|
rect("redline_tick",16+312*0.94,141,2,15,{1,0.31,0.20,1})
|
|
text("hint",16,161,312,16,"X / C CAMERA",10,muted)
|
|
sdk.ui.canvas("driving_dashboard",{
|
|
anchor="bottom_left",offset={24,28},size={344,180},
|
|
scale=clamp(number(sdk.settings.dashboard_scale,1),0.65,1.5),visible=true,items=items,
|
|
})
|
|
presentation.active_hud=true
|
|
end
|
|
-- END DRIVING PRESENTATION
|
|
|
|
local function reset_simulation()
|
|
state.gear=1; state.rpm=C.idle_rpm; state.steer=0; state.shift=0; state.keyboard_axis=0; state.aero_load=0
|
|
state.engine_omega=C.idle_rpm*math.pi/30; state.clutch=0; state.mass_audit=nil
|
|
for _,w in ipairs(wheels) do
|
|
w.omega=0; w.load=0; w.length=C.ride_length; w.contact=false; w.slip=0
|
|
w.spin=0; w.previous_omega=0; w.visual_steer=0; w.visual_y=0
|
|
-- Front/rear rates match STATIC MOMENT BALANCE, not an equal-load floor.
|
|
local fraction=w.front and ((C.center[3]+1.34703)/wheelbase)
|
|
or ((1.44360-C.center[3])/wheelbase)
|
|
w.static_load=C.mass*C.gravity*fraction/2
|
|
w.stiffness=w.front and C.front_spring or C.rear_spring
|
|
w.rest_length=C.ride_length+w.static_load/w.stiffness
|
|
w.drive_share=(w.front and C.front_drive or 1-C.front_drive)/2
|
|
w.alpha=0; w.kappa=0; w.fx=0; w.fy=0; w.usage=0
|
|
w.damping=2*C.damping_ratio*math.sqrt(w.stiffness*w.static_load/C.gravity)
|
|
end
|
|
end
|
|
|
|
-- Keep the old keys clean on reload: no orphan wheel/carrier bodies or meshes.
|
|
local function remove_rig()
|
|
stop_audio(); clear_presentation(); clear_effects(); clear_session_skids(); clear_all_remote_vfx(); destroy_smoke_buffers()
|
|
if state.occupied or sdk.player.attached()==BODY then
|
|
sdk.player.detach(); sdk.camera.clear_follow()
|
|
end
|
|
sdk.graphics.remove("skyline_visual")
|
|
for _,w in ipairs(wheels) do
|
|
sdk.graphics.remove(w.key)
|
|
sdk.physics.remove(w.key)
|
|
sdk.physics.remove("carrier_"..w.key)
|
|
sdk.physics.remove("knuckle_"..w.key)
|
|
end
|
|
sdk.physics.remove(BODY)
|
|
if state.occupied or sdk.player.attached()==BODY then
|
|
state.enter_after=math.max(state.enter_after,state.time+C.reentry_delay)
|
|
end
|
|
state.spawned=false; state.occupied=false; state.spawn_confirm=nil
|
|
state.enter_requested=false; state.exit_requested=false; state.reenter_on_ready=false
|
|
end
|
|
local function ground_at(position)
|
|
-- Search locally, not from y=500 (which could choose a roof/upper road).
|
|
local h=sdk.physics.raycast(add(position,{0,3,0}),{0,-1,0},
|
|
{max_distance=12,filter="ground"})
|
|
return h and vec(h.point) and h.point[2] or nil
|
|
end
|
|
local function spawn_rig(request)
|
|
local player=sdk.player.read()
|
|
if not vec(player.position) then sdk.log("Skyline: player position unavailable"); return end
|
|
local heading=request.heading or number(player.heading,0)
|
|
local q=yaw_rotation(heading)
|
|
local position=request.position or add(player.position,mul(rotate(q,{0,0,1}),4.5))
|
|
local height=-math.huge
|
|
for _,w in ipairs(wheels) do
|
|
local off=rotate(q,w.pos)
|
|
local y=ground_at(add(position,off))
|
|
if not y then sdk.log("Skyline: spawn needs map ground beneath all four wheels"); return end
|
|
height=math.max(height,y+C.radius-off[2]+0.025)
|
|
end
|
|
sdk.commands.request("spawn_chassis",{kind="physics_spawn",key=BODY,body={
|
|
shape={type="model",path=MODEL,object="skyline_mesh",
|
|
options={max_hulls=32,resolution=96,concavity=0.0025}},
|
|
deformation={yield_speed=0.5,compliance=0.15,radius=1.2,max_displacement=0.80,max_step=0.40,resolution={9,5,17}},
|
|
body_type="dynamic",mass=C.mass,position={position[1],height,position[3]},
|
|
heading=heading,friction=0.25,ccd=true,
|
|
-- Native assembly classification; the SDK treats this as metadata.
|
|
contact_group=8,
|
|
center_of_mass=C.center,inertia_half_extents=C.inertia_half,
|
|
linear_damping=0,angular_damping=0,
|
|
}})
|
|
reset_simulation()
|
|
state.spawn_confirm={
|
|
reenter=request.reenter==true,
|
|
}
|
|
end
|
|
local interaction_text
|
|
local function confirm_spawn()
|
|
local pending=state.spawn_confirm
|
|
if not pending then return end
|
|
local result=sdk.commands.result("spawn_chassis")
|
|
if result and result.ok and sdk.bodies.read({kind="mod",key=BODY}) then
|
|
sdk.graphics.mesh("skyline_visual",{path=MODEL,body=BODY,deform_nodes={"skyline_mesh"}})
|
|
prepare_effects()
|
|
state.spawn_confirm=nil
|
|
state.spawned=true
|
|
state.enter_after=math.max(state.enter_after,state.time+0.25)
|
|
start_audio()
|
|
state.reenter_on_ready=pending.reenter==true
|
|
return
|
|
end
|
|
if not result or result.ok then return end
|
|
state.spawn_confirm=nil
|
|
sdk.log("Skyline: chassis spawn failed: "..tostring(result.error))
|
|
interaction_text("Car spawn failed: "..tostring(result.error).." | F10: retry")
|
|
end
|
|
local function respawn(car,at_car)
|
|
local request={}
|
|
if at_car and car then
|
|
request={position=car.position,heading=yaw(car.rotation),reenter=state.occupied}
|
|
end
|
|
remove_rig()
|
|
-- Spawn on the next tick: queries cannot see this tick's queued spawns.
|
|
state.pending=request
|
|
end
|
|
|
|
local function input_state(buttons,dt)
|
|
local p=sdk.input.pad(); local t=p.triggers or {}; local stick=p.left or {}
|
|
local up=edge("KeyX") or pressed_button(buttons,0x0200)
|
|
local dn=edge("KeyZ") or pressed_button(buttons,0x0100)
|
|
local neutral=edge("KeyN")
|
|
local throttle,brake,steer,hand=0,1,0,false
|
|
if state.occupied then
|
|
if neutral then state.gear=0; state.shift=C.shift_time
|
|
elseif up and state.gear<6 then state.gear=state.gear+1; state.shift=C.shift_time
|
|
elseif dn and state.gear> -1 then state.gear=state.gear-1; state.shift=C.shift_time end
|
|
throttle=math.max(deadzone(clamp(number(t[2],0),0,1),0.04),down("KeyW") and 1 or 0)
|
|
brake=math.max(deadzone(clamp(number(t[1],0),0,1),0.04),down("KeyS") and 1 or 0)
|
|
local digital=(down("KeyD") and 1 or 0)-(down("KeyA") and 1 or 0)
|
|
state.keyboard_axis=approach(state.keyboard_axis,digital,dt/(digital==0 and 0.22 or 0.45))
|
|
steer=clamp(deadzone(clamp(number(stick[1],0),-1,1),0.09)+state.keyboard_axis,-1,1)
|
|
hand=down("ShiftLeft") or down("ShiftRight") or (buttons & 0x2000)~=0
|
|
end
|
|
state.shift=math.max(0,state.shift-dt)
|
|
return {throttle=throttle,brake=brake,steer=steer,hand=hand,raw_rt=number(t[2],0)}
|
|
end
|
|
local function steering_angles(input,dt,speed)
|
|
-- Driver-input mapping, NOT steering assistance or a tire/force correction.
|
|
-- More precision near stick centre at speed; FULL +/-40 deg remains available.
|
|
-- No measured/target sideslip or yaw rate is used. Zero input requests zero.
|
|
local blend=clamp((number(speed,0)-15)/40,0,1)
|
|
blend=blend*blend*(3-2*blend)
|
|
local precision=clamp(number(sdk.settings.steering_precision,1),0,1.5)
|
|
local exponent=C.steering_exponent+1.25*precision*blend
|
|
local shaped=(input<0 and -1 or 1)*math.abs(input)^exponent
|
|
state.steer=approach(state.steer,-shaped*C.steer_limit,C.steer_rate*dt)
|
|
if math.abs(state.steer)<1e-6 then return {0,0,0,0} end
|
|
local radius=wheelbase/math.tan(math.abs(state.steer))
|
|
local inner=math.atan(wheelbase/(radius-track/2))
|
|
local outer=math.atan(wheelbase/(radius+track/2))
|
|
if state.steer<0 then return {-outer,-inner,0,0} end
|
|
return {inner,outer,0,0}
|
|
end
|
|
local function torque_curve(rpm)
|
|
local points={{900,185},{2000,270},{3500,365},{5000,405},{6500,395},{8000,320}}
|
|
for i=2,#points do
|
|
if rpm<=points[i][1] then
|
|
local a,b=points[i-1],points[i]
|
|
return a[2]+(b[2]-a[2])*clamp((rpm-a[1])/(b[1]-a[1]),0,1)
|
|
end
|
|
end
|
|
return points[#points][2]
|
|
end
|
|
local function prepare_powertrain(input,dt,speed)
|
|
-- A finite-torque engine. Idle is a torque controller, NOT an RPM floor.
|
|
local rpm=state.engine_omega*30/math.pi
|
|
local loss=12+0.025*math.abs(state.engine_omega)
|
|
+(1-input.throttle)*35*clamp((rpm-C.idle_rpm)/2200,0,1)
|
|
local fuel=clamp((C.redline_rpm-rpm)/350,0,1)
|
|
local idle=clamp(loss+0.9*(C.idle_rpm-rpm),0,100)
|
|
local combustion=math.max(input.throttle*torque_curve(rpm)*fuel,idle)
|
|
local net=combustion-loss
|
|
state.engine_omega=math.max(0,state.engine_omega+net*dt/C.engine_inertia)
|
|
-- Automatic clutch pedal; it does not look at tire slip or sideslip.
|
|
local wanted=clamp((rpm-1000)/1100,0,1)
|
|
if speed>4 and rpm>1000 then wanted=1 end
|
|
if (speed<1 and input.throttle==0) or state.shift>0 or state.gear==0 then wanted=0 end
|
|
state.clutch=approach(state.clutch,wanted,(wanted<state.clutch and 10 or 5)*dt)
|
|
if state.shift>0 or state.gear==0 then state.clutch=0 end
|
|
local ratio=gear_ratio()
|
|
local inverse=1/C.engine_inertia
|
|
for _,w in ipairs(wheels) do
|
|
w.shaft_factor=ratio*w.drive_share
|
|
inverse=inverse+w.shaft_factor*w.shaft_factor/C.wheel_inertia
|
|
end
|
|
return {p=0, cap=C.clutch_capacity*state.clutch*dt,
|
|
inverse=inverse, diff_p=0, ratio=ratio}
|
|
end
|
|
local function solve_powertrain(power,dt)
|
|
-- Projected passive clutch impulse in gearbox coordinates.
|
|
local relative=state.engine_omega
|
|
for _,w in ipairs(wheels) do relative=relative-w.shaft_factor*w.omega end
|
|
local next_p=clamp(power.p+relative/power.inverse,-power.cap,power.cap)
|
|
local change=next_p-power.p
|
|
state.engine_omega=state.engine_omega-change/C.engine_inertia
|
|
for _,w in ipairs(wheels) do w.omega=w.omega+w.shaft_factor*change/C.wheel_inertia end
|
|
power.p=next_p
|
|
-- A torque-limited rear clutch LSD, not a forced equal-speed axle.
|
|
-- Brake/drive force remains entirely a tire result. Front diff is open.
|
|
local rear_torque=math.abs(power.p*power.ratio*(1-C.front_drive)/dt)
|
|
local cap=(C.rear_diff_preload+C.rear_diff_ramp*rear_torque)*dt
|
|
local delta=wheels[3].omega-wheels[4].omega
|
|
local next_diff=clamp(power.diff_p-delta*C.wheel_inertia/2,-cap,cap)
|
|
local dp=next_diff-power.diff_p
|
|
wheels[3].omega=wheels[3].omega+dp/C.wheel_inertia
|
|
wheels[4].omega=wheels[4].omega-dp/C.wheel_inertia
|
|
power.diff_p=next_diff
|
|
end
|
|
|
|
-- Invert a small SPD matrix (1..4 contacts), with partial pivoting.
|
|
local function inverse_matrix(matrix)
|
|
local n=#matrix; local a={}
|
|
for i=1,n do
|
|
a[i]={}
|
|
for j=1,n do a[i][j]=matrix[i][j]; a[i][n+j]=(i==j and 1 or 0) end
|
|
end
|
|
for k=1,n do
|
|
local pivot=k
|
|
for i=k+1,n do if math.abs(a[i][k])>math.abs(a[pivot][k]) then pivot=i end end
|
|
if math.abs(a[pivot][k])<1e-12 then return nil end
|
|
a[k],a[pivot]=a[pivot],a[k]
|
|
local d=a[k][k]
|
|
for j=1,2*n do a[k][j]=a[k][j]/d end
|
|
for i=1,n do if i~=k then
|
|
local factor=a[i][k]
|
|
for j=1,2*n do a[i][j]=a[i][j]-factor*a[k][j] end
|
|
end end
|
|
end
|
|
local result={}
|
|
for i=1,n do result[i]={}; for j=1,n do result[i][j]=a[i][n+j] end end
|
|
return result
|
|
end
|
|
local function constraint_axis(q,arm,axis)
|
|
local torque=cross(arm,axis)
|
|
local angular=inertia(q,torque,true)
|
|
return {axis=axis,torque=torque,angular=angular,inv_mass=1/C.mass+dot(torque,angular)}
|
|
end
|
|
local function velocity_along(sim,c)
|
|
return dot(sim.v,c.axis)+dot(sim.w,c.torque)
|
|
end
|
|
local function push(sim,c,impulse)
|
|
for i=1,3 do
|
|
sim.v[i]=sim.v[i]+c.axis[i]*impulse/C.mass
|
|
sim.w[i]=sim.w[i]+c.angular[i]*impulse
|
|
end
|
|
end
|
|
local function collect_contacts(car,angles,dt)
|
|
local result={}; local by_wheel={}
|
|
local q=car.rotation; local up=rotate(q,{0,1,0}); local direction=mul(up,-1)
|
|
local center=add(car.position,rotate(q,C.center))
|
|
for i,w in ipairs(wheels) do
|
|
w.contact=false; w.load=0; w.slip=0; w.length=C.droop_length
|
|
w.alpha=0; w.kappa=0; w.fx=0; w.fy=0; w.usage=0
|
|
local local_mount={w.pos[1],w.pos[2]+C.ride_length,w.pos[3]}
|
|
local mount=add(car.position,rotate(q,local_mount))
|
|
local origin=add(mount,mul(up,0.10))
|
|
-- Ground-only is intentional: ignores our chassis, skater proxies,
|
|
-- and other dynamic objects. Dynamic-prop driving is not implemented.
|
|
local hit=sdk.physics.raycast(origin,direction,
|
|
{max_distance=0.10+C.droop_length+C.radius,filter="ground"})
|
|
if hit and vec(hit.point) and vec(hit.normal) then
|
|
local n=norm(hit.normal)
|
|
local alignment=dot(n,up)
|
|
local distance=dot(sub(hit.point,mount),direction)
|
|
local length=distance-C.radius/math.max(0.45,alignment)
|
|
if alignment>0.45 and n[2]>0.1 and length<=C.droop_length then
|
|
local forward=rotate(q,{math.sin(angles[i]),0,math.cos(angles[i])})
|
|
forward=norm(sub(forward,mul(n,dot(forward,n))))
|
|
local lateral=norm(cross(n,forward))
|
|
-- Spherical contact-envelope approximation; normal force acts ON the road.
|
|
local hub=sub(mount,mul(up,length))
|
|
local point=sub(hub,mul(n,C.radius))
|
|
local arm=sub(point,center)
|
|
local c={wheel=w,wheel_index=i,point=point,ground=hit.point,ground_n=n,
|
|
alignment=alignment,length=length,compression=w.rest_length-length,
|
|
pn=0,px=0,py=0,pb=0,
|
|
n=constraint_axis(q,arm,n),
|
|
x=constraint_axis(q,arm,forward),y=constraint_axis(q,arm,lateral)}
|
|
c.xy=dot(c.x.axis,c.y.axis)/C.mass+dot(c.x.torque,c.y.angular)
|
|
result[#result+1]=c; by_wheel[i]=#result
|
|
w.contact=true; w.length=length
|
|
end
|
|
end
|
|
end
|
|
if #result==0 then return result end
|
|
-- Backward Euler for ALL corner springs + axle anti-roll springs:
|
|
-- (W + Gamma) p = beta - v_free; p is unilateral and force-limited.
|
|
-- Gamma = [dt A^-1(D + dt K)A^-1]^-1, beta=Gamma*dt*A^-1*K*x.
|
|
-- Axle couplings make anti-roll torque equal/opposite, not free downforce.
|
|
local matrix,bias={},{ }
|
|
for i,c in ipairs(result) do
|
|
local partner=by_wheel[c.wheel.mate]
|
|
local antiroll=c.wheel.front and C.front_antiroll or C.rear_antiroll
|
|
local k=c.wheel.stiffness
|
|
local force=k*c.compression
|
|
local bump=math.max(0,C.bump_length-c.length)
|
|
if bump>0 then k=k+C.bump_stiffness; force=force+C.bump_stiffness*bump end
|
|
local d=c.wheel.damping
|
|
if partner then
|
|
k=k+antiroll; d=d+C.antiroll_damping
|
|
force=force+antiroll*(c.compression-result[partner].compression)
|
|
end
|
|
matrix[i]={}
|
|
for j,other in ipairs(result) do
|
|
local value=0
|
|
if i==j then value=d+dt*k
|
|
elseif j==partner then value=-C.antiroll_damping-dt*antiroll end
|
|
matrix[i][j]=dt*value/(c.alignment*other.alignment)
|
|
end
|
|
bias[i]=dt*force/c.alignment
|
|
end
|
|
local gamma=inverse_matrix(matrix)
|
|
if not gamma then return {} end
|
|
for i,c in ipairs(result) do
|
|
c.gamma=gamma[i]; c.beta=0
|
|
for j=1,#result do c.beta=c.beta+gamma[i][j]*bias[j] end
|
|
end
|
|
return result
|
|
end
|
|
local function solve_normal(sim,contacts,index,dt)
|
|
local c=contacts[index]
|
|
local residual=velocity_along(sim,c.n)-c.beta
|
|
for j,other in ipairs(contacts) do residual=residual+c.gamma[j]*other.pn end
|
|
local new=clamp(c.pn-residual/(c.n.inv_mass+c.gamma[index]),0,C.maximum_load*dt)
|
|
push(sim,c.n,new-c.pn); c.pn=new
|
|
end
|
|
-- Solve the tire contact for a trial scalar compliance. Brake complementarity
|
|
-- is solved analytically: first test omega=0, otherwise use a saturated brake.
|
|
-- vx0,vy0,omega0 have THIS contact's accumulated impulses removed.
|
|
local function tire_candidate(c,g,vx0,vy0,omega0,brake)
|
|
local a,b,d=c.x.inv_mass+g,c.xy,c.y.inv_mass+g
|
|
local determinant=a*d-b*b
|
|
local px=(-d*vx0+b*vy0)/determinant
|
|
local py=(b*vx0-a*vy0)/determinant
|
|
local required=C.radius*px-C.wheel_inertia*omega0
|
|
if math.abs(required)<=brake then return px,py,required,0 end
|
|
local pb=clamp(required,-brake,brake)
|
|
local free_omega=omega0+pb/C.wheel_inertia
|
|
local sx=vx0-C.radius*free_omega
|
|
a=a+C.radius*C.radius/C.wheel_inertia
|
|
determinant=a*d-b*b
|
|
px=(-d*sx+b*vy0)/determinant
|
|
py=(b*sx-a*vy0)/determinant
|
|
return px,py,pb,free_omega-C.radius*px/C.wheel_inertia
|
|
end
|
|
local function brush_inverse_scale(ratio)
|
|
-- Inverse of F/D = 1 - (1 - demand/(3D))^3.
|
|
-- Rationalized to avoid cancellation as force approaches zero.
|
|
local root=(1-clamp(ratio,0,1))^(1/3)
|
|
return 3/(1+root+root*root)
|
|
end
|
|
local function solve_tire(sim,c,input,mu,dt)
|
|
local w=c.wheel
|
|
local oldx,oldy=c.px,c.py
|
|
local vx,vy=velocity_along(sim,c.x),velocity_along(sim,c.y)
|
|
local vx0=vx-c.x.inv_mass*oldx-c.xy*oldy
|
|
local vy0=vy-c.xy*oldx-c.y.inv_mass*oldy
|
|
local omega0=w.omega+(C.radius*oldx-c.pb)/C.wheel_inertia
|
|
local load=c.pn/dt
|
|
local load_ratio=math.max(0,load/w.static_load)
|
|
-- Sublinear load sensitivity, with bounded low-load coefficient.
|
|
local sensitivity=clamp(math.max(load_ratio,0.01)^(C.load_exponent-1),0.65,1.15)
|
|
local limit=mu*sensitivity*c.pn
|
|
local fraction=w.front and C.front_brake_fraction or 1-C.front_brake_fraction
|
|
local brake=(input.brake*C.brake_torque_total*fraction/2
|
|
+C.rolling_coefficient*load*C.radius
|
|
+((input.hand and not w.front) and C.handbrake_torque or 0))*dt
|
|
local px,py,pb,omega=0,0,clamp(-C.wheel_inertia*omega0,-brake,brake),0
|
|
omega=omega0+pb/C.wheel_inertia
|
|
if limit>1e-8 then
|
|
local transport=math.max(math.abs(vx),math.abs(w.omega*C.radius))
|
|
-- Static-friction limit near rest. Smooth transition to rolling brush.
|
|
-- This is local tire-contact regularization, never a chassis assist.
|
|
local blend=clamp((transport-0.15)/0.85,0,1)
|
|
blend=blend*blend*(3-2*blend)
|
|
local stiffness=(w.front and C.front_stiffness_per_load or C.rear_stiffness_per_load)
|
|
*w.static_load*math.max(load_ratio,0.01)^C.load_exponent
|
|
local base=blend*math.max(transport,0.15)/(stiffness*dt)
|
|
local low,high=base,3*base
|
|
px,py,pb,omega=tire_candidate(c,high,vx0,vy0,omega0,brake)
|
|
local amount=math.sqrt(px*px+py*py)
|
|
if base==0 and amount<=limit then
|
|
-- True sticking branch; a parked vehicle can hold a slope.
|
|
else
|
|
local sliding=amount>limit
|
|
if sliding then
|
|
low=high
|
|
high=high+(math.sqrt(vx0*vx0+vy0*vy0)+C.radius*math.abs(omega0))/limit+1e-6
|
|
end
|
|
for _=1,C.tire_root_iterations do
|
|
local g=(low+high)/2
|
|
local tx,ty=tire_candidate(c,g,vx0,vy0,omega0,brake)
|
|
local magnitude=math.sqrt(tx*tx+ty*ty)
|
|
local increase
|
|
if sliding then increase=magnitude>limit
|
|
else increase=g<base*brush_inverse_scale(magnitude/limit) end
|
|
if increase then low=g else high=g end
|
|
end
|
|
px,py,pb,omega=tire_candidate(c,high,vx0,vy0,omega0,brake)
|
|
end
|
|
end
|
|
w.omega=omega
|
|
push(sim,c.x,px-oldx); push(sim,c.y,py-oldy)
|
|
c.px=px; c.py=py; c.pb=pb; c.limit=limit
|
|
end
|
|
local function publish_hud(car,input,contacts,dt)
|
|
state.hud_time=state.hud_time-dt
|
|
if state.hud_time>0 then return end
|
|
state.hud_time=0.10
|
|
if not presentation.debug then
|
|
sdk.ui.text("skyline_status",""); sdk.ui.text("skyline_handling",""); sdk.ui.text("skyline_wheels",""); return
|
|
end
|
|
local velocity=unrotate(car.rotation,car.linvel)
|
|
local local_up=unrotate(car.rotation,{0,1,0})
|
|
local roll=math.deg(math.atan(local_up[1],local_up[2]))
|
|
local beta=math.deg(math.atan(velocity[1],math.max(0.1,math.abs(velocity[3]))))
|
|
local loaded=0
|
|
for _,w in ipairs(wheels) do if w.load>1 then loaded=loaded+1 end end
|
|
sdk.ui.text("skyline_status",string.format(
|
|
"Skyline DRIVE | %+.1f km/h | %s | %.0f rpm | T %.0f%% Brake %.0f%% HB %s",
|
|
velocity[3]*3.6,gear_label(),state.rpm,input.throttle*100,input.brake*100,
|
|
input.hand and "ON" or "off"))
|
|
sdk.ui.text("skyline_handling",string.format(
|
|
"Rack %+.1f deg | beta %+.1f deg | roll %+.1f deg | mu %.2f | load %d/4 | mass %s | aero %.0fN",
|
|
math.deg(state.steer),beta,roll,number(sdk.settings.tire_friction,1.3),loaded,
|
|
state.mass_audit or "pending",state.aero_load))
|
|
sdk.ui.text("skyline_wheels",string.format(
|
|
"FL %.0fN a%+.1f k%+.2f | FR %.0fN a%+.1f k%+.2f | RL %.0fN a%+.1f k%+.2f | RR %.0fN a%+.1f k%+.2f | clutch %.0f%%",
|
|
wheels[1].load,wheels[1].alpha,wheels[1].kappa,wheels[2].load,wheels[2].alpha,wheels[2].kappa,
|
|
wheels[3].load,wheels[3].alpha,wheels[3].kappa,wheels[4].load,wheels[4].alpha,wheels[4].kappa,state.clutch*100))
|
|
end
|
|
-- Read-only consistency check against NATIVE effective mass. Snapshot `mass`
|
|
-- is metadata in this engine, so checking that field alone proves nothing.
|
|
-- This never changes forces, inertia, pose, or velocity to hide a mismatch.
|
|
local function audit_mass_properties(car)
|
|
if state.mass_audit~=nil then return end
|
|
local q=car.rotation
|
|
local center=add(car.position,rotate(q,C.center))
|
|
local probes={
|
|
{{0,0,0},{0,1,0},1/C.mass},
|
|
{{0,1,0},{0,0,1},1/C.mass+1/I[1]},
|
|
{{0,0,1},{1,0,0},1/C.mass+1/I[2]},
|
|
{{1,0,0},{0,1,0},1/C.mass+1/I[3]},
|
|
}
|
|
local error=0
|
|
for _,probe in ipairs(probes) do
|
|
local actual=sdk.physics.effective_inv_mass(BODY,
|
|
add(center,rotate(q,probe[1])),rotate(q,probe[2]))
|
|
if not finite(actual) then
|
|
state.mass_audit="unavailable"
|
|
sdk.log("Skyline: native mass check unavailable; supplied-source mass properties assumed")
|
|
return
|
|
end
|
|
error=math.max(error,math.abs(actual-probe[3])/probe[3])
|
|
end
|
|
state.mass_audit=error<0.01 and "OK" or "MISMATCH"
|
|
sdk.log(string.format("Skyline: native mass/inertia check %s (maximum relative error %.3f%%)",
|
|
state.mass_audit,100*error))
|
|
end
|
|
-- Explicit aerodynamic surface loads. Cl*A values are editable ASSUMPTIONS,
|
|
-- not measured R34 data. Positive values mean downforce, negative mean lift.
|
|
-- Forces act at fore/aft locations and enter the existing coupled solver.
|
|
-- No contact-count condition, global-down force, or tire-friction multiplier.
|
|
local function apply_aerodynamics(sim,car,dt)
|
|
state.aero_load=0
|
|
if sdk.settings.aero_enabled==false then return end
|
|
local front=clamp(number(sdk.settings.aero_front_area,0.08),-0.4,0.6)
|
|
local rear=clamp(number(sdk.settings.aero_rear_area,0.12),-0.4,0.6)
|
|
local up=rotate(car.rotation,{0,1,0})
|
|
local forward=rotate(car.rotation,{0,0,1})
|
|
local surface_positions={{0,0.04,1.44360},{0,0.16,-1.34703}}
|
|
-- Evaluate both surfaces at the same state; avoid update-order dependence.
|
|
local air_linear,air_angular=sim.v,sim.w
|
|
for i,area in ipairs({front,rear}) do
|
|
local arm=rotate(car.rotation,sub(surface_positions[i],C.center))
|
|
local velocity=add(air_linear,cross(air_angular,arm))
|
|
local speed2=dot(velocity,velocity)
|
|
if speed2>0.01 and area~=0 then
|
|
local along=dot(velocity,forward)
|
|
-- Project body-down perpendicular to local airflow: lift does not
|
|
-- deliberately add thrust or oppose the body's roll orientation.
|
|
local flow=mul(velocity,1/math.sqrt(speed2))
|
|
local direction=sub(mul(up,-1),mul(flow,dot(mul(up,-1),flow)))
|
|
local projected2=dot(direction,direction)
|
|
if projected2>1e-8 then
|
|
local force=mul(direction,(0.5*1.225*along*along*area)/math.sqrt(projected2))
|
|
local impulse=mul(force,dt)
|
|
sim.v=add(sim.v,mul(impulse,1/C.mass))
|
|
sim.w=add(sim.w,inertia(car.rotation,cross(arm,impulse),true))
|
|
state.aero_load=state.aero_load-dot(force,up)
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
local function quat_mul(a,b)
|
|
return {
|
|
a[4]*b[1]+a[1]*b[4]+a[2]*b[3]-a[3]*b[2],
|
|
a[4]*b[2]-a[1]*b[3]+a[2]*b[4]+a[3]*b[1],
|
|
a[4]*b[3]+a[1]*b[2]-a[2]*b[1]+a[3]*b[4],
|
|
a[4]*b[4]-a[1]*b[1]-a[2]*b[2]-a[3]*b[3],
|
|
}
|
|
end
|
|
local function update_wheel_visuals(angles,dt)
|
|
for i,w in ipairs(wheels) do
|
|
-- The solver owns omega and suspension length. These are read-only
|
|
-- presentation values: no extra rigid body, force or steering assist.
|
|
w.spin=(w.spin+0.5*(w.previous_omega+w.omega)*dt)%(2*math.pi)
|
|
local steer=angles[i]
|
|
local y=C.ride_length-w.length
|
|
local steer_q=yaw_rotation(steer)
|
|
local spin_q={math.sin(w.spin/2),0,0,math.cos(w.spin/2)}
|
|
local axle=rotate(steer_q,{w.omega,0,0})
|
|
sdk.graphics.node_transform("skyline_visual",w.key,{
|
|
position={0,y,0}, rotation=quat_mul(steer_q,spin_q), relative=true,
|
|
linear_velocity={0,(y-w.visual_y)/dt,0},
|
|
angular_velocity={axle[1],(steer-w.visual_steer)/dt,axle[3]},
|
|
})
|
|
w.previous_omega=w.omega; w.visual_y=y; w.visual_steer=steer
|
|
end
|
|
end
|
|
interaction_text=function(text)
|
|
if state.interaction_text~=text then
|
|
state.interaction_text=text
|
|
sdk.ui.text("skyline_interaction", "")
|
|
if type(sdk.ui.canvas)=="function" then
|
|
if text=="" then sdk.ui.remove("skyline_interaction_prompt")
|
|
else sdk.ui.canvas("skyline_interaction_prompt", {
|
|
anchor="bottom_left",offset={24,216},size={540,34},visible=true,
|
|
items={{key="prompt",type="text",position={8,6},size={524,28},
|
|
text=text,font_size=18,color={0.96,0.98,1.0,1}}},
|
|
}) end
|
|
end
|
|
end
|
|
end
|
|
local function near_door(car)
|
|
local player=sdk.player.read()
|
|
if not vec(player.position) then return false end
|
|
local p=unrotate(car.rotation,sub(player.position,car.position))
|
|
if p[2]<-1.3 or p[2]>1.5 then return false end
|
|
for _,side in ipairs({-1,1}) do
|
|
local dx,dz=p[1]-side*1.65,p[3]+0.20
|
|
if dx*dx+dz*dz <= C.entry_radius*C.entry_radius then return true end
|
|
end
|
|
return false
|
|
end
|
|
local function request_entry()
|
|
if state.enter_requested or sdk.player.detaching() or state.time<state.enter_after then return end
|
|
sdk.player.attach(BODY,{-0.38,0.12,-0.23})
|
|
state.enter_requested=true; state.reenter_on_ready=false
|
|
end
|
|
local function request_exit()
|
|
if state.exit_requested then return end
|
|
-- Body-local FOOT candidates. The engine rotates them with the chassis,
|
|
-- finds map ground, and rejects occupied full-height standing capsules.
|
|
sdk.player.detach({candidates={
|
|
{-1.75,-0.52,-0.20},{1.75,-0.52,-0.20},
|
|
{-1.90,-0.52,1.3},{1.90,-0.52,1.3},
|
|
{-1.90,-0.52,-1.5},{1.90,-0.52,-1.5},
|
|
{0,-0.52,3.25},{0,-0.52,-3.15},
|
|
},height=1.8,radius=0.30,ground_snap=3.0})
|
|
state.exit_requested=true
|
|
end
|
|
local function sync_occupancy()
|
|
local attached=sdk.player.attached()==BODY
|
|
if attached then
|
|
state.enter_requested=false
|
|
if not state.occupied then state.occupied=true; configure_camera() end
|
|
local err=sdk.player.detach_error()
|
|
if state.exit_requested and type(err)=="string" then
|
|
state.exit_requested=false; interaction_text(err)
|
|
end
|
|
else
|
|
if state.occupied then
|
|
state.occupied=false; state.exit_requested=false
|
|
state.enter_after=math.max(state.enter_after,state.time+C.reentry_delay)
|
|
clear_presentation()
|
|
if presentation.debug then set_debug_visible(true) end
|
|
end
|
|
state.enter_requested=false
|
|
end
|
|
end
|
|
local function set_collision_debug(enabled)
|
|
state.hull_debug=enabled==true
|
|
sdk.physics.debug_colliders(state.hull_debug)
|
|
end
|
|
|
|
local function simulate(car,input,dt)
|
|
if not vec(car.linvel) or not vec(car.angvel) or not vec(car.position)
|
|
or type(car.rotation)~="table" or not finite(car.rotation[4]) then return end
|
|
audit_mass_properties(car)
|
|
local speed=math.sqrt(car.linvel[1]^2+car.linvel[3]^2)
|
|
local angles=steering_angles(input.steer,dt,speed)
|
|
local power=prepare_powertrain(input,dt,speed)
|
|
local contacts=collect_contacts(car,angles,dt)
|
|
-- Gravity is integrated by DynamicsWorld with the submitted forces.
|
|
-- Include it in the prediction, but DO NOT submit gravity a second time.
|
|
local free={car.linvel[1],car.linvel[2]-C.gravity*dt,car.linvel[3]}
|
|
local sim={v={free[1],free[2],free[3]},w={car.angvel[1],car.angvel[2],car.angvel[3]}}
|
|
local drag=1/(1+C.aero_drag*speed*dt/C.mass)
|
|
sim.v[1]=sim.v[1]*drag; sim.v[3]=sim.v[3]*drag
|
|
apply_aerodynamics(sim,car,dt)
|
|
local mu=clamp(number(sdk.settings.tire_friction,1.3),0.4,2)
|
|
-- Alternating sweep order avoids a persistent front/rear solver bias.
|
|
for pass=1,C.iterations do
|
|
solve_powertrain(power,dt)
|
|
for k=1,#contacts do
|
|
local index=(pass%2==1) and k or (#contacts-k+1)
|
|
solve_normal(sim,contacts,index,dt)
|
|
end
|
|
for k=1,#contacts do
|
|
local index=(pass%2==1) and k or (#contacts-k+1)
|
|
solve_tire(sim,contacts[index],input,mu,dt)
|
|
end
|
|
end
|
|
local body_center=add(car.position,rotate(car.rotation,C.center))
|
|
for _,c in ipairs(contacts) do
|
|
c.wheel.load=c.pn/dt
|
|
local vx,vy=velocity_along(sim,c.x),velocity_along(sim,c.y)
|
|
local tread=c.wheel.omega*C.radius
|
|
c.wheel.slip=tread-vx
|
|
c.wheel.kappa=(tread-vx)/math.max(math.abs(vx),math.abs(tread),0.5)
|
|
c.wheel.alpha=math.deg(math.atan(vy,math.max(math.abs(vx),0.1)))
|
|
c.wheel.fx=c.px/dt; c.wheel.fy=c.py/dt
|
|
c.wheel.usage=c.limit and math.sqrt(c.px*c.px+c.py*c.py)/math.max(c.limit,1e-8) or 0
|
|
local arm=sub(c.ground or c.point,body_center)
|
|
c.surface_v=add(sim.v,cross(sim.w,arm))
|
|
end
|
|
for _,w in ipairs(wheels) do if not w.contact then
|
|
local fraction=w.front and C.front_brake_fraction or 1-C.front_brake_fraction
|
|
local brake=input.brake*C.brake_torque_total*fraction/2
|
|
+((input.hand and not w.front) and C.handbrake_torque or 0)
|
|
w.omega=approach(w.omega,0,brake*dt/C.wheel_inertia)
|
|
end end
|
|
local impulse=mul(sub(sim.v,free),C.mass)
|
|
local angular=inertia(car.rotation,sub(sim.w,car.angvel),false)
|
|
if vec(impulse) and vec(angular) then
|
|
-- apply_force/apply_torque each REPLACE the previous force/torque
|
|
-- in this engine. Aggregate here, then submit once per body per tick.
|
|
sdk.physics.force(BODY,mul(impulse,1/dt))
|
|
sdk.physics.torque(BODY,mul(angular,1/dt))
|
|
end
|
|
state.rpm=state.engine_omega*30/math.pi
|
|
sound.throttle=input.throttle; sound.shift=state.shift; sound.brake=input.brake
|
|
local body_velocity=unrotate(car.rotation,car.linvel)
|
|
state.speed=math.sqrt(body_velocity[1]^2+body_velocity[3]^2)
|
|
presentation.speed=state.speed
|
|
presentation.handbrake=input.hand
|
|
publish_hud(car,input,contacts,dt)
|
|
sound.drift_slip=0
|
|
tick_wheel_vfx(contacts,dt)
|
|
update_wheel_visuals(angles,dt)
|
|
end
|
|
|
|
return {
|
|
on_load=function()
|
|
-- Native capabilities are published by vm.rs, not invented by api.lua.
|
|
-- Replacing only the Lua wrapper must NOT enable unknown native commands.
|
|
local native=sdk.capabilities or {}
|
|
if not native.engine_access or not native.command_results then error("Skyline requires the generalized engine API build") end
|
|
if (native.deformation or 0)<1 then
|
|
error("Skyline 4.5.0 requires the deformation engine update. Close the game and relaunch play.bat after the rebuild is installed.")
|
|
end
|
|
if (native.model_collision or 0)<1 or (native.solid_bridge or 0)<3
|
|
or (native.physics_debug or 0)<1 or (native.scene_transforms or 0)<2 then
|
|
error("Skyline 4.5.0: the running executable is missing the native Model Collision Repair API. "..
|
|
"Install the complete matching crates update and launch a successfully rebuilt game executable. "..
|
|
"Copying only api.lua is not sufficient.")
|
|
end
|
|
if not sdk.graphics or (sdk.graphics.version or 0)<2 or not sdk.player.detaching then
|
|
error("Skyline 4.5.0: the embedded Lua SDK wrapper does not match the native Model Collision Repair API")
|
|
end
|
|
reset_simulation(); remove_rig(); prepare_audio(); prepare_presentation(); ensure_vfx_mesh()
|
|
set_collision_debug(sdk.settings.show_collision_hull==true)
|
|
sdk.log("Skyline 4.5.0: geometry-shaped deformation, model collision, native impacts, animated wheels, safe exits; J toggles actual colliders")
|
|
sdk.ui.text("skyline_status",""); sdk.ui.text("skyline_handling",""); sdk.ui.text("skyline_wheels","")
|
|
end,
|
|
on_update=function(event)
|
|
update_audio(event); update_dashboard(event); upload_wheel_vfx(event)
|
|
end,
|
|
on_ui_update=function(event)
|
|
refresh_multiplayer_debug(clamp(number(event and event.dt,1/60),0,0.1))
|
|
end,
|
|
on_settings=function(event)
|
|
if event.key=="audio_enabled" and event.value==false then stop_audio() end
|
|
if event.key=="show_driving_debug" then set_debug_visible(event.value==true) end
|
|
if event.key=="show_collision_hull" then set_collision_debug(event.value==true) end
|
|
if event.key=="show_speedometer" and event.value==false and presentation.supported then
|
|
sdk.ui.remove("driving_dashboard");presentation.active_hud=false
|
|
end
|
|
presentation.hud_timer=0
|
|
if event.key:match("^camera_") or event.key=="hood_height" then configure_camera() end
|
|
end,
|
|
on_unload=function()
|
|
set_collision_debug(false); interaction_text(""); remove_rig()
|
|
destroy_smoke_buffers(); clear_session_skids()
|
|
sdk.ui.text("skyline_audio",""); sdk.ui.text("skyline_presentation","")
|
|
sdk.ui.text("skyline_help",""); sdk.ui.text("skyline_status",""); sdk.ui.text("skyline_handling",""); sdk.ui.text("skyline_wheels","")
|
|
sdk.ui.text("skyline_mp","")
|
|
end,
|
|
on_fixed_update=function(event)
|
|
local dt=number(event and event.dt,1/120)
|
|
if dt<=0 or dt>0.1 then return end
|
|
state.time=state.time+dt
|
|
sync_occupancy()
|
|
local p=sdk.input.pad(); local buttons=math.floor(number(p.buttons,0))
|
|
-- Sample edges unconditionally; held keys must not become new presses
|
|
-- merely because another shortcut returned early this tick.
|
|
local spawn=edge("F10")
|
|
local reset_key=edge("KeyR")
|
|
local enter_key=edge("KeyE")
|
|
local camera_key=edge("KeyC")
|
|
local debug_key=edge("KeyH")
|
|
local hull_key=edge("KeyJ")
|
|
if hull_key then set_collision_debug(not state.hull_debug) end
|
|
local camera_pressed=camera_key or pressed_button(buttons,0x4000)
|
|
if debug_key then set_debug_visible(not presentation.debug) end
|
|
local enter=enter_key or pressed_button(buttons,0x8000)
|
|
local reset=reset_key or pressed_button(buttons,0x0080)
|
|
local car=sdk.physics.read(BODY)
|
|
confirm_spawn()
|
|
if spawn then respawn(nil,false)
|
|
elseif state.pending then
|
|
local request=state.pending; state.pending=nil; spawn_rig(request)
|
|
elseif car and state.spawned then
|
|
if reset then respawn(car,true)
|
|
else
|
|
if state.reenter_on_ready and state.time>=state.enter_after and not sdk.player.detaching() then
|
|
request_entry()
|
|
end
|
|
if enter then
|
|
if state.occupied then request_exit()
|
|
elseif state.time>=state.enter_after and near_door(car) then request_entry() end
|
|
end
|
|
if not state.occupied then
|
|
local remaining=math.max(0,state.enter_after-state.time)
|
|
if sdk.player.detaching() then interaction_text("Moving to a clear on-foot exit...")
|
|
elseif remaining>0 then interaction_text(string.format("Re-entry: %.1f s | J: collision hull",remaining))
|
|
elseif near_door(car) then interaction_text("E / Y: enter | J: collision hull")
|
|
else interaction_text("Approach a door to enter | J: collision hull") end
|
|
elseif not state.exit_requested and type(sdk.player.detach_error())~="string" then
|
|
interaction_text("E / Y: exit | J: collision hull")
|
|
end
|
|
if camera_pressed and state.occupied then
|
|
presentation.mode=presentation.mode=="chase" and "hood" or "chase"
|
|
configure_camera();presentation.hud_timer=0
|
|
end
|
|
simulate(car,input_state(buttons,dt),dt)
|
|
end
|
|
end
|
|
state.buttons=buttons
|
|
end,
|
|
on_event=function(event)
|
|
if event.name=="world_changed" then
|
|
remove_rig(); stop_audio(); clear_presentation(); sound.prepared=false; prepare_audio()
|
|
state.pending=nil; state.spawn_confirm=nil
|
|
state.enter_requested=false; state.exit_requested=false; state.reenter_on_ready=false
|
|
state.enter_after=state.time+C.reentry_delay; interaction_text("")
|
|
set_collision_debug(state.hull_debug)
|
|
state.keys={}; state.buttons=0; reset_simulation()
|
|
sdk.ui.text("skyline_status",""); sdk.ui.text("skyline_handling",""); sdk.ui.text("skyline_wheels","")
|
|
sdk.ui.text("skyline_mp","")
|
|
end
|
|
end,
|
|
}
|