diff --git a/cars/chaebol_x1/car.glb b/cars/chaebol_x1/car.glb index cd18ad3..a55e47a 100644 Binary files a/cars/chaebol_x1/car.glb and b/cars/chaebol_x1/car.glb differ diff --git a/cars/chaebol_x2/car.glb b/cars/chaebol_x2/car.glb index 05931b6..e5645b4 100644 Binary files a/cars/chaebol_x2/car.glb and b/cars/chaebol_x2/car.glb differ diff --git a/cars/datto_120y/car.glb b/cars/datto_120y/car.glb index 1f85c54..a1051e4 100644 Binary files a/cars/datto_120y/car.glb and b/cars/datto_120y/car.glb differ diff --git a/cars/excel_x3/car.glb b/cars/excel_x3/car.glb index 8a66afc..132325e 100644 Binary files a/cars/excel_x3/car.glb and b/cars/excel_x3/car.glb differ diff --git a/cars/floored_xd/car.glb b/cars/floored_xd/car.glb index 80a1d65..f37e821 100644 Binary files a/cars/floored_xd/car.glb and b/cars/floored_xd/car.glb differ diff --git a/cars/gemmy_tx/car.glb b/cars/gemmy_tx/car.glb index 073ad19..c6f77dd 100644 Binary files a/cars/gemmy_tx/car.glb and b/cars/gemmy_tx/car.glb differ diff --git a/cars/hardon_cmere/car.glb b/cars/hardon_cmere/car.glb index 88f01b1..54eb889 100644 Binary files a/cars/hardon_cmere/car.glb and b/cars/hardon_cmere/car.glb differ diff --git a/cars/hardon_shaggen/car.glb b/cars/hardon_shaggen/car.glb index 2533aee..2f26fcc 100644 Binary files a/cars/hardon_shaggen/car.glb and b/cars/hardon_shaggen/car.glb differ diff --git a/cars/kingswood_hq/car.glb b/cars/kingswood_hq/car.glb index 0099516..1e08524 100644 Binary files a/cars/kingswood_hq/car.glb and b/cars/kingswood_hq/car.glb differ diff --git a/cars/lazza_ka/car.glb b/cars/lazza_ka/car.glb index ea28e06..1206d14 100644 Binary files a/cars/lazza_ka/car.glb and b/cars/lazza_ka/car.glb differ diff --git a/cars/maxi_merk/car.glb b/cars/maxi_merk/car.glb index 4d25bd5..ee426fa 100644 Binary files a/cars/maxi_merk/car.glb and b/cars/maxi_merk/car.glb differ diff --git a/cars/mini_merk/car.glb b/cars/mini_merk/car.glb index 705d1cf..e7a4c9c 100644 Binary files a/cars/mini_merk/car.glb and b/cars/mini_merk/car.glb differ diff --git a/cars/turdrunner_slur/car.glb b/cars/turdrunner_slur/car.glb index 6b21589..066182d 100644 Binary files a/cars/turdrunner_slur/car.glb and b/cars/turdrunner_slur/car.glb differ diff --git a/cars/vl_terbo/car.glb b/cars/vl_terbo/car.glb index e1dd0fe..02333ed 100644 Binary files a/cars/vl_terbo/car.glb and b/cars/vl_terbo/car.glb differ diff --git a/cars/xf_falcon/car.glb b/cars/xf_falcon/car.glb index f2d854d..b6bffd7 100644 Binary files a/cars/xf_falcon/car.glb and b/cars/xf_falcon/car.glb differ diff --git a/shot_juice1.png.import b/shot_juice1.png.import new file mode 100644 index 0000000..a4b9630 --- /dev/null +++ b/shot_juice1.png.import @@ -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 diff --git a/shot_juice2.png.import b/shot_juice2.png.import new file mode 100644 index 0000000..bb81ae2 --- /dev/null +++ b/shot_juice2.png.import @@ -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 diff --git a/shot_skin_f.png.import b/shot_skin_f.png.import new file mode 100644 index 0000000..2b2c94d --- /dev/null +++ b/shot_skin_f.png.import @@ -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 diff --git a/shot_skin_r.png.import b/shot_skin_r.png.import new file mode 100644 index 0000000..7fe36da --- /dev/null +++ b/shot_skin_r.png.import @@ -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 diff --git a/src/car.gd b/src/car.gd index 3b1fa4d..1a0e76c 100644 --- a/src/car.gd +++ b/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) diff --git a/src/fx.gd b/src/fx.gd new file mode 100644 index 0000000..589c696 --- /dev/null +++ b/src/fx.gd @@ -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 diff --git a/src/fx.gd.uid b/src/fx.gd.uid new file mode 100644 index 0000000..92964c1 --- /dev/null +++ b/src/fx.gd.uid @@ -0,0 +1 @@ +uid://dnj43dt8fa3xf diff --git a/src/game.gd b/src/game.gd index 381c336..e907ae6 100644 --- a/src/game.gd +++ b/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 diff --git a/tools/build_cars.py b/tools/build_cars.py index 01e3213..35c44d8 100644 --- a/tools/build_cars.py +++ b/tools/build_cars.py @@ -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