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 <noreply@anthropic.com>
This commit is contained in:
commit
a8b735f79e
9
.gitignore
vendored
Normal file
9
.gitignore
vendored
Normal file
@ -0,0 +1,9 @@
|
||||
# Godot 4 editor cache & reimportable data
|
||||
.godot/
|
||||
|
||||
# Godot export output
|
||||
/build/
|
||||
/export/
|
||||
|
||||
# macOS
|
||||
.DS_Store
|
||||
49
README.md
Normal file
49
README.md
Normal file
@ -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/`.
|
||||
6
main.tscn
Normal file
6
main.tscn
Normal file
@ -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")
|
||||
25
project.godot
Normal file
25
project.godot
Normal file
@ -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
|
||||
196
scripts/Main.gd
Normal file
196
scripts/Main.gd
Normal file
@ -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]
|
||||
1
scripts/Main.gd.uid
Normal file
1
scripts/Main.gd.uid
Normal file
@ -0,0 +1 @@
|
||||
uid://cu0jxa8bwjjon
|
||||
69
scripts/MeleeAttack.gd
Normal file
69
scripts/MeleeAttack.gd
Normal file
@ -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
|
||||
1
scripts/MeleeAttack.gd.uid
Normal file
1
scripts/MeleeAttack.gd.uid
Normal file
@ -0,0 +1 @@
|
||||
uid://d4anp68080714
|
||||
246
scripts/Player.gd
Normal file
246
scripts/Player.gd
Normal file
@ -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)
|
||||
1
scripts/Player.gd.uid
Normal file
1
scripts/Player.gd.uid
Normal file
@ -0,0 +1 @@
|
||||
uid://cadponumhk4bh
|
||||
125
scripts/Smashable.gd
Normal file
125
scripts/Smashable.gd
Normal file
@ -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))
|
||||
1
scripts/Smashable.gd.uid
Normal file
1
scripts/Smashable.gd.uid
Normal file
@ -0,0 +1 @@
|
||||
uid://cd8eqv7ok1v8q
|
||||
Loading…
Reference in New Issue
Block a user