commit a8b735f79e3df581970d5c9656d58c786090fff0 Author: Monster Robot Party Date: Sat Jul 4 00:25:35 2026 +1000 Destroyulator prototype: first-person cascade smasher (Godot 4.7) Mac-native (Apple Silicon / Metal), Jolt physics. - First-person controller + semi-opaque viewmodel hands - Punch (L-click) / kick (R-click) via a swappable MeleeAttack resource - Structural support-graph cascade: knock the legs -> the rack pancakes - Brittle vinyl records shatter on hard impact - Day-0 stress gate (B: rain 500) — holds 60fps on the M3 Ultra Co-Authored-By: Claude Opus 4.8 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2625c32 --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +# Godot 4 editor cache & reimportable data +.godot/ + +# Godot export output +/build/ +/export/ + +# macOS +.DS_Store diff --git a/README.md b/README.md new file mode 100644 index 0000000..ddf9e7e --- /dev/null +++ b/README.md @@ -0,0 +1,49 @@ +# Destroyulator — Cascade Prototype + +Mac-first (Apple Silicon / Metal), **Godot 4.7**, **Jolt** physics. Built to prove two +things on the M3 *before* committing: the destruction feels good, and the frame rate holds. + +## Run it + +**Editor (easiest):** open `/Applications/Godot.app`, import this folder (`game/`), press **F5** / ▶. + +**CLI:** +```sh +/Applications/Godot.app/Contents/MacOS/Godot --path /Users/m3ultra/Documents/Destroyulater/game +``` + +## Controls + +| Input | Does | +|---|---| +| **Left-click** | Swing the (nerf) hammer at whatever's under the cursor | +| **S** | Rain **500** rigid bodies — the **Day-0 stress gate**. Watch the FPS counter hold. | +| **R** | Reset the rack | + +Top-left HUD shows **FPS · live body count · things smashed**. + +## What it's proving — the cascade + +The rack is nested the same way your store's DB is (`rack → shelf → crate → jacket → record`): + +- **Frame + shelves** start *frozen* (act static, hold everything up). +- Smash a **shelf** → it bursts into shards and vanishes → the **crates** on it fall → + the **cardboard jackets** spill → the **vinyl records** tumble out → +- **records are brittle** (`brittle_speed` in `Smashable.gd`) → they **crack a beat later + when they smack the floor**. A record that lands flat *survives* — there's your comedy/score beat. + +No scripted "release" logic — gravity reads the hierarchy. That's the whole point. + +## Files + +- `scripts/Smashable.gd` — one component for every breakable piece. The `PROFILES` table is + where **material identity** is authored (color · shard count · brittleness · bounce). + Swap `wood/cardboard/vinyl/glass/steel` behaviours here. +- `scripts/Main.gd` — builds the world + rack from primitives, hammer raycast, stress test, HUD. + +## Next step: real art (one function) + +Everything is grey primitives so it runs with zero import deps. To use your actual assets, +change `_piece()` in `Main.gd` to load a GLB instead of building a BoxMesh, e.g. +`load("res://assets/wooden-rack-type-long.glb").instantiate()` for the visual, keep the +convex collider. Your `3D-STORE/clean_glbs/*.glb` are ready to copy into `game/assets/`. diff --git a/main.tscn b/main.tscn new file mode 100644 index 0000000..48e3563 --- /dev/null +++ b/main.tscn @@ -0,0 +1,6 @@ +[gd_scene load_steps=2 format=3] + +[ext_resource type="Script" path="res://scripts/Main.gd" id="1_main"] + +[node name="Main" type="Node3D"] +script = ExtResource("1_main") diff --git a/project.godot b/project.godot new file mode 100644 index 0000000..959376c --- /dev/null +++ b/project.godot @@ -0,0 +1,25 @@ +; Engine configuration file. +; It's best edited using the editor UI and not directly, +; since the parameters that go here are not all obvious. +; +; Format: +; [section] ; section goes between [] +; param=value ; assign values to parameters + +config_version=5 + +[application] + +config/name="Destroyulator — Cascade Prototype" +config/description="Smash a record rack, watch it cascade. Mac-first proto." +run/main_scene="res://main.tscn" +config/features=PackedStringArray("4.7", "Forward Plus") + +[physics] + +3d/physics_engine="Jolt Physics" + +[rendering] + +lights_and_shadows/directional_shadow/size=2048 +anti_aliasing/quality/msaa_3d=1 diff --git a/scripts/Main.gd b/scripts/Main.gd new file mode 100644 index 0000000..3f1bf9f --- /dev/null +++ b/scripts/Main.gd @@ -0,0 +1,196 @@ +extends Node3D + +## Destroyulator — cascade prototype. +## +## Builds a little record rack out of primitives and lets you smash it. The point +## isn't the art (that's a one-line swap for your real .glb racks later) — it's to +## prove two things on your actual M3, today: +## 1. THE DAY-0 GATE: press S to rain 500 rigid bodies and watch the FPS hold. +## 2. THE CASCADE: smash a frozen shelf -> the crates on it fall -> jackets spill +## -> records tumble out -> records crack when they hit the floor (brittle). +## +## Controls: WASD move · Space jump · L-click punch · R-click kick · Esc release mouse +## · B = rain 500 (stress gate) · R = reset + +@onready var _cam: Camera3D +var _world: Node3D # everything smashable/debris lives here so reset is one line +var _hud: Label +var _smash_count := 0 + +func _ready() -> void: + randomize() + _setup_world() + _build_rack(Vector3.ZERO) + _setup_hud() + +# ---------------------------------------------------------------- world / camera +func _setup_world() -> void: + var we := WorldEnvironment.new() + var env := Environment.new() + env.background_mode = Environment.BG_COLOR + env.background_color = Color(0.07, 0.06, 0.09) + env.ambient_light_source = Environment.AMBIENT_SOURCE_COLOR + env.ambient_light_color = Color(0.5, 0.5, 0.62) + env.ambient_light_energy = 0.6 + we.environment = env + add_child(we) + + var sun := DirectionalLight3D.new() + sun.rotation_degrees = Vector3(-52, -38, 0) + sun.light_energy = 1.3 + sun.shadow_enabled = true + add_child(sun) + + # First-person player replaces the old fixed camera. add_child() runs its _ready() + # synchronously, so player.camera exists on the next line; we reuse it for HUD/debug. + var player := Player.new() + add_child(player) + player.global_position = Vector3(0, 0, 4) # stand back, facing -Z toward the rack + _cam = player.camera + + # floor + var floor_body := StaticBody3D.new() + var fmesh := MeshInstance3D.new() + var plane := BoxMesh.new() + plane.size = Vector3(24, 0.2, 24) + fmesh.mesh = plane + var fmat := StandardMaterial3D.new() + fmat.albedo_color = Color(0.13, 0.12, 0.15) + fmesh.material_override = fmat + floor_body.add_child(fmesh) + var fcol := CollisionShape3D.new() + var fshape := BoxShape3D.new() + fshape.size = plane.size + fcol.shape = fshape + floor_body.add_child(fcol) + floor_body.position = Vector3(0, -0.1, 0) + add_child(floor_body) + + _world = Node3D.new() + _world.name = "World" + add_child(_world) + +# ---------------------------------------------------------------- the rack +# One frame (frozen) holding shelves; on each shelf, crates; in each crate, +# cardboard jackets standing up with brittle vinyl records tucked between them. +func _build_rack(origin: Vector3) -> void: + var shelf_ys := [0.45, 1.15] + + # 4 posts (frozen wood) — the primary supports for BOTH shelves. + var posts: Array[Smashable] = [] + for sx in [-0.62, 0.62]: + for sz in [-0.28, 0.28]: + posts.append(_piece("wood", Vector3(0.06, 1.5, 0.06), + origin + Vector3(sx, 0.75, sz), true)) + + for y in shelf_ys: + # frozen shelf slab — smash THIS (or any leg) to drop everything above it. + var shelf := _piece("wood", Vector3(1.34, 0.05, 0.62), origin + Vector3(0, y, 0), true) + # every leg holds up every shelf: knock ANY leg -> both shelves drop (arcade call). + for post in posts: + post.supports.append(shelf) + + # two crates resting on the shelf; the shelf holds them up. + for cx in [-0.34, 0.34]: + var crate_pos: Vector3 = origin + Vector3(cx, y + 0.22, 0) + var crate := _piece("wood", Vector3(0.5, 0.34, 0.5), crate_pos, false) + shelf.supports.append(crate) # shelf gone -> crate released too + # jackets + a record sit loose in the crate — already dynamic, they fall on + # their own once the crate/shelf is gone. Do NOT add them to supports. + var idx := 0 + for jz in [-0.14, -0.05, 0.05, 0.14]: + _piece("cardboard", Vector3(0.34, 0.34, 0.03), + crate_pos + Vector3(0, 0.12, jz), false) + if idx == 1: + # a record tucked in, lying flat-ish; brittle -> cracks on hard landing + _piece("vinyl", Vector3(0.15, 0.02, 0.15), + crate_pos + Vector3(0, 0.24, jz + 0.02), false, true) + idx += 1 + +func _piece(kind: String, size: Vector3, pos: Vector3, frozen: bool, cylinder := false) -> Smashable: + var s := Smashable.new() + s.kind = kind + s.start_frozen = frozen + var mat := StandardMaterial3D.new() + mat.albedo_color = Smashable.PROFILES[kind]["color"] + var mesh := MeshInstance3D.new() + mesh.material_override = mat + var col := CollisionShape3D.new() + if cylinder: + var cm := CylinderMesh.new() + cm.top_radius = size.x + cm.bottom_radius = size.x + cm.height = size.y + mesh.mesh = cm + var cs := CylinderShape3D.new() + cs.radius = size.x + cs.height = size.y + col.shape = cs + else: + var bm := BoxMesh.new() + bm.size = size + mesh.mesh = bm + var bs := BoxShape3D.new() + bs.size = size + col.shape = bs + s.add_child(mesh) + s.add_child(col) + _world.add_child(s) # _ready() runs here (kind + frozen already set) + s.global_position = pos + s.smashed.connect(_on_smashed) + return s + +func _on_smashed(_kind: String, _at: Vector3, _shards: int) -> void: + _smash_count += 1 + +# ---------------------------------------------------------------- input +func _unhandled_input(event: InputEvent) -> void: + # Melee (L-click punch / R-click kick) and Esc live in Player.gd. Here we keep the + # debug keys. Rain moved off S -> B, because the player now uses S to walk backward. + if event is InputEventKey and event.pressed and not event.echo: + if event.keycode == KEY_B: + _rain(500) + elif event.keycode == KEY_R: + _reset() + +# The Day-0 stress gate: rain N bodies, watch the FPS counter hold. +func _rain(n: int) -> void: + for i in n: + var b := RigidBody3D.new() + b.add_to_group("debris") + var mesh := MeshInstance3D.new() + var bm := BoxMesh.new() + bm.size = Vector3(0.12, 0.12, 0.12) + mesh.mesh = bm + b.add_child(mesh) + var col := CollisionShape3D.new() + var shape := BoxShape3D.new() + shape.size = bm.size + col.shape = shape + b.add_child(col) + _world.add_child(b) + b.global_position = Vector3(randf_range(-2.2, 2.2), randf_range(3.5, 8.0), randf_range(-2.2, 2.2)) + +func _reset() -> void: + for c in _world.get_children(): + c.queue_free() + _smash_count = 0 + await get_tree().process_frame + _build_rack(Vector3.ZERO) + +# ---------------------------------------------------------------- HUD +func _setup_hud() -> void: + var layer := CanvasLayer.new() + add_child(layer) + _hud = Label.new() + _hud.position = Vector2(16, 12) + _hud.add_theme_font_size_override("font_size", 18) + _hud.add_theme_color_override("font_color", Color(1, 0.36, 0.62)) + layer.add_child(_hud) + +func _process(_dt: float) -> void: + if _hud == null: + return + var bodies := get_tree().get_nodes_in_group("smashable").size() + get_tree().get_nodes_in_group("debris").size() + _hud.text = "FPS %d bodies %d smashed %d\nWASD move Space jump L-click punch R-click kick Esc release B: rain 500 R: reset" % [ + Engine.get_frames_per_second(), bodies, _smash_count] diff --git a/scripts/Main.gd.uid b/scripts/Main.gd.uid new file mode 100644 index 0000000..cb29818 --- /dev/null +++ b/scripts/Main.gd.uid @@ -0,0 +1 @@ +uid://cu0jxa8bwjjon diff --git a/scripts/MeleeAttack.gd b/scripts/MeleeAttack.gd new file mode 100644 index 0000000..3ef510d --- /dev/null +++ b/scripts/MeleeAttack.gd @@ -0,0 +1,69 @@ +extends Resource +class_name MeleeAttack + +## Config + logic for one melee swing (punch, kick, or a real weapon later). +## Swap the resource + viewmodel and you have a new weapon; movement never changes. +## +## Hit detection is a forgiving SPHERE query against the physics space, fired from +## the camera center (crosshair) so you don't have to pixel-aim. intersect_shape is +## NOT distance-sorted, so we pick the collider nearest the camera and act on it. +## +## Godot 4.7 GDScript 2.0. Verified: PhysicsShapeQueryParameters3D, SphereShape3D, +## PhysicsDirectSpaceState3D.intersect_shape, apply_impulse(impulse, rel_pos). + +@export var reach: float = 1.8 ## distance in front of the camera the hit sphere sits +@export var radius: float = 0.38 ## fat sphere so melee is forgiving, not pixel-precise +@export var impulse: float = 6.0 ## smash strength handed to Smashable.smash() +@export var knockback: float = 3.0 ## central/off-center impulse for plain RigidBody3D props +@export var cooldown: float = 0.28 ## seconds between swings with this attack + +## Run the hit test and apply effects. +## caster : the player (supplies the physics world + a self-exclude RID) +## origin : camera world position +## dir : camera forward (normalized), i.e. -camera.global_transform.basis.z +## Returns the node hit (Smashable or RigidBody3D) or null. Safe to call only from +## _physics_process — that's where direct_space_state is guaranteed valid. +func strike(caster: Node3D, origin: Vector3, dir: Vector3, exclude: Array = []) -> Node: + var space := caster.get_world_3d().direct_space_state + if space == null: + return null + + var sphere := SphereShape3D.new() + sphere.radius = radius + + var params := PhysicsShapeQueryParameters3D.new() + params.shape = sphere + params.transform = Transform3D(Basis.IDENTITY, origin + dir * reach) + params.collide_with_bodies = true + params.collide_with_areas = false + params.exclude = exclude # [caster.get_rid()] so the fat sphere never self-hits + + var hits := space.intersect_shape(params, 8) + if hits.is_empty(): + return null + + # intersect_shape is unsorted — pick the collider nearest the camera so a swing + # hits the first thing in front, not something behind it. + var best: Node = null + var best_d := INF + for h in hits: + var col = h.get("collider") + if col == null or not (col is Node3D): + continue + var d: float = (col.global_position - origin).length_squared() + if d < best_d: + best_d = d + best = col + + if best == null: + return null + + # Smashable extends RigidBody3D, so this check MUST come first or we'd only + # knock it around and never shatter it. + if best is Smashable: + best.smash(dir * impulse) + elif best is RigidBody3D: + # off-center impulse (relative to center of mass) so props tumble, not glide + var hit_pos: Vector3 = origin + dir * reach + best.apply_impulse(dir * knockback, hit_pos - best.global_position) + return best diff --git a/scripts/MeleeAttack.gd.uid b/scripts/MeleeAttack.gd.uid new file mode 100644 index 0000000..646b0ee --- /dev/null +++ b/scripts/MeleeAttack.gd.uid @@ -0,0 +1 @@ +uid://d4anp68080714 diff --git a/scripts/Player.gd b/scripts/Player.gd new file mode 100644 index 0000000..71a9e77 --- /dev/null +++ b/scripts/Player.gd @@ -0,0 +1,246 @@ +extends CharacterBody3D +class_name Player + +## First-person player for Destroyulator. Built entirely in code so Main.gd can +## spawn it where the old fixed Camera3D was and reuse `player.camera` for the +## crosshair raycast. Mouse-captured look, WASD move, Space jump; L-click PUNCH, +## R-click KICK; Esc releases the mouse (click recaptures). +## +## Melee is a swappable MeleeAttack resource (reach/radius/impulse/knockback/cooldown) +## so real weapons slot in by swapping the resource + viewmodel — movement never +## changes. Hits are fired from screen-center (the mouse is captured, so its position +## is meaningless) and resolved in _physics_process, where direct_space_state is valid. +## +## Godot 4.7 GDScript 2.0. No 3.x APIs: move_and_slide() takes no args and reads/writes +## `velocity`; get_gravity() supplies the project default; mouse_mode is a property. + +@export var move_speed: float = 5.0 +@export var accel: float = 12.0 +@export var air_accel: float = 3.0 +@export var jump_velocity: float = 4.5 +@export var mouse_sensitivity: float = 0.0025 # radians per pixel of mouse motion +@export var pitch_limit_deg: float = 89.0 +@export var eye_height: float = 1.6 + +var camera: Camera3D # Main reads this for the crosshair raycast + +var _head: Node3D +var _hands_rig: Node3D # cosmetic viewmodel (hands + leg), swung on attack +var _r_hand: MeshInstance3D +var _l_hand: MeshInstance3D +var _leg: MeshInstance3D +var _swing_tween: Tween + +var _pitch := 0.0 +var _cd_left := 0.0 +var _queued: MeleeAttack = null # buffered click, consumed next physics step + +var _punch: MeleeAttack +var _kick: MeleeAttack + +# resting local transforms of the viewmodel parts (relative to the camera) +const R_HAND_REST := Vector3(0.30, -0.32, -0.55) +const L_HAND_REST := Vector3(-0.30, -0.34, -0.58) +const LEG_REST := Vector3(0.06, -1.05, -0.42) + +func _ready() -> void: + # --- collision capsule (feet at the body origin) --- + var col := CollisionShape3D.new() + var cap := CapsuleShape3D.new() + cap.radius = 0.35 + cap.height = 1.8 + col.shape = cap + col.position = Vector3(0, 0.9, 0) # capsule center at height/2 so feet sit at origin + add_child(col) + + # --- head (pitch) + camera --- + _head = Node3D.new() + _head.name = "Head" + _head.position = Vector3(0, eye_height, 0) + add_child(_head) + + camera = Camera3D.new() + camera.name = "Camera3D" + _head.add_child(camera) + camera.current = true + + _build_viewmodel() + _build_crosshair() + _make_attacks() + + Input.mouse_mode = Input.MOUSE_MODE_CAPTURED + +# ---------------------------------------------------------------- attacks +func _make_attacks() -> void: + _punch = MeleeAttack.new() + _punch.reach = 1.7 + _punch.radius = 0.34 + _punch.impulse = 6.0 + _punch.knockback = 2.5 + _punch.cooldown = 0.26 + + _kick = MeleeAttack.new() + _kick.reach = 2.2 + _kick.radius = 0.42 + _kick.impulse = 10.0 + _kick.knockback = 5.0 + _kick.cooldown = 0.48 + +# ---------------------------------------------------------------- viewmodel +func _build_viewmodel() -> void: + _hands_rig = Node3D.new() + _hands_rig.name = "ViewModel" + camera.add_child(_hands_rig) + + var mat := StandardMaterial3D.new() + # semi-opaque: BOTH the alpha mode AND alpha<1 are required (alpha alone renders opaque) + mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA + mat.albedo_color = Color(0.86, 0.62, 0.5, 0.55) + mat.no_depth_test = true # hands draw over world geo, never clip + mat.render_priority = 1 + mat.cull_mode = BaseMaterial3D.CULL_DISABLED + mat.roughness = 0.85 + + _r_hand = _make_capsule(mat) + _l_hand = _make_capsule(mat) + _leg = _make_leg(mat) + _hands_rig.add_child(_r_hand) + _hands_rig.add_child(_l_hand) + _hands_rig.add_child(_leg) + + _r_hand.position = R_HAND_REST + _l_hand.position = L_HAND_REST + _leg.position = LEG_REST + _r_hand.rotation = Vector3(deg_to_rad(-70), 0.0, deg_to_rad(-12)) + _l_hand.rotation = Vector3(deg_to_rad(-70), 0.0, deg_to_rad(12)) + _leg.rotation = Vector3(deg_to_rad(-90), 0.0, 0.0) + +func _make_capsule(mat: StandardMaterial3D) -> MeshInstance3D: + var mi := MeshInstance3D.new() + var cap := CapsuleMesh.new() + cap.radius = 0.055 + cap.height = 0.20 # full height incl. the hemispherical caps + mi.mesh = cap + mi.material_override = mat + mi.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF + return mi + +func _make_leg(mat: StandardMaterial3D) -> MeshInstance3D: + var shin := MeshInstance3D.new() + var cap := CapsuleMesh.new() + cap.radius = 0.075 + cap.height = 0.34 + shin.mesh = cap + shin.material_override = mat + shin.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF + var foot := MeshInstance3D.new() + var box := BoxMesh.new() + box.size = Vector3(0.11, 0.07, 0.22) + foot.mesh = box + foot.material_override = mat + foot.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF + foot.position = Vector3(0.0, -0.20, 0.09) + shin.add_child(foot) + return shin + +func _build_crosshair() -> void: + var layer := CanvasLayer.new() + layer.name = "CrosshairLayer" + add_child(layer) + var dot := ColorRect.new() + dot.color = Color(1, 1, 1, 0.85) + dot.anchor_left = 0.5 + dot.anchor_top = 0.5 + dot.anchor_right = 0.5 + dot.anchor_bottom = 0.5 + dot.offset_left = -2 + dot.offset_top = -2 + dot.offset_right = 2 + dot.offset_bottom = 2 + layer.add_child(dot) + +# ---------------------------------------------------------------- input +func _unhandled_input(event: InputEvent) -> void: + if event is InputEventMouseMotion and Input.mouse_mode == Input.MOUSE_MODE_CAPTURED: + rotate_y(-event.relative.x * mouse_sensitivity) # yaw on the body only + _pitch = clamp( + _pitch - event.relative.y * mouse_sensitivity, + deg_to_rad(-pitch_limit_deg), deg_to_rad(pitch_limit_deg)) + _head.rotation.x = _pitch # pitch on the head only + return + + if event is InputEventMouseButton and event.pressed: + if Input.mouse_mode != Input.MOUSE_MODE_CAPTURED: + Input.mouse_mode = Input.MOUSE_MODE_CAPTURED # click recaptures; this one isn't an attack + return + if event.button_index == MOUSE_BUTTON_LEFT: + _queue_attack(_punch) + elif event.button_index == MOUSE_BUTTON_RIGHT: + _queue_attack(_kick) + return + + if event is InputEventKey and event.pressed and not event.echo: + if event.keycode == KEY_ESCAPE: + Input.mouse_mode = Input.MOUSE_MODE_VISIBLE + +func _queue_attack(atk: MeleeAttack) -> void: + # buffer the click; the hit resolves in _physics_process where the space state is valid. + if _cd_left <= 0.0: + _queued = atk + +# ---------------------------------------------------------------- physics / movement +func _physics_process(delta: float) -> void: + _cd_left = max(0.0, _cd_left - delta) + + # resolve a buffered melee swing + if _queued != null and _cd_left <= 0.0: + var atk := _queued + _queued = null + _cd_left = atk.cooldown + var origin := camera.global_position + var dir := -camera.global_transform.basis.z # camera forward = crosshair + atk.strike(self, origin, dir, [get_rid()]) + _swing_viewmodel(atk == _kick) + + # gravity (only off the floor, or it accumulates and you rocket downward) + if not is_on_floor(): + velocity += get_gravity() * delta + + if Input.is_key_pressed(KEY_SPACE) and is_on_floor(): + velocity.y = jump_velocity + + var input_dir := Vector2( + float(Input.is_key_pressed(KEY_D)) - float(Input.is_key_pressed(KEY_A)), + float(Input.is_key_pressed(KEY_S)) - float(Input.is_key_pressed(KEY_W))) + var wish := transform.basis * Vector3(input_dir.x, 0.0, input_dir.y) + wish.y = 0.0 + if wish.length() > 1.0: + wish = wish.normalized() + + var target := wish * move_speed + var a := accel if is_on_floor() else air_accel + velocity.x = move_toward(velocity.x, target.x, a * move_speed * delta) + velocity.z = move_toward(velocity.z, target.z, a * move_speed * delta) + + move_and_slide() + +# ---------------------------------------------------------------- cosmetic swing +func _swing_viewmodel(is_kick: bool) -> void: + if _swing_tween and _swing_tween.is_valid(): + _swing_tween.kill() # interrupt any lingering swing so rapid clicks don't fight + if is_kick: + _leg.position = LEG_REST + var up := LEG_REST + Vector3(0.0, 0.66, -0.12) + _swing_tween = create_tween() + _swing_tween.set_trans(Tween.TRANS_CUBIC).set_ease(Tween.EASE_OUT) + _swing_tween.tween_property(_leg, "position", up, 0.10) + _swing_tween.set_trans(Tween.TRANS_QUAD).set_ease(Tween.EASE_IN) + _swing_tween.tween_property(_leg, "position", LEG_REST, 0.20) + else: + _r_hand.position = R_HAND_REST + var fwd := R_HAND_REST + Vector3(-0.06, 0.10, -0.34) + _swing_tween = create_tween() + _swing_tween.set_trans(Tween.TRANS_CUBIC).set_ease(Tween.EASE_OUT) + _swing_tween.tween_property(_r_hand, "position", fwd, 0.08) + _swing_tween.set_trans(Tween.TRANS_QUAD).set_ease(Tween.EASE_IN) + _swing_tween.tween_property(_r_hand, "position", R_HAND_REST, 0.16) diff --git a/scripts/Player.gd.uid b/scripts/Player.gd.uid new file mode 100644 index 0000000..1e36feb --- /dev/null +++ b/scripts/Player.gd.uid @@ -0,0 +1 @@ +uid://cadponumhk4bh diff --git a/scripts/Smashable.gd b/scripts/Smashable.gd new file mode 100644 index 0000000..8da0778 --- /dev/null +++ b/scripts/Smashable.gd @@ -0,0 +1,125 @@ +extends RigidBody3D +class_name Smashable + +## A piece of the world that can be smashed. +## +## The cascade lives here: frame pieces start FROZEN (they act static and hold the +## stuff above them up). When you smash a frame piece it vanishes into shards, so +## everything resting on it falls — no scripted "release" needed, gravity does it. +## Brittle kinds (vinyl, glass) also shatter on their own when they hit something +## hard enough — that's the satisfying TAIL of the cascade: the record survives the +## tumble out of its jacket, then cracks a beat later when it smacks the floor. + +@export_enum("wood", "cardboard", "vinyl", "glass", "steel") var kind: String = "wood" +@export var start_frozen: bool = false + +# per-material behaviour — this table is where "material identity" is authored. +# brittle_speed 0.0 == not brittle (won't self-shatter on impact). +const PROFILES := { + "wood": {"color": Color(0.42, 0.28, 0.15), "shards": 7, "brittle_speed": 0.0, "bounce": 0.05}, + "cardboard": {"color": Color(0.66, 0.5, 0.32), "shards": 4, "brittle_speed": 0.0, "bounce": 0.0}, + "vinyl": {"color": Color(0.04, 0.04, 0.05), "shards": 6, "brittle_speed": 2.2, "bounce": 0.12}, + "glass": {"color": Color(0.55, 0.8, 0.85), "shards": 12, "brittle_speed": 1.6, "bounce": 0.0}, + "steel": {"color": Color(0.7, 0.72, 0.76), "shards": 0, "brittle_speed": 0.0, "bounce": 0.25}, +} + +signal smashed(kind: String, at: Vector3, shard_count: int) + +var _shattered := false +var _speed := 0.0 # last physics-frame speed, sampled before any collision resolves + +## Pieces this one holds up. When THIS shatters, these get released (unfrozen) so +## gravity drops them. Wired by Main._build_rack (e.g. post.supports.append(shelf)). +## Losing ANY primary support drops the load — arcade feel, no partial-support math. +var supports: Array[Node] = [] +var _released := false # so a piece is only released once (idempotent, cycle-safe) + +func _ready() -> void: + add_to_group("smashable") + var p: Dictionary = PROFILES.get(kind, PROFILES["wood"]) + physics_material_override = PhysicsMaterial.new() + physics_material_override.bounce = p["bounce"] + if start_frozen: + freeze_mode = RigidBody3D.FREEZE_MODE_STATIC + freeze = true + if float(p["brittle_speed"]) > 0.0: + contact_monitor = true + max_contacts_reported = 4 + body_entered.connect(_on_body_entered) + +func _physics_process(_dt: float) -> void: + if not freeze: + _speed = linear_velocity.length() + +func _on_body_entered(_body: Node) -> void: + if _shattered: + return + var p: Dictionary = PROFILES.get(kind, PROFILES["wood"]) + if _speed >= float(p["brittle_speed"]): + shatter(Vector3.ZERO) + +## Called by the hammer. +func smash(impulse: Vector3) -> void: + shatter(impulse) + +func shatter(impulse: Vector3) -> void: + if _shattered: + return + _shattered = true + var p: Dictionary = PROFILES.get(kind, PROFILES["wood"]) + smashed.emit(kind, global_position, int(p["shards"])) + _release_supported() # drop what I was holding up BEFORE I leave the tree — the cascade + _spawn_shards(int(p["shards"]), p["color"], impulse) + queue_free() + +## Wake every piece this one was holding up. Guarded on both ends (each child's own +## _released flag) so a shared load or an accidental cycle can't loop. +func _release_supported() -> void: + for node in supports: + if is_instance_valid(node) and node is Smashable: + node.release() + +## Turn a frozen support into a live dynamic body and give it a comedic nudge so it +## visibly topples instead of sinking straight down. Idempotent: two legs releasing +## the same shelf only drop it once. +func release() -> void: + if _released: + return + _released = true + if _shattered: + return # already gone; nothing to drop + if freeze: + freeze = false # gravity + forces resume immediately + sleeping = false # defensive against a Jolt sleep frame + apply_central_impulse(Vector3(randf_range(-0.6, 0.6), 0.15, randf_range(-0.6, 0.6))) + +func _spawn_shards(n: int, color: Color, impulse: Vector3) -> void: + if n <= 0: + return + var parent := get_parent() + if parent == null: + return + var mat := StandardMaterial3D.new() + mat.albedo_color = color + var inherited := linear_velocity + for i in n: + var s := RigidBody3D.new() + s.add_to_group("debris") + s.mass = 0.08 + var sz := randf_range(0.03, 0.09) + var mesh := MeshInstance3D.new() + var bm := BoxMesh.new() + bm.size = Vector3(sz, sz, sz) + mesh.mesh = bm + mesh.material_override = mat + s.add_child(mesh) + var col := CollisionShape3D.new() + var shape := BoxShape3D.new() + shape.size = bm.size + col.shape = shape + s.add_child(col) + parent.add_child(s) + s.global_position = global_position + Vector3(randf_range(-0.08, 0.08), randf_range(-0.08, 0.08), randf_range(-0.08, 0.08)) + var kick := Vector3(randf_range(-1, 1), randf_range(0.3, 1.4), randf_range(-1, 1)).normalized() * randf_range(0.6, 2.2) + s.linear_velocity = inherited + kick + impulse * 0.3 + s.angular_velocity = Vector3(randf_range(-7, 7), randf_range(-7, 7), randf_range(-7, 7)) diff --git a/scripts/Smashable.gd.uid b/scripts/Smashable.gd.uid new file mode 100644 index 0000000..9ced52c --- /dev/null +++ b/scripts/Smashable.gd.uid @@ -0,0 +1 @@ +uid://cd8eqv7ok1v8q