Car bodies sculpted, wheels that spin, destruction that reads
Skin pass (build_cars.py, whole procedural fleet rebuilt): - warp_obj() kills the slab: plan taper (nose/tail pull in), tumblehome (the glasshouse leans inward above the belt) and rocker tuck, applied to world-space verts so det_ panels stay flush with the body. - Beveled body edges catch light instead of vanishing into flat shading. - Wheels are tyre + rim with the mesh centred on the hub and the OBJECT at the hub -- the old wheels baked position into their verts, so a spinning wheel would have orbited the car's origin. Chrome det_hub hubcaps on every corner, dark arch shrouds so wheels sit in wells rather than under a slab. - B-pillars over the one-piece glasshouse, licence plates, exhaust, door handles. Mirrors anchored to where the tapered flank actually ends up. Destruction feel: - Visual wheels spin with road speed and the fronts steer (rotation order YXZ: yaw then roll, so steered wheels roll about the steered axle). - Shed panels burst outward with lift instead of just detaching; light hits ping a hubcap off first, saving doors for crashes that deserve them. - Sparks on every impact, soft-puff smoke on wrecks and heavy damage, tyre smoke while drifting (src/fx.gd -- procedural particles, radial-gradient billboards, no textures). Camera shake on player crashes, applied after look_at so it reads as the camera being knocked, not re-aimed. - Crumple strength up ~40% now that scratches and dulling ride on it. Traffic and parked cars inherit all of it via the shared fleet GLBs. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
589e99c727
commit
3070a96b41
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
40
shot_juice1.png.import
Normal file
40
shot_juice1.png.import
Normal file
@ -0,0 +1,40 @@
|
||||
[remap]
|
||||
|
||||
importer="texture"
|
||||
type="CompressedTexture2D"
|
||||
uid="uid://d00cb83dfqjw4"
|
||||
path="res://.godot/imported/shot_juice1.png-fba2fcb034fbfdcb8d9ae2a583d4d311.ctex"
|
||||
metadata={
|
||||
"vram_texture": false
|
||||
}
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://shot_juice1.png"
|
||||
dest_files=["res://.godot/imported/shot_juice1.png-fba2fcb034fbfdcb8d9ae2a583d4d311.ctex"]
|
||||
|
||||
[params]
|
||||
|
||||
compress/mode=0
|
||||
compress/high_quality=false
|
||||
compress/lossy_quality=0.7
|
||||
compress/uastc_level=0
|
||||
compress/rdo_quality_loss=0.0
|
||||
compress/hdr_compression=1
|
||||
compress/normal_map=0
|
||||
compress/channel_pack=0
|
||||
mipmaps/generate=false
|
||||
mipmaps/limit=-1
|
||||
roughness/mode=0
|
||||
roughness/src_normal=""
|
||||
process/channel_remap/red=0
|
||||
process/channel_remap/green=1
|
||||
process/channel_remap/blue=2
|
||||
process/channel_remap/alpha=3
|
||||
process/fix_alpha_border=true
|
||||
process/premult_alpha=false
|
||||
process/normal_map_invert_y=false
|
||||
process/hdr_as_srgb=false
|
||||
process/hdr_clamp_exposure=false
|
||||
process/size_limit=0
|
||||
detect_3d/compress_to=1
|
||||
40
shot_juice2.png.import
Normal file
40
shot_juice2.png.import
Normal file
@ -0,0 +1,40 @@
|
||||
[remap]
|
||||
|
||||
importer="texture"
|
||||
type="CompressedTexture2D"
|
||||
uid="uid://l2umf3kh323s"
|
||||
path="res://.godot/imported/shot_juice2.png-2a393fa7456855db0f24793b286f65ec.ctex"
|
||||
metadata={
|
||||
"vram_texture": false
|
||||
}
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://shot_juice2.png"
|
||||
dest_files=["res://.godot/imported/shot_juice2.png-2a393fa7456855db0f24793b286f65ec.ctex"]
|
||||
|
||||
[params]
|
||||
|
||||
compress/mode=0
|
||||
compress/high_quality=false
|
||||
compress/lossy_quality=0.7
|
||||
compress/uastc_level=0
|
||||
compress/rdo_quality_loss=0.0
|
||||
compress/hdr_compression=1
|
||||
compress/normal_map=0
|
||||
compress/channel_pack=0
|
||||
mipmaps/generate=false
|
||||
mipmaps/limit=-1
|
||||
roughness/mode=0
|
||||
roughness/src_normal=""
|
||||
process/channel_remap/red=0
|
||||
process/channel_remap/green=1
|
||||
process/channel_remap/blue=2
|
||||
process/channel_remap/alpha=3
|
||||
process/fix_alpha_border=true
|
||||
process/premult_alpha=false
|
||||
process/normal_map_invert_y=false
|
||||
process/hdr_as_srgb=false
|
||||
process/hdr_clamp_exposure=false
|
||||
process/size_limit=0
|
||||
detect_3d/compress_to=1
|
||||
40
shot_skin_f.png.import
Normal file
40
shot_skin_f.png.import
Normal file
@ -0,0 +1,40 @@
|
||||
[remap]
|
||||
|
||||
importer="texture"
|
||||
type="CompressedTexture2D"
|
||||
uid="uid://s6i8tuutr6lt"
|
||||
path="res://.godot/imported/shot_skin_f.png-c6f63b09adc28589fda207aea5e20aef.ctex"
|
||||
metadata={
|
||||
"vram_texture": false
|
||||
}
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://shot_skin_f.png"
|
||||
dest_files=["res://.godot/imported/shot_skin_f.png-c6f63b09adc28589fda207aea5e20aef.ctex"]
|
||||
|
||||
[params]
|
||||
|
||||
compress/mode=0
|
||||
compress/high_quality=false
|
||||
compress/lossy_quality=0.7
|
||||
compress/uastc_level=0
|
||||
compress/rdo_quality_loss=0.0
|
||||
compress/hdr_compression=1
|
||||
compress/normal_map=0
|
||||
compress/channel_pack=0
|
||||
mipmaps/generate=false
|
||||
mipmaps/limit=-1
|
||||
roughness/mode=0
|
||||
roughness/src_normal=""
|
||||
process/channel_remap/red=0
|
||||
process/channel_remap/green=1
|
||||
process/channel_remap/blue=2
|
||||
process/channel_remap/alpha=3
|
||||
process/fix_alpha_border=true
|
||||
process/premult_alpha=false
|
||||
process/normal_map_invert_y=false
|
||||
process/hdr_as_srgb=false
|
||||
process/hdr_clamp_exposure=false
|
||||
process/size_limit=0
|
||||
detect_3d/compress_to=1
|
||||
40
shot_skin_r.png.import
Normal file
40
shot_skin_r.png.import
Normal file
@ -0,0 +1,40 @@
|
||||
[remap]
|
||||
|
||||
importer="texture"
|
||||
type="CompressedTexture2D"
|
||||
uid="uid://bkffl21h7ydui"
|
||||
path="res://.godot/imported/shot_skin_r.png-d462c89253aeeef933841bff40b3422a.ctex"
|
||||
metadata={
|
||||
"vram_texture": false
|
||||
}
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://shot_skin_r.png"
|
||||
dest_files=["res://.godot/imported/shot_skin_r.png-d462c89253aeeef933841bff40b3422a.ctex"]
|
||||
|
||||
[params]
|
||||
|
||||
compress/mode=0
|
||||
compress/high_quality=false
|
||||
compress/lossy_quality=0.7
|
||||
compress/uastc_level=0
|
||||
compress/rdo_quality_loss=0.0
|
||||
compress/hdr_compression=1
|
||||
compress/normal_map=0
|
||||
compress/channel_pack=0
|
||||
mipmaps/generate=false
|
||||
mipmaps/limit=-1
|
||||
roughness/mode=0
|
||||
roughness/src_normal=""
|
||||
process/channel_remap/red=0
|
||||
process/channel_remap/green=1
|
||||
process/channel_remap/blue=2
|
||||
process/channel_remap/alpha=3
|
||||
process/fix_alpha_border=true
|
||||
process/premult_alpha=false
|
||||
process/normal_map_invert_y=false
|
||||
process/hdr_as_srgb=false
|
||||
process/hdr_clamp_exposure=false
|
||||
process/size_limit=0
|
||||
detect_3d/compress_to=1
|
||||
42
src/car.gd
42
src/car.gd
@ -46,6 +46,9 @@ var _deform: Deformer = null
|
||||
var _snd_eng: AudioStreamPlayer3D
|
||||
var _snd_skid: AudioStreamPlayer3D
|
||||
var _snd_boost: AudioStreamPlayer3D
|
||||
var _vis_wheels: Array = [] # {node, front, radius, spin}
|
||||
var _smoke: GPUParticles3D
|
||||
var _dust: GPUParticles3D
|
||||
|
||||
@onready var wheels: Array[Node] = [$FL, $FR, $RL, $RR]
|
||||
|
||||
@ -70,6 +73,12 @@ func _ready() -> void:
|
||||
_deform = Deformer.new()
|
||||
if not _deform.setup(model):
|
||||
_deform = null
|
||||
# visual wheels spin and (front pair) steer -- static wheels are half of
|
||||
# why the old cars read as toys. Requires hub-origin wheels (build_cars).
|
||||
for w in model.find_children("wheel_*", "MeshInstance3D", true, false):
|
||||
var mi := w as MeshInstance3D
|
||||
var r := maxf(mi.get_aabb().size.y * 0.5, 0.1)
|
||||
_vis_wheels.append({"node": mi, "front": mi.position.z < 0.0, "radius": r, "spin": 0.0})
|
||||
else:
|
||||
var bm := BoxMesh.new()
|
||||
bm.size = Vector3(stats.width, 1.0, stats.length)
|
||||
@ -81,6 +90,8 @@ func _ready() -> void:
|
||||
_snd_skid = Sfx.looper(self, "skid", -7.0 if is_player else -14.0)
|
||||
if is_player:
|
||||
_snd_boost = Sfx.looper(self, "boost", -8.0)
|
||||
_smoke = Fx.smoker(self, Vector3(0, 0.6, -stats.length * 0.32))
|
||||
_dust = Fx.duster(self, Vector3(0, 0.1, stats.length * 0.42))
|
||||
|
||||
func _physics_process(delta: float) -> void:
|
||||
if is_player:
|
||||
@ -152,6 +163,15 @@ func _physics_process(delta: float) -> void:
|
||||
elif not boosting and _snd_boost.playing:
|
||||
_snd_boost.stop()
|
||||
|
||||
var fwd_speed := -global_basis.z.dot(linear_velocity)
|
||||
for w in _vis_wheels:
|
||||
w["spin"] = fposmod(w["spin"] + fwd_speed / w["radius"] * delta, TAU)
|
||||
(w["node"] as Node3D).rotation = Vector3(w["spin"], steer if w["front"] else 0.0, 0.0)
|
||||
if _smoke:
|
||||
_smoke.emitting = wrecked or (_deform != null and _deform.damage > 0.7)
|
||||
if _dust:
|
||||
_dust.emitting = drift_in and grounded >= 2 and linear_velocity.length() > 10.0
|
||||
|
||||
var jolt := linear_velocity - _prev_lv
|
||||
_prev_lv = linear_velocity
|
||||
_impact_cd = maxf(_impact_cd - delta, 0.0)
|
||||
@ -169,13 +189,14 @@ func _impact(jolt: Vector3) -> void:
|
||||
-2.0, randf_range(0.9, 1.1))
|
||||
if dv > WRECK_DV:
|
||||
Sfx.shot(self, "glass", -4.0)
|
||||
Fx.sparks(self, clampi(int(dv * 3.0), 10, 44))
|
||||
var side := (global_basis.inverse() * -jolt).normalized()
|
||||
if _deform:
|
||||
# no contact manifold from the jolt heuristic, so approximate the strike
|
||||
# point by pushing out along the impact direction to the body shell
|
||||
var shape := ($Shape as CollisionShape3D).shape as BoxShape3D
|
||||
var reach := side * shape.size * 0.5
|
||||
_deform.impact(reach, -side, minf(dv, 26.0) * 0.035)
|
||||
_deform.impact(reach, -side, minf(dv, 26.0) * 0.048)
|
||||
for i in 1 + int(dv > 8.0) + int(dv > 12.0):
|
||||
_shed_panel(side)
|
||||
if dv > WRECK_DV and not wrecked:
|
||||
@ -193,9 +214,16 @@ func _recover() -> void:
|
||||
func _shed_panel(side: Vector3) -> void:
|
||||
if _parts.is_empty():
|
||||
return
|
||||
var best: Node3D = _parts[0]
|
||||
# light hits ping a hubcap off first -- cheap, funny, and saves the doors
|
||||
# for crashes that deserve them
|
||||
var pool: Array[Node] = _parts
|
||||
if _prev_lv.length() < 14.0:
|
||||
var hubs := _parts.filter(func(p): return String(p.name).begins_with("det_hub"))
|
||||
if not hubs.is_empty():
|
||||
pool = hubs
|
||||
var best: Node3D = pool[0]
|
||||
var best_d := -INF
|
||||
for p: Node3D in _parts:
|
||||
for p: Node3D in pool:
|
||||
var d := to_local(p.global_position).normalized().dot(side)
|
||||
if d > best_d:
|
||||
best_d = d
|
||||
@ -213,7 +241,13 @@ func _shed_panel(side: Vector3) -> void:
|
||||
get_parent().add_child(rb)
|
||||
rb.global_transform = best.global_transform
|
||||
best.reparent(rb)
|
||||
rb.linear_velocity = linear_velocity
|
||||
# burst outward, not just detach: inherit the car's motion plus a radial
|
||||
# kick away from the hull and a bit of lift -- panels FLY, hubcaps roll
|
||||
var out := (rb.global_position - global_position)
|
||||
out.y = 0.0
|
||||
out = out.normalized() if out.length_squared() > 0.01 else Vector3.UP
|
||||
rb.linear_velocity = linear_velocity + out * randf_range(2.5, 6.0) \
|
||||
+ Vector3.UP * randf_range(1.5, 3.5)
|
||||
rb.angular_velocity = Vector3(randf_range(-8, 8), randf_range(-8, 8), randf_range(-8, 8))
|
||||
get_tree().create_timer(8.0).timeout.connect(rb.queue_free)
|
||||
|
||||
|
||||
129
src/fx.gd
Normal file
129
src/fx.gd
Normal file
@ -0,0 +1,129 @@
|
||||
class_name Fx
|
||||
## Procedural particle effects -- sparks, smoke, tyre dust. No textures: soft
|
||||
## billboard quads shaped entirely by process-material ramps built in code,
|
||||
## same no-assets philosophy as the sound kit.
|
||||
|
||||
static var _spark_mat: ParticleProcessMaterial
|
||||
static var _smoke_mat: ParticleProcessMaterial
|
||||
static var _dust_mat: ParticleProcessMaterial
|
||||
static var _spark_draw: QuadMesh
|
||||
static var _smoke_draw: QuadMesh
|
||||
|
||||
static func _ramp(colors: Array[Color]) -> GradientTexture1D:
|
||||
var g := Gradient.new()
|
||||
for i in colors.size():
|
||||
if i == 0:
|
||||
g.set_color(0, colors[0])
|
||||
elif i == colors.size() - 1 and colors.size() > 1:
|
||||
g.set_color(g.get_point_count() - 1, colors[i])
|
||||
else:
|
||||
g.add_point(float(i) / (colors.size() - 1), colors[i])
|
||||
var t := GradientTexture1D.new()
|
||||
t.gradient = g
|
||||
return t
|
||||
|
||||
static func _draw_quad(size: float, color: Color, emissive: bool) -> QuadMesh:
|
||||
var q := QuadMesh.new()
|
||||
q.size = Vector2(size, size)
|
||||
var m := StandardMaterial3D.new()
|
||||
m.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
|
||||
m.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
|
||||
m.billboard_mode = BaseMaterial3D.BILLBOARD_PARTICLES
|
||||
m.vertex_color_use_as_albedo = true
|
||||
m.albedo_color = color
|
||||
# radial soft falloff -- without it every particle is a hard-edged square
|
||||
# and smoke reads as flying pixels rather than puffs
|
||||
var grad := Gradient.new()
|
||||
grad.set_color(0, Color.WHITE)
|
||||
grad.set_color(1, Color(1, 1, 1, 0))
|
||||
var disc := GradientTexture2D.new()
|
||||
disc.gradient = grad
|
||||
disc.fill = GradientTexture2D.FILL_RADIAL
|
||||
disc.fill_from = Vector2(0.5, 0.5)
|
||||
disc.fill_to = Vector2(0.5, 0.02)
|
||||
disc.width = 64
|
||||
disc.height = 64
|
||||
m.albedo_texture = disc
|
||||
if emissive:
|
||||
m.emission_enabled = true
|
||||
m.emission = color
|
||||
m.emission_energy_multiplier = 2.0
|
||||
m.blend_mode = BaseMaterial3D.BLEND_MODE_ADD
|
||||
q.material = m
|
||||
return q
|
||||
|
||||
static func _init_shared() -> void:
|
||||
if _spark_mat:
|
||||
return
|
||||
_spark_mat = ParticleProcessMaterial.new()
|
||||
_spark_mat.direction = Vector3(0, 1, 0)
|
||||
_spark_mat.spread = 75.0
|
||||
_spark_mat.initial_velocity_min = 4.0
|
||||
_spark_mat.initial_velocity_max = 11.0
|
||||
_spark_mat.gravity = Vector3(0, -14, 0)
|
||||
_spark_mat.scale_min = 0.5
|
||||
_spark_mat.scale_max = 1.0
|
||||
_spark_mat.color_ramp = _ramp([Color(1.0, 0.9, 0.5), Color(1.0, 0.45, 0.05), Color(0.6, 0.1, 0.0, 0.0)])
|
||||
_spark_draw = _draw_quad(0.09, Color(1, 0.7, 0.2), true)
|
||||
|
||||
_smoke_mat = ParticleProcessMaterial.new()
|
||||
_smoke_mat.direction = Vector3(0, 1, 0)
|
||||
_smoke_mat.spread = 25.0
|
||||
_smoke_mat.initial_velocity_min = 0.8
|
||||
_smoke_mat.initial_velocity_max = 1.8
|
||||
_smoke_mat.gravity = Vector3(0, 0.9, 0)
|
||||
_smoke_mat.scale_min = 0.7
|
||||
_smoke_mat.scale_max = 1.5
|
||||
_smoke_mat.scale_over_velocity_min = 0.0
|
||||
_smoke_mat.color_ramp = _ramp([Color(0.25, 0.23, 0.22, 0.0), Color(0.30, 0.29, 0.28, 0.45), Color(0.45, 0.45, 0.45, 0.0)])
|
||||
_smoke_draw = _draw_quad(0.9, Color(0.3, 0.3, 0.3), false)
|
||||
|
||||
_dust_mat = ParticleProcessMaterial.new()
|
||||
_dust_mat.direction = Vector3(0, 1, 0)
|
||||
_dust_mat.spread = 55.0
|
||||
_dust_mat.initial_velocity_min = 1.0
|
||||
_dust_mat.initial_velocity_max = 2.5
|
||||
_dust_mat.gravity = Vector3(0, 0.4, 0)
|
||||
_dust_mat.scale_min = 0.6
|
||||
_dust_mat.scale_max = 1.2
|
||||
_dust_mat.color_ramp = _ramp([Color(0.75, 0.73, 0.70, 0.0), Color(0.72, 0.70, 0.66, 0.4), Color(0.7, 0.7, 0.7, 0.0)])
|
||||
|
||||
static func sparks(parent: Node3D, amount := 24) -> void:
|
||||
## One-shot burst at the parent's position, self-freeing.
|
||||
_init_shared()
|
||||
var p := GPUParticles3D.new()
|
||||
p.process_material = _spark_mat
|
||||
p.draw_pass_1 = _spark_draw
|
||||
p.amount = amount
|
||||
p.lifetime = 0.5
|
||||
p.one_shot = true
|
||||
p.explosiveness = 1.0
|
||||
parent.add_child(p)
|
||||
p.emitting = true
|
||||
parent.get_tree().create_timer(1.2).timeout.connect(p.queue_free)
|
||||
|
||||
static func smoker(parent: Node3D, offset: Vector3 = Vector3(0, 0.5, 0)) -> GPUParticles3D:
|
||||
## Persistent smoke column; caller toggles .emitting.
|
||||
_init_shared()
|
||||
var p := GPUParticles3D.new()
|
||||
p.process_material = _smoke_mat
|
||||
p.draw_pass_1 = _smoke_draw
|
||||
p.amount = 26
|
||||
p.lifetime = 1.6
|
||||
p.emitting = false
|
||||
p.position = offset
|
||||
parent.add_child(p)
|
||||
return p
|
||||
|
||||
static func duster(parent: Node3D, offset: Vector3) -> GPUParticles3D:
|
||||
## Tyre smoke/dust for drifts; caller toggles .emitting.
|
||||
_init_shared()
|
||||
var p := GPUParticles3D.new()
|
||||
p.process_material = _dust_mat
|
||||
p.draw_pass_1 = _smoke_draw
|
||||
p.amount = 20
|
||||
p.lifetime = 0.9
|
||||
p.emitting = false
|
||||
p.position = offset
|
||||
parent.add_child(p)
|
||||
return p
|
||||
1
src/fx.gd.uid
Normal file
1
src/fx.gd.uid
Normal file
@ -0,0 +1 @@
|
||||
uid://dnj43dt8fa3xf
|
||||
16
src/game.gd
16
src/game.gd
@ -105,6 +105,7 @@ var info_label: Label
|
||||
var msg_label: Label
|
||||
var msg_timer: SceneTreeTimer
|
||||
var _sticky := false # a result screen is up; transient messages must not clobber it
|
||||
var _shake := 0.0 # camera jolt on impacts, decays exponentially
|
||||
|
||||
func _ready() -> void:
|
||||
var level: Node = (load(level_path) as PackedScene).instantiate()
|
||||
@ -284,7 +285,7 @@ func _ready() -> void:
|
||||
t.model_idx = midx
|
||||
midx += 1
|
||||
add_child(t)
|
||||
t.smashed.connect(_traffic_smashed)
|
||||
t.smashed.connect(_traffic_smashed.bind(t))
|
||||
traffic.append(t)
|
||||
elif race_path:
|
||||
var n: int = TRAFFIC_COUNT.get(mode, 4)
|
||||
@ -294,7 +295,7 @@ func _ready() -> void:
|
||||
t.offset = race_path.curve.get_baked_length() * (i + 0.5) / n
|
||||
t.speed = randf_range(9.0, 14.0)
|
||||
add_child(t)
|
||||
t.smashed.connect(_traffic_smashed)
|
||||
t.smashed.connect(_traffic_smashed.bind(t))
|
||||
traffic.append(t)
|
||||
|
||||
cam = Camera3D.new()
|
||||
@ -879,6 +880,7 @@ func _nearest_off(pos: Vector3, seed_off: float, L: float) -> float:
|
||||
return best
|
||||
|
||||
func _player_crash(dv: float) -> void:
|
||||
_shake = maxf(_shake, clampf(dv / 16.0, 0.12, 0.65))
|
||||
if mode in ["crash", "junction"] and not over:
|
||||
score += minf(dv, 30.0) * 120.0
|
||||
if dv > 3.0:
|
||||
@ -905,7 +907,9 @@ func _rival_wrecked(r: Rival) -> void:
|
||||
Sfx.shot(car, "sting_win", -4.0)
|
||||
_msg("TAKEDOWN!", 56)
|
||||
|
||||
func _traffic_smashed(impulse: float) -> void:
|
||||
func _traffic_smashed(impulse: float, t: TrafficCar) -> void:
|
||||
if is_instance_valid(t):
|
||||
Fx.sparks(t, clampi(int(impulse * 2.0), 12, 40))
|
||||
if mode in ["crash", "junction"] and not over:
|
||||
score += impulse * 400.0
|
||||
_msg("$%d" % int(impulse * 400.0), 40)
|
||||
@ -1020,6 +1024,12 @@ func _process(delta: float) -> void:
|
||||
var target := car.global_position + car.global_basis * Vector3(0, 2.4, 6.0)
|
||||
cam.global_position = cam.global_position.lerp(target, 1.0 - exp(-5.0 * delta))
|
||||
cam.look_at(car.global_position + Vector3.UP * 1.2)
|
||||
if _shake > 0.005:
|
||||
# post-look_at, so the jolt reads as the camera being knocked, not re-aimed
|
||||
cam.global_position += Vector3(randf_range(-1, 1), randf_range(-1, 1),
|
||||
randf_range(-1, 1)) * _shake * 0.35
|
||||
cam.rotation.z += randf_range(-1, 1) * _shake * 0.03
|
||||
_shake *= exp(-6.0 * delta)
|
||||
cam.fov = lerpf(cam.fov, 84.0 if car.boosting else 72.0, 6.0 * delta)
|
||||
speed_label.text = "%d km/h" % roundf(car.speed_kmh())
|
||||
boost_bar.size.x = 160.0 * car.boost_max
|
||||
|
||||
@ -131,6 +131,40 @@ def add_obj(name, mesh, mat):
|
||||
return obj
|
||||
|
||||
|
||||
def smoothstep(x):
|
||||
x = max(0.0, min(1.0, x))
|
||||
return x * x * (3 - 2 * x)
|
||||
|
||||
|
||||
def warp_obj(obj, L, belt, roof):
|
||||
"""Slab-buster: plan taper (narrower nose/tail), tumblehome (glasshouse
|
||||
leans in above the belt) and rocker tuck (sides pull under below the sill).
|
||||
Applied to world-space verts, so det_ panels stay flush with the body."""
|
||||
f, r = L / 2, -L / 2
|
||||
for v in obj.data.vertices:
|
||||
y, z = v.co.y, v.co.z
|
||||
t = (y - r) / L
|
||||
plan = 1.0 \
|
||||
- 0.11 * smoothstep((t - 0.70) / 0.30) ** 1.4 \
|
||||
- 0.07 * smoothstep((0.16 - t) / 0.16) ** 1.4
|
||||
tumble = 1.0
|
||||
if z > belt and roof > belt:
|
||||
tumble = 1.0 - 0.15 * min((z - belt) / (roof - belt), 1.0) ** 1.15
|
||||
tuck = 1.0
|
||||
if z < 0.34:
|
||||
tuck = 1.0 - 0.07 * ((0.34 - z) / 0.34) ** 2
|
||||
v.co.x *= plan * tumble * tuck
|
||||
|
||||
|
||||
def bevel_body(obj, width=0.035):
|
||||
bm = bmesh.new()
|
||||
bm.from_mesh(obj.data)
|
||||
bmesh.ops.bevel(bm, geom=list(bm.edges), offset=width, segments=1,
|
||||
profile=0.7, affect='EDGES')
|
||||
bm.to_mesh(obj.data)
|
||||
bm.free()
|
||||
|
||||
|
||||
def extrude_profile(name, profile, half_w, mat):
|
||||
"""Closed (y,z) polygon extruded from -half_w to +half_w along x."""
|
||||
n = len(profile)
|
||||
@ -163,15 +197,61 @@ def box(name, center, size, mat, rot_x=0.0):
|
||||
return add_obj(name, mesh, mat)
|
||||
|
||||
|
||||
def wheel(name, x, y, r, mat):
|
||||
def wheel(name, x, y, r, tyre_mat, rim_mat):
|
||||
"""Tyre + inset rim, mesh centred on the hub and the OBJECT placed at the
|
||||
hub -- the old version baked position into the verts, which meant a spinning
|
||||
wheel in-game would orbit the car's origin instead of its own axle."""
|
||||
mesh = bpy.data.meshes.new(name)
|
||||
bm = bmesh.new()
|
||||
bmesh.ops.create_cone(bm, cap_ends=True, segments=14, radius1=r, radius2=r, depth=0.24)
|
||||
res = bmesh.ops.create_cone(bm, cap_ends=True, segments=14, radius1=r, radius2=r, depth=0.24)
|
||||
for f in {f for v in res["verts"] for f in v.link_faces}:
|
||||
f.material_index = 0
|
||||
res = bmesh.ops.create_cone(bm, cap_ends=True, segments=12,
|
||||
radius1=r * 0.58, radius2=r * 0.58, depth=0.26)
|
||||
for f in {f for v in res["verts"] for f in v.link_faces}:
|
||||
f.material_index = 1
|
||||
bmesh.ops.rotate(bm, cent=(0, 0, 0), matrix=Matrix.Rotation(math.pi / 2, 3, "Y"), verts=bm.verts)
|
||||
bmesh.ops.translate(bm, vec=(x, y, r), verts=bm.verts)
|
||||
bm.to_mesh(mesh)
|
||||
bm.free()
|
||||
return add_obj(name, mesh, mat)
|
||||
obj = add_obj(name, mesh, tyre_mat)
|
||||
obj.data.materials.append(rim_mat)
|
||||
obj.location = (x, y, r)
|
||||
return obj
|
||||
|
||||
|
||||
def hubcap(name, x, y, r, side, mat):
|
||||
"""Chrome dish on the wheel's outer face. det_ so it pings off on impact --
|
||||
every shitbox loses a hubcap eventually, that's canon."""
|
||||
mesh = bpy.data.meshes.new(name)
|
||||
bm = bmesh.new()
|
||||
res = bmesh.ops.create_cone(bm, cap_ends=True, segments=12,
|
||||
radius1=r * 0.42, radius2=r * 0.30, depth=0.05)
|
||||
bmesh.ops.rotate(bm, cent=(0, 0, 0),
|
||||
matrix=Matrix.Rotation(math.pi / 2 * (-1 if side < 0 else 1), 3, "Y"),
|
||||
verts=bm.verts)
|
||||
bm.to_mesh(mesh)
|
||||
bm.free()
|
||||
obj = add_obj(name, mesh, mat)
|
||||
obj.location = (x + side * 0.135, y, r)
|
||||
return obj
|
||||
|
||||
|
||||
def arch(name, x, y, r, mat):
|
||||
"""Dark half-shroud over the wheel so it reads as sitting in a well
|
||||
rather than bolted under a slab."""
|
||||
mesh = bpy.data.meshes.new(name)
|
||||
bm = bmesh.new()
|
||||
res = bmesh.ops.create_cone(bm, cap_ends=False, segments=10,
|
||||
radius1=r * 1.22, radius2=r * 1.22, depth=0.30)
|
||||
bmesh.ops.rotate(bm, cent=(0, 0, 0), matrix=Matrix.Rotation(math.pi / 2, 3, "Y"), verts=bm.verts)
|
||||
# keep only the top half of the drum
|
||||
doomed = [v for v in bm.verts if v.co.z < 0.05]
|
||||
bmesh.ops.delete(bm, geom=doomed, context="VERTS")
|
||||
bm.to_mesh(mesh)
|
||||
bm.free()
|
||||
obj = add_obj(name, mesh, mat)
|
||||
obj.location = (x, y, r * 0.95)
|
||||
return obj
|
||||
|
||||
|
||||
def build(car_id, s):
|
||||
@ -221,7 +301,9 @@ def build(car_id, s):
|
||||
(0.06, half - 0.06, door_h), paint_dk))
|
||||
parts.append(box("det_door_r%s" % side, (sx * hw, boot_y + 0.05 + half / 2, door_z),
|
||||
(0.06, half - 0.06, door_h), paint_dk))
|
||||
parts.append(box("det_mirror_%s" % side, (sx * (hw + 0.09), ws_base + 0.05, s["belt"] + 0.10),
|
||||
# hugs the warped body: mirrors skip warp_obj (they stay proud), so
|
||||
# anchor them to roughly where the tapered flank actually ends up
|
||||
parts.append(box("det_mirror_%s" % side, (sx * (hw * 0.94 + 0.08), ws_base + 0.05, s["belt"] + 0.10),
|
||||
(0.14, 0.09, 0.12), dark))
|
||||
parts.append(box("det_bumper_f", (0, f + 0.05, bottom + 0.16), (W + 0.06, 0.15, 0.16), bumper_mat))
|
||||
parts.append(box("det_bumper_r", (0, r - 0.05, bottom + 0.16), (W + 0.06, 0.15, 0.16), bumper_mat))
|
||||
@ -282,10 +364,53 @@ def build(car_id, s):
|
||||
trim.append(box("trim_strip_%s" % ("l" if sx < 0 else "r"),
|
||||
(sx * (hw + 0.015), -L * 0.02, 0.52), (0.03, strip_len, 0.055), dark))
|
||||
|
||||
# licence plates, exhaust and door handles -- the cheap details that read
|
||||
plate_f = material("plate_f", (0.88, 0.88, 0.84, 1), rough=0.5)
|
||||
plate_r = material("plate_r", (0.82, 0.74, 0.38, 1), rough=0.5)
|
||||
trim.append(box("trim_plate_f", (0, f + 0.09, bottom + 0.17), (0.36, 0.02, 0.11), plate_f))
|
||||
trim.append(box("trim_plate_r", (0, r - 0.09, bottom + 0.17), (0.36, 0.02, 0.11), plate_r))
|
||||
ex = bpy.data.meshes.new("exhaust")
|
||||
bmx = bmesh.new()
|
||||
bmesh.ops.create_cone(bmx, cap_ends=True, segments=8, radius1=0.045, radius2=0.045, depth=0.3)
|
||||
bmesh.ops.rotate(bmx, cent=(0, 0, 0), matrix=Matrix.Rotation(math.pi / 2, 3, "X"), verts=bmx.verts)
|
||||
bmx.to_mesh(ex)
|
||||
bmx.free()
|
||||
exo = add_obj("trim_exhaust", ex, dark)
|
||||
exo.location = (-W * 0.28, r - 0.02, bottom - 0.04)
|
||||
trim.append(exo)
|
||||
span_h = (ws_base - 0.05) - (boot_y + 0.05)
|
||||
for sx in (-1, 1):
|
||||
for k in range(1 if s.get("doors", 4) == 2 else 2):
|
||||
hy = ws_base - 0.05 - span_h * (0.30 + 0.5 * k)
|
||||
trim.append(box("trim_handle", (sx * hw, hy, s["belt"] - 0.10),
|
||||
(0.05, 0.14, 0.03), chrome))
|
||||
# B-pillar over the one-piece glasshouse so the cabin reads as windows, not canopy
|
||||
if s.get("doors", 4) != 2:
|
||||
py = ws_base - 0.05 - span_h / 2
|
||||
for sx in (-1, 1):
|
||||
trim.append(box("trim_bpillar", (sx * hw * 0.82, py, (s["belt"] + s["roof"]) / 2),
|
||||
(0.035, 0.07, s["roof"] - s["belt"] - 0.02), paint_dk))
|
||||
|
||||
# sculpt: bevel the slab edges, then taper/tumblehome/tuck everything but
|
||||
# mirrors (stay proud) and wheels (positioned by object, not verts)
|
||||
bevel_body(obj=body)
|
||||
for o in [body] + parts + trim:
|
||||
if "mirror" in o.name:
|
||||
continue
|
||||
warp_obj(o, L, s["belt"], s["roof"])
|
||||
|
||||
def plan_at(y):
|
||||
t = (y - r) / L
|
||||
return 1.0 - 0.11 * smoothstep((t - 0.70) / 0.30) ** 1.4 \
|
||||
- 0.07 * smoothstep((0.16 - t) / 0.16) ** 1.4
|
||||
|
||||
ax_f, ax_r = f - s["ohang_f"], r + s["ohang_r"]
|
||||
wx = hw - 0.10
|
||||
wheels = [wheel("wheel_fl", -wx, ax_f, s["wheel"], tyre), wheel("wheel_fr", wx, ax_f, s["wheel"], tyre),
|
||||
wheel("wheel_rl", -wx, ax_r, s["wheel"], tyre), wheel("wheel_rr", wx, ax_r, s["wheel"], tyre)]
|
||||
wheels = []
|
||||
for wn, wx_s, ay in (("fl", -1, ax_f), ("fr", 1, ax_f), ("rl", -1, ax_r), ("rr", 1, ax_r)):
|
||||
wx = (hw - 0.10) * plan_at(ay)
|
||||
wheels.append(wheel("wheel_%s" % wn, wx_s * wx, ay, s["wheel"], tyre, material("rim", (0.28, 0.28, 0.30, 1), rough=0.6)))
|
||||
wheels.append(hubcap("det_hub_%s" % wn, wx_s * wx, ay, s["wheel"], wx_s, chrome))
|
||||
wheels.append(arch("arch_%s" % wn, wx_s * wx, ay, s["wheel"], dark))
|
||||
|
||||
for obj in parts + trim + wheels:
|
||||
obj.parent = body
|
||||
@ -355,8 +480,9 @@ def build_moke(car_id, s):
|
||||
axles = [ax_f, ax_r] + [r + e for e in s.get("extra_axles", [])]
|
||||
wheels = []
|
||||
for i, ay in enumerate(axles):
|
||||
wheels.append(wheel("wheel_%dl" % i, -wx, ay, s["wheel"], tyre))
|
||||
wheels.append(wheel("wheel_%dr" % i, wx, ay, s["wheel"], tyre))
|
||||
rim = material("rim%d" % i, (0.28, 0.28, 0.30, 1), rough=0.6)
|
||||
wheels.append(wheel("wheel_%dl" % i, -wx, ay, s["wheel"], tyre, rim))
|
||||
wheels.append(wheel("wheel_%dr" % i, wx, ay, s["wheel"], tyre, rim))
|
||||
|
||||
for obj in parts + trim + wheels:
|
||||
obj.parent = body
|
||||
|
||||
Loading…
Reference in New Issue
Block a user