Add blood decals, gore, mobile HUD, web start gate + touch/perf tests
Remove tools/fstest.html scratch page used to probe browser fullscreen/orientation APIs. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EznnY8rH2dXhtono1kwsXg
This commit is contained in:
@@ -33,7 +33,11 @@ I am a **Godot 4.6+ expert**. I follow current best practices for GDScript, scen
|
|||||||
| `levels/MapScreen.tscn` / `levels/map_screen.gd` | Between-fights map screen; win → pick a node → next level |
|
| `levels/MapScreen.tscn` / `levels/map_screen.gd` | Between-fights map screen; win → pick a node → next level |
|
||||||
| `crowd_marker.gd` | `CrowdMarker` (`@tool Node3D`, `class_name`) — designer drops one per billboard spectator via the editor's Add-Node dialog; picks one of six 60°-spaced facings from the `orientation` dropdown. Draws an editor-only preview (slab + forward arrow, never serialised) and joins the `&"crowd_marker"` group |
|
| `crowd_marker.gd` | `CrowdMarker` (`@tool Node3D`, `class_name`) — designer drops one per billboard spectator via the editor's Add-Node dialog; picks one of six 60°-spaced facings from the `orientation` dropdown. Draws an editor-only preview (slab + forward arrow, never serialised) and joins the `&"crowd_marker"` group |
|
||||||
| `crowd_billboards.gd` / `crowd_billboard.gdshader` | `MultiMeshInstance3D` that harvests every `CrowdMarker` (group `&"crowd_marker"`) into one draw call, using each marker's world position + chosen facing. Front/back sprite sheet baked by `tools/bake_crowd_sheet.gd`; falls back to a generated inward-facing ring when no markers are placed |
|
| `crowd_billboards.gd` / `crowd_billboard.gdshader` | `MultiMeshInstance3D` that harvests every `CrowdMarker` (group `&"crowd_marker"`) into one draw call, using each marker's world position + chosen facing. Front/back sprite sheet baked by `tools/bake_crowd_sheet.gd`; falls back to a generated inward-facing ring when no markers are placed |
|
||||||
| `debug_params.gd` | Runtime-tunable parameter registry (autoload `DP`) |
|
| `debug_params.gd` | Runtime-tunable parameter registry (autoload `DP`). Mobile-relevant flags: `force_touch_controls`, `touch_debug` (also enabled on-device with `?debug=1` in the page URL — shows the live profiler overlay in `touch_controls.gd`), and `use_rigid_skin` (default **off** — native GPU skinning everywhere now that the 4.7 web templates fixed the Mali/ANGLE invisible-skinning bug; `rigid_skin.gd` is a retained opt-in fallback). Rigid-skin is gated through `Controls.rigid_skin_enabled()`, which a page URL param overrides for on-device A/B with no console: `?skin=1` forces the workaround on (for a device where native skinning still fails), `?noskin=1` forces native off. Turning the workaround off also removes its per-spawn SurfaceTool rebuild, which was the arena's matador-spawn lag |
|
||||||
|
| `touch_controls.gd` | On-screen thumb UI for touch/web (`CanvasLayer`, instanced by `hud.gd`, self-gating on `Controls.use_touch_ui()`): a floating left-side joystick feeding the `move_*` actions + a right-side ability cluster, drawn/hit-tested in one IGNORE-filtered Control from raw `_input()`. The joystick self-heals — the latest left-zone touch re-acquires it (`_stick_index`), and a relayout / focus-out / vanished touch resets state so a dropped touchend can't strand a stuck stick. Ability buttons use grown hit-rects + a press flash. `_draw_debug()` (gated on `Controls.debug_overlay()` — DP `touch_debug` or `?debug=1`) is a live on-device profiler reading Godot's `Performance` monitors: FPS/frame-ms with a bottleneck verdict, `process`(main-thread GDScript) vs `physics`(ragdoll/Jolt) ms with 2 s peak-hold, draw calls/objects/primitives, node count, render resolution, and `crossOriginIsolated` — so a lag spike is pinned to CPU-script vs CPU-physics vs GPU-fill instead of guessed |
|
||||||
|
| `web_start_gate.gd` | Autoload `WebStartGate` — "TAP TO PLAY" panel shown on web+touch before play; the tap drives `Controls.request_fullscreen_landscape()` so the fullscreen/orientation churn happens with no in-flight touch to lose. Re-armable: polls browser fullscreen state and returns if the player leaves it (iPhone Safari can't fullscreen, so it stops nagging once a request never takes) |
|
||||||
|
| `mobile_hud.gd` | Mobile-only health readouts (`CanvasLayer`, instanced by `hud.gd` only on touch): big bull pip row top-left + a Souls-style bear boss bar top-centre (discovered via group `&"bear"`, tracks `health_changed`). The desktop bottom pip cluster is hidden on touch |
|
||||||
|
| `orientation_guard.gd` | Autoload `OrientationGuard` — full-screen "rotate your device" prompt shown on touch devices while portrait |
|
||||||
| `Assets/` | Raw 3D assets (`.glb`, `.fbx`) |
|
| `Assets/` | Raw 3D assets (`.glb`, `.fbx`) |
|
||||||
| `Blender/` | Blender source files, animation scripts, FBX exports |
|
| `Blender/` | Blender source files, animation scripts, FBX exports |
|
||||||
| `Blender/create_matador_anims.py` | Creates walk + idle animations and exports FBX |
|
| `Blender/create_matador_anims.py` | Creates walk + idle animations and exports FBX |
|
||||||
@@ -105,8 +109,11 @@ bash run_tests.sh
|
|||||||
gdlint *.gd levels/*.gd tests/*.gd # GDScript lint (gdtoolkit, installed via uv)
|
gdlint *.gd levels/*.gd tests/*.gd # GDScript lint (gdtoolkit, installed via uv)
|
||||||
godot --headless --script tests/logic_test.gd # pure logic, ~2 s
|
godot --headless --script tests/logic_test.gd # pure logic, ~2 s
|
||||||
godot --headless --script tests/performance_test.gd # frame-budget regression guard, ~4 s
|
godot --headless --script tests/performance_test.gd # frame-budget regression guard, ~4 s
|
||||||
|
godot --headless --script tests/ragdoll_perf_test.gd # on-hit spike breakdown: ragdoll build / sim-start / blood / mass hit, ~90 s
|
||||||
godot --headless --script tests/gameplay_test.gd # full scene, bone sanity, ~4 s
|
godot --headless --script tests/gameplay_test.gd # full scene, bone sanity, ~4 s
|
||||||
godot --headless --script tests/level_switch_test.gd # no arena geometry leaks into the bear level, ~2 s
|
godot --headless --script tests/level_switch_test.gd # no arena geometry leaks into the bear level, ~2 s
|
||||||
|
godot --headless --script tests/touch_controls_test.gd # joystick self-heals: relayout/focus-out/vanished-touch never strand a stuck stick, ~2 s
|
||||||
|
godot --headless --script tests/rigid_skin_test.gd # web mesh conversion keeps every material (no bald / recoloured matadors), ~2 s
|
||||||
godot --headless --script tests/bear_boss_test.gd # bear boss moveset: routines, leap landing, hyper-armour, ~10 s
|
godot --headless --script tests/bear_boss_test.gd # bear boss moveset: routines, leap landing, hyper-armour, ~10 s
|
||||||
godot --headless --script tests/bear_fx_test.gd # bear attack-FX: each Bear_FX clip pops its mesh + self-hides, ~2 s
|
godot --headless --script tests/bear_fx_test.gd # bear attack-FX: each Bear_FX clip pops its mesh + self-hides, ~2 s
|
||||||
godot --headless --script tests/bear_hitbox_test.gd # bear attack colliders: leap launches everywhere (ring_frac slider) + smash lane travel, ~3 s
|
godot --headless --script tests/bear_hitbox_test.gd # bear attack colliders: leap launches everywhere (ring_frac slider) + smash lane travel, ~3 s
|
||||||
|
|||||||
@@ -116,9 +116,10 @@ func _ready() -> void:
|
|||||||
# The smash/leap clips end held at full scale (Siim only ramped the claw clips back
|
# The smash/leap clips end held at full scale (Siim only ramped the claw clips back
|
||||||
# down), so snap each effect back to 0 when its clip finishes — keeps them one-shot.
|
# down), so snap each effect back to 0 when its clip finishes — keeps them one-shot.
|
||||||
_fx_player.animation_finished.connect(_on_fx_finished)
|
_fx_player.animation_finished.connect(_on_fx_finished)
|
||||||
# Web (Mali/ANGLE) can't run Compatibility vertex-skinning; rebuild the bear as
|
# Native GPU skinning by default (the Mali/ANGLE invisible-skinning bug is fixed in the 4.7
|
||||||
# non-skinned bone-attached pieces there. Desktop keeps smooth GPU skinning.
|
# web templates). The bear is a single dense organic mesh, so the rigid-skin fallback tears
|
||||||
if OS.has_feature("web"):
|
# seam holes as it animates — another reason native is the default. See rigid_skin_enabled().
|
||||||
|
if Controls.rigid_skin_enabled():
|
||||||
RigidSkin.convert_tree(self)
|
RigidSkin.convert_tree(self)
|
||||||
if _anim_player:
|
if _anim_player:
|
||||||
_anim_idle = _resolve_anim(["IDLE_ON4LEGS", "IDLE"])
|
_anim_idle = _resolve_anim(["IDLE_ON4LEGS", "IDLE"])
|
||||||
@@ -883,6 +884,7 @@ func _take_hit(hit_dir: Vector3, strength: float) -> void:
|
|||||||
hit_dir.y = 0.0
|
hit_dir.y = 0.0
|
||||||
hit_dir = hit_dir.normalized()
|
hit_dir = hit_dir.normalized()
|
||||||
_blood_burst.burst(global_position + Vector3(0.0, 1.4, 0.0), hit_dir)
|
_blood_burst.burst(global_position + Vector3(0.0, 1.4, 0.0), hit_dir)
|
||||||
|
Gore.splat(global_position, hit_dir, 1.1)
|
||||||
_shake(clampf(strength / 15.0, 0.4, 1.0))
|
_shake(clampf(strength / 15.0, 0.4, 1.0))
|
||||||
if _hp <= 0:
|
if _hp <= 0:
|
||||||
_die(hit_dir)
|
_die(hit_dir)
|
||||||
@@ -918,6 +920,7 @@ func _die(hit_dir: Vector3) -> void:
|
|||||||
_hit_area.monitoring = false
|
_hit_area.monitoring = false
|
||||||
velocity = Vector3.ZERO
|
velocity = Vector3.ZERO
|
||||||
_blood_burst.burst(global_position + Vector3(0.0, 1.4, 0.0), hit_dir)
|
_blood_burst.burst(global_position + Vector3(0.0, 1.4, 0.0), hit_dir)
|
||||||
|
Gore.splat(global_position, hit_dir, 1.4)
|
||||||
_play(_anim_death)
|
_play(_anim_death)
|
||||||
# No death clip on the rig — topple the beast onto its side (about the hit direction,
|
# No death clip on the rig — topple the beast onto its side (about the hit direction,
|
||||||
# so it falls the way it was struck) and sink it away, then free.
|
# so it falls the way it was struck) and sink it away, then free.
|
||||||
|
|||||||
+194
@@ -0,0 +1,194 @@
|
|||||||
|
extends MultiMeshInstance3D
|
||||||
|
## A whole level's worth of persistent blood splats rendered in one draw call. Each stain
|
||||||
|
## is a flat quad laid on the surface it hit — floor or a nearby wall — picked from a
|
||||||
|
## procedurally-baked atlas of splat shapes and jittered per instance (random shape, yaw,
|
||||||
|
## size, brightness) so no two read alike.
|
||||||
|
##
|
||||||
|
## The pool is a fixed ring buffer: new splats overwrite the oldest, so the instance count
|
||||||
|
## and fill cost stay bounded no matter how long a fight runs — the property that keeps it
|
||||||
|
## cheap on the web/mobile target. The game shell rebuilds this node into LevelRoot on every
|
||||||
|
## level load, so the stains die with the level (exactly one level's worth, never leaked
|
||||||
|
## into the next). Fire splats through the `Gore` autoload, not this node directly.
|
||||||
|
|
||||||
|
const PhysicsLayers = preload("res://physics_layers.gd")
|
||||||
|
const SHADER_PATH := "res://blood_decals.gdshader"
|
||||||
|
const GROUP := &"blood_decals"
|
||||||
|
|
||||||
|
# Atlas: ATLAS_CELLS distinct splat shapes packed into one row, CELL_PX square each.
|
||||||
|
const ATLAS_CELLS := 8
|
||||||
|
const CELL_PX := 64
|
||||||
|
|
||||||
|
var _rng := RandomNumberGenerator.new()
|
||||||
|
var _mm: MultiMesh
|
||||||
|
var _cap: int = 0
|
||||||
|
var _next: int = 0 # ring-buffer write cursor
|
||||||
|
var _filled: int = 0 # how many ring-buffer slots have ever been stamped, capped at _cap
|
||||||
|
|
||||||
|
|
||||||
|
func _ready() -> void:
|
||||||
|
add_to_group(GROUP)
|
||||||
|
_rng.randomize()
|
||||||
|
_cap = maxi(int(DP.f("blood_cap")), 0)
|
||||||
|
_build()
|
||||||
|
|
||||||
|
|
||||||
|
func _build() -> void:
|
||||||
|
var quad := QuadMesh.new()
|
||||||
|
quad.size = Vector2.ONE # unit quad; the instance basis carries the real size
|
||||||
|
|
||||||
|
var mat := ShaderMaterial.new()
|
||||||
|
mat.shader = load(SHADER_PATH) as Shader
|
||||||
|
mat.set_shader_parameter("atlas", _bake_atlas())
|
||||||
|
mat.set_shader_parameter("cells", ATLAS_CELLS)
|
||||||
|
material_override = mat
|
||||||
|
|
||||||
|
_mm = MultiMesh.new()
|
||||||
|
_mm.transform_format = MultiMesh.TRANSFORM_3D
|
||||||
|
_mm.use_custom_data = true
|
||||||
|
_mm.mesh = quad
|
||||||
|
_mm.instance_count = _cap
|
||||||
|
# Godot renders only the first `visible_instance_count` instances, so unstained slots simply
|
||||||
|
# aren't drawn — no need to park them off-map. (Parking them via set_instance_transform doesn't
|
||||||
|
# work anyway: MultiMesh's per-instance buffer lives server-side and isn't reliably readable
|
||||||
|
# back through get_instance_transform, so a "hide by moving far away" scheme can't even be
|
||||||
|
# verified, let alone trusted.) Starts at 0 and grows as splats land.
|
||||||
|
_mm.visible_instance_count = 0
|
||||||
|
multimesh = _mm
|
||||||
|
|
||||||
|
|
||||||
|
## Stain the world at `world_pos`: one splat cluster on the floor beneath it, plus a splat
|
||||||
|
## on any wall within `blood_wall_reach`. `dir` is the spray heading (used to seed the wall
|
||||||
|
## fan); `size` is the base quad size in metres.
|
||||||
|
func splat(world_pos: Vector3, dir: Vector3 = Vector3.ZERO, size: float = 0.6) -> void:
|
||||||
|
if not is_instance_valid(_mm) or _cap <= 0:
|
||||||
|
return
|
||||||
|
var space := get_world_3d().direct_space_state
|
||||||
|
if space == null:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Floor directly under the hit.
|
||||||
|
var floor_hit := _ray(space, world_pos + Vector3.UP * 0.5, world_pos + Vector3.DOWN * 4.0)
|
||||||
|
if not floor_hit.is_empty():
|
||||||
|
_stamp_cluster(floor_hit.position, floor_hit.normal, size)
|
||||||
|
|
||||||
|
# Walls near the hit: a fan of outward rays, seeded on the spray heading. Only surfaces
|
||||||
|
# within reach catch blood, so an open-arena hit stains nothing but the floor.
|
||||||
|
var rays := maxi(int(DP.f("blood_wall_rays")), 0)
|
||||||
|
if rays <= 0:
|
||||||
|
return
|
||||||
|
var reach := DP.f("blood_wall_reach")
|
||||||
|
var seed_dir := Vector3(dir.x, 0.0, dir.z)
|
||||||
|
seed_dir = seed_dir.normalized() if seed_dir.length_squared() > 0.01 else Vector3.FORWARD
|
||||||
|
var origin := world_pos + Vector3.UP * 0.6
|
||||||
|
for k in rays:
|
||||||
|
var ang := TAU * (float(k) + 0.5) / float(rays)
|
||||||
|
var out := seed_dir.rotated(Vector3.UP, ang)
|
||||||
|
var wall_hit := _ray(space, origin, origin + out * reach)
|
||||||
|
# Only stain near-vertical surfaces here; the floor is already handled above.
|
||||||
|
if not wall_hit.is_empty() and absf((wall_hit.normal as Vector3).y) < 0.6:
|
||||||
|
_stamp(wall_hit.position, wall_hit.normal, size * 0.85)
|
||||||
|
|
||||||
|
|
||||||
|
func _ray(space: PhysicsDirectSpaceState3D, from: Vector3, to: Vector3) -> Dictionary:
|
||||||
|
var q := PhysicsRayQueryParameters3D.create(
|
||||||
|
from, to, PhysicsLayers.WORLD | PhysicsLayers.CORPSE
|
||||||
|
)
|
||||||
|
q.collide_with_bodies = true
|
||||||
|
return space.intersect_ray(q)
|
||||||
|
|
||||||
|
|
||||||
|
# A main splat plus a scatter of smaller droplets around it, all lying on the same surface.
|
||||||
|
func _stamp_cluster(pos: Vector3, normal: Vector3, size: float) -> void:
|
||||||
|
_stamp(pos, normal, size)
|
||||||
|
var n := normal.normalized()
|
||||||
|
if n.length_squared() < 0.5:
|
||||||
|
n = Vector3.UP
|
||||||
|
var up := Vector3.UP if absf(n.dot(Vector3.UP)) < 0.99 else Vector3.FORWARD
|
||||||
|
var tx := up.cross(n).normalized()
|
||||||
|
var ty := n.cross(tx).normalized()
|
||||||
|
var count := maxi(int(DP.f("blood_satellites")), 0)
|
||||||
|
for s in count:
|
||||||
|
var rad := size * _rng.randf_range(0.4, 1.2)
|
||||||
|
var a := _rng.randf() * TAU
|
||||||
|
var off := tx * (cos(a) * rad) + ty * (sin(a) * rad)
|
||||||
|
_stamp(pos + off, n, size * _rng.randf_range(0.25, 0.5))
|
||||||
|
|
||||||
|
|
||||||
|
# Write one splat into the ring buffer, oriented flat on the surface and lifted a hair along
|
||||||
|
# its normal (layered by write index) so coplanar quads don't z-fight the surface or each other.
|
||||||
|
func _stamp(pos: Vector3, normal: Vector3, size: float) -> void:
|
||||||
|
var n := normal.normalized()
|
||||||
|
if n.length_squared() < 0.5:
|
||||||
|
n = Vector3.UP
|
||||||
|
var i := _next
|
||||||
|
_next = (_next + 1) % _cap
|
||||||
|
var eps := 0.015 + float(i) * 0.00015
|
||||||
|
var yaw := _rng.randf() * TAU
|
||||||
|
_mm.set_instance_transform(i, Transform3D(_surface_basis(n, yaw, size), pos + n * eps))
|
||||||
|
var cell := float(_rng.randi_range(0, ATLAS_CELLS - 1))
|
||||||
|
var shade := _rng.randf_range(0.6, 1.0)
|
||||||
|
_mm.set_instance_custom_data(i, Color(cell, shade, 0.0, 0.0))
|
||||||
|
_filled = mini(_filled + 1, _cap)
|
||||||
|
_mm.visible_instance_count = _filled
|
||||||
|
|
||||||
|
|
||||||
|
# Basis for a QuadMesh (face along local +Z) lying flat on a surface with the given normal,
|
||||||
|
# spun by `yaw` about that normal and uniformly scaled to `size`.
|
||||||
|
func _surface_basis(n: Vector3, yaw: float, size: float) -> Basis:
|
||||||
|
var up := Vector3.UP if absf(n.dot(Vector3.UP)) < 0.99 else Vector3.FORWARD
|
||||||
|
var x := up.cross(n).normalized()
|
||||||
|
var y := n.cross(x).normalized()
|
||||||
|
var c := cos(yaw)
|
||||||
|
var s := sin(yaw)
|
||||||
|
var b := Basis()
|
||||||
|
b.x = (x * c + y * s) * size
|
||||||
|
b.y = (y * c - x * s) * size
|
||||||
|
b.z = n
|
||||||
|
return b
|
||||||
|
|
||||||
|
|
||||||
|
# ── Atlas baking ──────────────────────────────────────────────────────────────
|
||||||
|
# Draw ATLAS_CELLS irregular blood shapes into one row. Each shape is a metaball field —
|
||||||
|
# a big central blob, a few overlapping lobes and some flung droplets — thresholded to an
|
||||||
|
# organic silhouette. Baked once per pool (once per level load, ~ms) with a fixed seed so
|
||||||
|
# the shape set is deterministic and every level's stains match.
|
||||||
|
|
||||||
|
func _bake_atlas() -> ImageTexture:
|
||||||
|
var img := Image.create(ATLAS_CELLS * CELL_PX, CELL_PX, false, Image.FORMAT_RGBA8)
|
||||||
|
img.fill(Color(0.0, 0.0, 0.0, 0.0))
|
||||||
|
var rng := RandomNumberGenerator.new()
|
||||||
|
rng.seed = hash("bullosseum-blood")
|
||||||
|
for c in ATLAS_CELLS:
|
||||||
|
_draw_splat(img, c * CELL_PX, rng)
|
||||||
|
return ImageTexture.create_from_image(img)
|
||||||
|
|
||||||
|
|
||||||
|
func _draw_splat(img: Image, ox: int, rng: RandomNumberGenerator) -> void:
|
||||||
|
var px := float(CELL_PX)
|
||||||
|
var mid := px * 0.5
|
||||||
|
# blobs are (centre_x, centre_y, radius) — a central mass, lobes, then far droplets.
|
||||||
|
var blobs: Array[Vector3] = []
|
||||||
|
blobs.append(Vector3(mid, mid, px * rng.randf_range(0.22, 0.30)))
|
||||||
|
for i in rng.randi_range(3, 6):
|
||||||
|
var a := rng.randf() * TAU
|
||||||
|
var d := px * rng.randf_range(0.10, 0.34)
|
||||||
|
blobs.append(Vector3(mid + cos(a) * d, mid + sin(a) * d, px * rng.randf_range(0.06, 0.16)))
|
||||||
|
for i in rng.randi_range(2, 5):
|
||||||
|
var a := rng.randf() * TAU
|
||||||
|
var d := px * rng.randf_range(0.30, 0.46)
|
||||||
|
blobs.append(Vector3(mid + cos(a) * d, mid + sin(a) * d, px * rng.randf_range(0.02, 0.05)))
|
||||||
|
|
||||||
|
for y in CELL_PX:
|
||||||
|
for x in CELL_PX:
|
||||||
|
var field := 0.0
|
||||||
|
for b in blobs:
|
||||||
|
var dx := float(x) - b.x
|
||||||
|
var dy := float(y) - b.y
|
||||||
|
field += (b.z * b.z) / (dx * dx + dy * dy + 1.0)
|
||||||
|
var a := smoothstep(0.75, 1.15, field)
|
||||||
|
if a <= 0.004:
|
||||||
|
continue
|
||||||
|
# Denser field (splat interior) reads a touch richer/darker than the thin edges.
|
||||||
|
var t := clampf(field * 0.5, 0.0, 1.0)
|
||||||
|
var col := Color(0.62 - 0.16 * t, 0.05 - 0.03 * t, 0.04 - 0.02 * t, a)
|
||||||
|
img.set_pixel(ox + x, y, col)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://cs8fhiasw7q7j
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
shader_type spatial;
|
||||||
|
// Persistent blood splats, drawn as one MultiMesh of flat quads (see blood_decals.gd).
|
||||||
|
// Unshaded and nearest-filtered to sit inside the PS1 look; alpha-blended so overlapping
|
||||||
|
// splats pool darker. depth_draw_never + the small normal offset the pool bakes into each
|
||||||
|
// transform keep the coplanar floor/wall quads from z-fighting the surface they stain.
|
||||||
|
render_mode unshaded, cull_disabled, shadows_disabled, depth_draw_never, blend_mix;
|
||||||
|
|
||||||
|
uniform sampler2D atlas : source_color, filter_nearest;
|
||||||
|
// Number of splat shapes packed side-by-side in the atlas (one row).
|
||||||
|
uniform int cells = 8;
|
||||||
|
|
||||||
|
varying flat float v_cell;
|
||||||
|
varying flat float v_shade;
|
||||||
|
|
||||||
|
void vertex() {
|
||||||
|
// x = which atlas shape this instance uses; y = per-instance brightness (fresh vs old).
|
||||||
|
v_cell = INSTANCE_CUSTOM.x;
|
||||||
|
v_shade = INSTANCE_CUSTOM.y;
|
||||||
|
}
|
||||||
|
|
||||||
|
void fragment() {
|
||||||
|
float u = (UV.x + v_cell) / float(cells);
|
||||||
|
vec4 c = texture(atlas, vec2(u, UV.y));
|
||||||
|
ALBEDO = c.rgb * v_shade;
|
||||||
|
ALPHA = c.a;
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://cj5rmaywacvbj
|
||||||
+126
-21
@@ -23,36 +23,115 @@ const REBINDABLE_ACTIONS: PackedStringArray = [
|
|||||||
|
|
||||||
func _ready() -> void:
|
func _ready() -> void:
|
||||||
load_saved()
|
load_saved()
|
||||||
|
_install_web_fullscreen_hook()
|
||||||
|
|
||||||
|
|
||||||
# ── Web fullscreen + landscape lock ─────────────────────────────────────────────
|
# ── Web fullscreen + landscape lock ─────────────────────────────────────────────
|
||||||
# Browsers only allow requestFullscreen / screen.orientation.lock from inside a user
|
# The reliable way to force fullscreen on the web: install a NATIVE DOM listener that calls
|
||||||
# gesture, so we piggyback on the first tap/click of the web build to go fullscreen and
|
# requestFullscreen() synchronously inside the real user-gesture event — the same thing
|
||||||
# lock to landscape. One-shot; input still flows to the menu/game normally (never consumed).
|
# three.js games do. Godot dispatches input from its rAF render loop, one hop removed from the
|
||||||
var _fs_triggered: bool = false
|
# DOM event, so both Godot's window_set_mode and a JS call made from _input() run outside the
|
||||||
|
# gesture's activation and some mobile browsers reject them. The listener below sidesteps that
|
||||||
|
# entirely, and — left attached — re-enters fullscreen on the next tap if the player leaves it.
|
||||||
func _input(event: InputEvent) -> void:
|
# All of it is guarded, so iPhone Safari (no requestFullscreen) and desktop just no-op cleanly.
|
||||||
if _fs_triggered or not OS.has_feature("web"):
|
func _install_web_fullscreen_hook() -> void:
|
||||||
|
if not OS.has_feature("web"):
|
||||||
return
|
return
|
||||||
var gesture := (event is InputEventScreenTouch and (event as InputEventScreenTouch).pressed) \
|
JavaScriptBridge.eval("""
|
||||||
or (event is InputEventMouseButton and (event as InputEventMouseButton).pressed)
|
(function(){
|
||||||
if gesture:
|
if (window.__bullFSInit) return;
|
||||||
_fs_triggered = true
|
window.__bullFSInit = true;
|
||||||
request_fullscreen_landscape()
|
window.__bull_fs = 'idle';
|
||||||
|
// Armed = we should grab fullscreen on the next tap. Starts true so the first tap enters,
|
||||||
|
// then disarms — so once the player is in (or deliberately leaves via Esc / back / swipe)
|
||||||
|
// ordinary gameplay taps never yank them back in. WebStartGate re-arms via arm_fullscreen().
|
||||||
|
window.__bullFSArmed = true;
|
||||||
|
window.__bullFS = function(){
|
||||||
|
try {
|
||||||
|
if (document.fullscreenElement || document.webkitFullscreenElement) {
|
||||||
|
window.__bull_fs = 'ok'; window.__bullFSArmed = false; return;
|
||||||
|
}
|
||||||
|
if (!window.__bullFSArmed) return;
|
||||||
|
var c = document.getElementById('canvas') || document.querySelector('canvas')
|
||||||
|
|| document.documentElement;
|
||||||
|
var rf = c.requestFullscreen || c.webkitRequestFullscreen
|
||||||
|
|| c.msRequestFullscreen || c.mozRequestFullScreen;
|
||||||
|
if (!rf) { window.__bull_fs = 'no-api'; lock(); return; }
|
||||||
|
window.__bull_fs = 'req';
|
||||||
|
var p = rf.call(c);
|
||||||
|
if (p && p.then) {
|
||||||
|
p.then(function(){ window.__bull_fs = 'ok'; window.__bullFSArmed = false; lock(); },
|
||||||
|
function(e){ window.__bull_fs = 'err:' + (e && e.name || e); });
|
||||||
|
} else { window.__bull_fs = 'ok'; window.__bullFSArmed = false; lock(); }
|
||||||
|
} catch(e){ window.__bull_fs = 'ex:' + (e && e.name || e); }
|
||||||
|
function lock(){ try { if (screen.orientation && screen.orientation.lock)
|
||||||
|
screen.orientation.lock('landscape').catch(function(){}); } catch(e){} }
|
||||||
|
};
|
||||||
|
// pointerup / touchend / click grant transient activation (pointerdown/touchstart do not).
|
||||||
|
['pointerup','touchend','click'].forEach(function(ev){
|
||||||
|
window.addEventListener(ev, function(){ window.__bullFS(); }, true);
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
""", true)
|
||||||
|
|
||||||
|
|
||||||
## Enter browser fullscreen and lock to landscape. Must be called from a user-gesture
|
## Ask to enter fullscreen + landscape now (e.g. from WebStartGate's tap). The DOM listener
|
||||||
## context (first tap, or a button press). No-op off web. Android Chrome/Brave honour the
|
## installed above normally beats this to it on the same gesture; this is the explicit path and
|
||||||
## orientation lock; iOS Safari ignores it (unsupported), so the .catch() swallows the reject.
|
## is a safe no-op if we're already fullscreen. No-op off web.
|
||||||
func request_fullscreen_landscape() -> void:
|
func request_fullscreen_landscape() -> void:
|
||||||
if not OS.has_feature("web"):
|
if not OS.has_feature("web"):
|
||||||
return
|
return
|
||||||
DisplayServer.window_set_mode(DisplayServer.WINDOW_MODE_FULLSCREEN)
|
_install_web_fullscreen_hook()
|
||||||
JavaScriptBridge.eval(
|
JavaScriptBridge.eval("if (window.__bullFS) window.__bullFS();", true)
|
||||||
"(function(){function l(){try{if(screen.orientation&&screen.orientation.lock)"
|
|
||||||
+ "screen.orientation.lock('landscape').catch(function(){});}catch(e){}}"
|
|
||||||
+ "l();document.addEventListener('fullscreenchange',l,{once:true});})()", true)
|
## Re-arm the "grab fullscreen on next tap" hook, so the next tap re-enters. WebStartGate calls
|
||||||
|
## this whenever it shows itself (boot, or after the player left fullscreen). No-op off web.
|
||||||
|
func arm_fullscreen() -> void:
|
||||||
|
if OS.has_feature("web"):
|
||||||
|
JavaScriptBridge.eval("window.__bullFSArmed = true;", true)
|
||||||
|
|
||||||
|
|
||||||
|
## Whether the browser can go fullscreen at all. False on iPhone Safari (no requestFullscreen),
|
||||||
|
## which lets WebStartGate stop prompting there instead of nagging forever. True off web (n/a).
|
||||||
|
func fullscreen_supported() -> bool:
|
||||||
|
if not OS.has_feature("web"):
|
||||||
|
return true
|
||||||
|
var r: Variant = JavaScriptBridge.eval(
|
||||||
|
"(function(){var c=document.getElementById('canvas')||document.documentElement;"
|
||||||
|
+ "return !!(c.requestFullscreen||c.webkitRequestFullscreen"
|
||||||
|
+ "||c.msRequestFullscreen||c.mozRequestFullScreen)?1:0;})()", true)
|
||||||
|
if r is bool:
|
||||||
|
return r
|
||||||
|
if r is int or r is float:
|
||||||
|
return int(r) != 0
|
||||||
|
return false
|
||||||
|
|
||||||
|
|
||||||
|
## Is the browser currently fullscreen? No-op-ish off web (returns false).
|
||||||
|
func is_browser_fullscreen() -> bool:
|
||||||
|
if not OS.has_feature("web"):
|
||||||
|
return false
|
||||||
|
var r: Variant = JavaScriptBridge.eval(
|
||||||
|
"(document.fullscreenElement||document.webkitFullscreenElement)?1:0", true)
|
||||||
|
if r is bool:
|
||||||
|
return r
|
||||||
|
if r is int or r is float:
|
||||||
|
return int(r) != 0
|
||||||
|
return false
|
||||||
|
|
||||||
|
|
||||||
|
## One-line web diagnostic for the on-device debug overlay: secure context, cross-origin
|
||||||
|
## isolation (threads), last fullscreen attempt result, and whether we're fullscreen now.
|
||||||
|
func fullscreen_status() -> String:
|
||||||
|
if not OS.has_feature("web"):
|
||||||
|
return "native"
|
||||||
|
var r: Variant = JavaScriptBridge.eval(
|
||||||
|
"'sec=' + (window.isSecureContext ? 1 : 0) + ' iso=' + (window.crossOriginIsolated ? 1 : 0)"
|
||||||
|
+ " + ' fs=' + (window.__bull_fs || '?')"
|
||||||
|
+ " + ' cur=' + ((document.fullscreenElement || document.webkitFullscreenElement) ? 1 : 0)",
|
||||||
|
true)
|
||||||
|
return str(r) if r != null else "?"
|
||||||
|
|
||||||
|
|
||||||
## Whether to drive the game with the on-screen touch UI (joystick + buttons)
|
## Whether to drive the game with the on-screen touch UI (joystick + buttons)
|
||||||
@@ -117,6 +196,32 @@ func debug_overlay() -> bool:
|
|||||||
return _url_debug == 1
|
return _url_debug == 1
|
||||||
|
|
||||||
|
|
||||||
|
## Whether to apply the rigid-skin workaround (rigid_skin.gd) instead of native GPU skinning.
|
||||||
|
## Defaults to the DP "use_rigid_skin" flag (on for web), but a page URL param overrides it so
|
||||||
|
## native skinning can be A/B'd on the actual phone with no console reachable: append `?noskin=1`
|
||||||
|
## to force it OFF (does the device render skinned meshes natively now — e.g. after a Godot web
|
||||||
|
## template update? — and do the bear's rigid-skin seam holes go away), or `?skin=1` to force ON.
|
||||||
|
## -1 = not read yet, 0 = url forces off, 1 = url forces on, 2 = no url override (use DP flag).
|
||||||
|
var _url_skin: int = -1
|
||||||
|
|
||||||
|
|
||||||
|
func rigid_skin_enabled() -> bool:
|
||||||
|
if _url_skin == -1:
|
||||||
|
_url_skin = 2
|
||||||
|
if OS.has_feature("web"):
|
||||||
|
var q: Variant = JavaScriptBridge.eval("String(window.location.search)", true)
|
||||||
|
var s: String = str(q) if q != null else ""
|
||||||
|
if s.find("noskin") >= 0:
|
||||||
|
_url_skin = 0
|
||||||
|
elif s.find("skin") >= 0:
|
||||||
|
_url_skin = 1
|
||||||
|
if _url_skin == 0:
|
||||||
|
return false
|
||||||
|
if _url_skin == 1:
|
||||||
|
return true
|
||||||
|
return DP.b("use_rigid_skin")
|
||||||
|
|
||||||
|
|
||||||
## Returns the first keyboard event bound to an action, or null.
|
## Returns the first keyboard event bound to an action, or null.
|
||||||
func get_key_event(action: StringName) -> InputEventKey:
|
func get_key_event(action: StringName) -> InputEventKey:
|
||||||
for event: InputEvent in InputMap.action_get_events(action):
|
for event: InputEvent in InputMap.action_get_events(action):
|
||||||
|
|||||||
@@ -306,6 +306,18 @@ func _register_all() -> void:
|
|||||||
# it), so it can't be perma-stunlocked — it gets a window to commit an attack that then
|
# it), so it can't be perma-stunlocked — it gets a window to commit an attack that then
|
||||||
# rides its hyper-armour. 0 = no poise (stun-locks under sustained fire).
|
# rides its hyper-armour. 0 = no poise (stun-locks under sustained fire).
|
||||||
_reg_f("Bear", "bear_stagger_cd", 1.0, 0.0, 4.0, 0.05)
|
_reg_f("Bear", "bear_stagger_cd", 1.0, 0.0, 4.0, 0.05)
|
||||||
|
# ── Blood ─────────────────────────────────────────────────────────────────
|
||||||
|
# Persistent floor/wall splats (blood_decals.gd), fired via the Gore autoload and
|
||||||
|
# gated by the player's Settings.gore. blood_cap is the ring-buffer size: past it,
|
||||||
|
# new splats overwrite the oldest so fill/memory stay bounded. blood_scale is a
|
||||||
|
# global size multiplier over each hit's own size; satellites are extra droplets
|
||||||
|
# scattered per floor cluster; the wall fan casts blood_wall_rays outward and stains
|
||||||
|
# any wall within blood_wall_reach metres of the hit.
|
||||||
|
_reg_f("Blood", "blood_cap", 192.0, 0.0, 512.0, 16.0)
|
||||||
|
_reg_f("Blood", "blood_scale", 1.0, 0.1, 4.0, 0.05)
|
||||||
|
_reg_f("Blood", "blood_satellites", 3.0, 0.0, 8.0, 1.0)
|
||||||
|
_reg_f("Blood", "blood_wall_reach", 2.5, 0.0, 6.0, 0.1)
|
||||||
|
_reg_f("Blood", "blood_wall_rays", 8.0, 0.0, 16.0, 1.0)
|
||||||
# ── Sword ─────────────────────────────────────────────────────────────────
|
# ── Sword ─────────────────────────────────────────────────────────────────
|
||||||
# Local seating of the sword in the right hand (drawn / fighting).
|
# Local seating of the sword in the right hand (drawn / fighting).
|
||||||
# The blade model runs along its local +Z, but weapon_bone (like every rig bone)
|
# The blade model runs along its local +Z, but weapon_bone (like every rig bone)
|
||||||
@@ -422,6 +434,14 @@ func _register_all() -> void:
|
|||||||
# On-screen readout of live touch state (event count, last position, active zone) so
|
# On-screen readout of live touch state (event count, last position, active zone) so
|
||||||
# touch problems can be diagnosed on the actual device, where no console is reachable.
|
# touch problems can be diagnosed on the actual device, where no console is reachable.
|
||||||
_reg_b("Touch", "touch_debug", false)
|
_reg_b("Touch", "touch_debug", false)
|
||||||
|
# Rebuild skinned characters as rigid, bone-attached mesh pieces instead of GPU vertex
|
||||||
|
# skinning. This was a workaround for older Mali/ANGLE web GPUs where Compatibility skinning
|
||||||
|
# rendered characters invisible — but the Godot 4.7 web templates fixed that (confirmed
|
||||||
|
# on-device), so native skinning is now the default everywhere: it renders the bear's dense
|
||||||
|
# mesh without the rigid-seam holes AND drops the per-spawn SurfaceTool rebuild that was
|
||||||
|
# spiking the arena on matador spawn. Kept as an opt-in fallback (DP toggle, or ?skin=1 in
|
||||||
|
# the URL) in case a device is found where Compatibility skinning still fails.
|
||||||
|
_reg_b("Render", "use_rigid_skin", false)
|
||||||
|
|
||||||
|
|
||||||
func _reg_f(section: String, key: String, default: float,
|
func _reg_f(section: String, key: String, default: float,
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
extends Node
|
||||||
|
## Stateless façade (autoload `Gore`) for spawning persistent blood splats. Callers just say
|
||||||
|
## Gore.splat(pos, dir, size) at each wound; this routes to the level's BloodDecals pool
|
||||||
|
## (group &"blood_decals"), which the game shell rebuilds into LevelRoot on every load so the
|
||||||
|
## stains die with the level. No-ops when there's no pool (menus, headless tests without a
|
||||||
|
## level) or when the player has turned gore off — so the gate lives in one place.
|
||||||
|
|
||||||
|
|
||||||
|
func splat(world_pos: Vector3, dir: Vector3 = Vector3.ZERO, size: float = 0.6) -> void:
|
||||||
|
if not Settings.gore:
|
||||||
|
return
|
||||||
|
var pool := get_tree().get_first_node_in_group(&"blood_decals")
|
||||||
|
if pool == null:
|
||||||
|
return
|
||||||
|
pool.call(&"splat", world_pos, dir, size * DP.f("blood_scale"))
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://b3dobof8qule3
|
||||||
@@ -95,6 +95,7 @@ func _ready() -> void:
|
|||||||
_build_damage_vignette()
|
_build_damage_vignette()
|
||||||
_build_game_over()
|
_build_game_over()
|
||||||
_build_touch_controls()
|
_build_touch_controls()
|
||||||
|
_build_mobile_hud()
|
||||||
_update_control_hints()
|
_update_control_hints()
|
||||||
_find_spawner.call_deferred()
|
_find_spawner.call_deferred()
|
||||||
_watch_barrels.call_deferred()
|
_watch_barrels.call_deferred()
|
||||||
@@ -107,6 +108,14 @@ func _build_touch_controls() -> void:
|
|||||||
add_child((load("res://touch_controls.gd") as Script).new())
|
add_child((load("res://touch_controls.gd") as Script).new())
|
||||||
|
|
||||||
|
|
||||||
|
# Mobile-only health readouts (bull pips top-left + bear boss bar top-centre). On touch the
|
||||||
|
# bottom pip cluster is hidden (see _build_health), so this is the only health display there;
|
||||||
|
# on desktop it's never created.
|
||||||
|
func _build_mobile_hud() -> void:
|
||||||
|
if Controls.use_touch_ui():
|
||||||
|
add_child((load("res://mobile_hud.gd") as Script).new())
|
||||||
|
|
||||||
|
|
||||||
# Bottom-centre column that groups the gameplay HUD (abilities on top, bull health
|
# Bottom-centre column that groups the gameplay HUD (abilities on top, bull health
|
||||||
# directly beneath) so both read as one cluster instead of scattered corners.
|
# directly beneath) so both read as one cluster instead of scattered corners.
|
||||||
func _build_bottom_hud() -> void:
|
func _build_bottom_hud() -> void:
|
||||||
@@ -171,18 +180,29 @@ func _position_bull_bg() -> void:
|
|||||||
vp_h - bh)
|
vp_h - bh)
|
||||||
|
|
||||||
|
|
||||||
# Unobtrusive early-build tag in the bottom-right corner.
|
# Early-build tag. Bottom-right on desktop; on touch it moves to the bottom centre, clear of
|
||||||
|
# the ability cluster + joystick that own the bottom corners, and reads with an outline.
|
||||||
func _build_version_label() -> void:
|
func _build_version_label() -> void:
|
||||||
var lbl := Label.new()
|
var lbl := Label.new()
|
||||||
lbl.text = GameVersion.full()
|
lbl.text = GameVersion.full()
|
||||||
lbl.add_theme_color_override("font_color", _MUTED)
|
lbl.add_theme_color_override("font_color", Color(0.90, 0.86, 0.72))
|
||||||
lbl.add_theme_font_size_override("font_size", 12)
|
lbl.add_theme_color_override("font_outline_color", Color(0, 0, 0, 0.85))
|
||||||
lbl.set_anchors_preset(Control.PRESET_BOTTOM_RIGHT)
|
lbl.add_theme_constant_override("outline_size", 4)
|
||||||
lbl.grow_horizontal = Control.GROW_DIRECTION_BEGIN
|
lbl.add_theme_font_size_override("font_size", 16)
|
||||||
lbl.grow_vertical = Control.GROW_DIRECTION_BEGIN
|
lbl.grow_vertical = Control.GROW_DIRECTION_BEGIN
|
||||||
lbl.offset_right = -14.0
|
|
||||||
lbl.offset_bottom = -10.0
|
lbl.offset_bottom = -10.0
|
||||||
lbl.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
lbl.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||||
|
if Controls.use_touch_ui():
|
||||||
|
lbl.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||||||
|
lbl.anchor_left = 0.5
|
||||||
|
lbl.anchor_right = 0.5
|
||||||
|
lbl.anchor_top = 1.0
|
||||||
|
lbl.anchor_bottom = 1.0
|
||||||
|
lbl.grow_horizontal = Control.GROW_DIRECTION_BOTH
|
||||||
|
else:
|
||||||
|
lbl.set_anchors_preset(Control.PRESET_BOTTOM_RIGHT)
|
||||||
|
lbl.grow_horizontal = Control.GROW_DIRECTION_BEGIN
|
||||||
|
lbl.offset_right = -20.0
|
||||||
add_child(lbl)
|
add_child(lbl)
|
||||||
|
|
||||||
|
|
||||||
@@ -494,7 +514,7 @@ func _build_game_over() -> void:
|
|||||||
# Buttons overlaid at the bottom of the clip.
|
# Buttons overlaid at the bottom of the clip.
|
||||||
var row := HBoxContainer.new()
|
var row := HBoxContainer.new()
|
||||||
row.alignment = BoxContainer.ALIGNMENT_CENTER
|
row.alignment = BoxContainer.ALIGNMENT_CENTER
|
||||||
row.add_theme_constant_override("separation", 16)
|
row.add_theme_constant_override("separation", 32 if Controls.use_touch_ui() else 16)
|
||||||
row.anchor_left = 0.5
|
row.anchor_left = 0.5
|
||||||
row.anchor_right = 0.5
|
row.anchor_right = 0.5
|
||||||
row.anchor_top = 1.0
|
row.anchor_top = 1.0
|
||||||
@@ -504,13 +524,13 @@ func _build_game_over() -> void:
|
|||||||
row.offset_bottom = -48.0
|
row.offset_bottom = -48.0
|
||||||
_over_root.add_child(row)
|
_over_root.add_child(row)
|
||||||
|
|
||||||
# Win → Continue to the run map; loss → Go Again from the top. Shown per-result in
|
# Win → continue to the run map; loss → restart from the top. Shown per-result in
|
||||||
# _show_game_over; both share the Enter shortcut.
|
# _show_game_over; both share the Enter shortcut and label.
|
||||||
_over_continue = _make_button("Continue (Enter)")
|
_over_continue = _make_button("Continue (Enter)")
|
||||||
_over_continue.pressed.connect(_continue_to_lobby)
|
_over_continue.pressed.connect(_continue_to_lobby)
|
||||||
row.add_child(_over_continue)
|
row.add_child(_over_continue)
|
||||||
|
|
||||||
_over_again = _make_button("Go Again (Enter)")
|
_over_again = _make_button("Continue (Enter)")
|
||||||
_over_again.pressed.connect(_restart)
|
_over_again.pressed.connect(_restart)
|
||||||
row.add_child(_over_again)
|
row.add_child(_over_again)
|
||||||
|
|
||||||
@@ -518,6 +538,11 @@ func _build_game_over() -> void:
|
|||||||
menu.pressed.connect(_return_to_menu)
|
menu.pressed.connect(_return_to_menu)
|
||||||
row.add_child(menu)
|
row.add_child(menu)
|
||||||
|
|
||||||
|
# Thumbs need a far bigger target than a mouse pointer, and the outcome clip has room
|
||||||
|
# for it — grow the whole row on touch only.
|
||||||
|
for btn: Button in [_over_continue, _over_again, menu]:
|
||||||
|
_grow_for_touch(btn)
|
||||||
|
|
||||||
# ── Bull health ───────────────────────────────────────────────────────────────
|
# ── Bull health ───────────────────────────────────────────────────────────────
|
||||||
# A row of gold-socketed pips beneath the ability bar — one pip per HP, so raising or
|
# A row of gold-socketed pips beneath the ability bar — one pip per HP, so raising or
|
||||||
# lowering max HP visibly grows or shrinks the row. Lit pips run red and shade toward
|
# lowering max HP visibly grows or shrinks the row. Lit pips run red and shade toward
|
||||||
@@ -540,6 +565,9 @@ func _build_health() -> void:
|
|||||||
"panel", UiTheme.plaque(UiTheme.LEATHER, UiTheme.BORDER, 4, 12.0, 6.0))
|
"panel", UiTheme.plaque(UiTheme.LEATHER, UiTheme.BORDER, 4, 12.0, 6.0))
|
||||||
panel.size_flags_horizontal = Control.SIZE_SHRINK_CENTER
|
panel.size_flags_horizontal = Control.SIZE_SHRINK_CENTER
|
||||||
panel.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
panel.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||||
|
# On touch the bottom edge is crowded by the joystick + ability cluster and mobile_hud.gd
|
||||||
|
# paints health up top instead, so drop this bottom pip cluster there.
|
||||||
|
panel.visible = not Controls.use_touch_ui()
|
||||||
_bottom_col.add_child(panel)
|
_bottom_col.add_child(panel)
|
||||||
|
|
||||||
var row := HBoxContainer.new()
|
var row := HBoxContainer.new()
|
||||||
@@ -799,6 +827,10 @@ func _build_controls_panel() -> void:
|
|||||||
vbox.add_child(_toggle_btn)
|
vbox.add_child(_toggle_btn)
|
||||||
|
|
||||||
|
|
||||||
|
const _TOUCH_BTN_FONT: int = 34
|
||||||
|
const _TOUCH_BTN_MIN: Vector2 = Vector2(320.0, 110.0)
|
||||||
|
|
||||||
|
|
||||||
func _make_button(label_text: String) -> Button:
|
func _make_button(label_text: String) -> Button:
|
||||||
var btn := Button.new()
|
var btn := Button.new()
|
||||||
btn.text = label_text
|
btn.text = label_text
|
||||||
@@ -819,6 +851,15 @@ func _make_button(label_text: String) -> Button:
|
|||||||
return btn
|
return btn
|
||||||
|
|
||||||
|
|
||||||
|
# Scales a game-over button up to a comfortable thumb target on touch devices; a no-op
|
||||||
|
# with mouse/gamepad, where the desktop sizing already reads fine.
|
||||||
|
func _grow_for_touch(btn: Button) -> void:
|
||||||
|
if not Controls.use_touch_ui():
|
||||||
|
return
|
||||||
|
btn.add_theme_font_size_override("font_size", _TOUCH_BTN_FONT)
|
||||||
|
btn.custom_minimum_size = _TOUCH_BTN_MIN
|
||||||
|
|
||||||
|
|
||||||
func _row(parent: VBoxContainer, key: String, desc: String, desc_color: Color) -> void:
|
func _row(parent: VBoxContainer, key: String, desc: String, desc_color: Color) -> void:
|
||||||
var row := HBoxContainer.new()
|
var row := HBoxContainer.new()
|
||||||
row.add_theme_constant_override("separation", 10)
|
row.add_theme_constant_override("separation", 10)
|
||||||
|
|||||||
@@ -30,6 +30,9 @@ func load_active_level() -> void:
|
|||||||
var module := level.level_scene.instantiate()
|
var module := level.level_scene.instantiate()
|
||||||
_tag_environment_for_corpses(module)
|
_tag_environment_for_corpses(module)
|
||||||
_level_root.add_child(module)
|
_level_root.add_child(module)
|
||||||
|
# A fresh blood-splat pool for this level, alongside the module under LevelRoot so it's
|
||||||
|
# torn down with everything else on the next load — stains never leak into the next fight.
|
||||||
|
_level_root.add_child(preload("res://blood_decals.gd").new())
|
||||||
|
|
||||||
|
|
||||||
## Ground and walls stay on WORLD so the bull and live matadors collide with them; here
|
## Ground and walls stay on WORLD so the bull and live matadors collide with them; here
|
||||||
|
|||||||
@@ -100,6 +100,12 @@ func _ready() -> void:
|
|||||||
exit_button.pressed.connect(_on_exit_pressed)
|
exit_button.pressed.connect(_on_exit_pressed)
|
||||||
options_back.pressed.connect(_close_subpanels)
|
options_back.pressed.connect(_close_subpanels)
|
||||||
credits_back.pressed.connect(_close_subpanels)
|
credits_back.pressed.connect(_close_subpanels)
|
||||||
|
# The Options panel is keyboard rebinds + desktop-shaped audio rows — meaningless on a
|
||||||
|
# phone, so drop the entry point on touch devices.
|
||||||
|
options_button.visible = not Controls.use_touch_ui()
|
||||||
|
# On the web build get_tree().quit() just freezes the canvas (you can't close a browser
|
||||||
|
# tab from script), so Exit has nowhere to go — hide it. Native desktop keeps it.
|
||||||
|
exit_button.visible = not OS.has_feature("web")
|
||||||
_apply_theme()
|
_apply_theme()
|
||||||
_build_version_label()
|
_build_version_label()
|
||||||
_build_options_toggles()
|
_build_options_toggles()
|
||||||
|
|||||||
+5
-3
@@ -102,9 +102,10 @@ func _ready() -> void:
|
|||||||
_anim_player = _find_anim_player(_mesh)
|
_anim_player = _find_anim_player(_mesh)
|
||||||
if _skeleton:
|
if _skeleton:
|
||||||
_sim = MatadorRagdoll.build(_skeleton)
|
_sim = MatadorRagdoll.build(_skeleton)
|
||||||
# Web (Mali/ANGLE) can't run Compatibility vertex-skinning; rebuild as non-skinned
|
# Native GPU skinning by default; rigid_skin is an opt-in fallback now (the Mali/ANGLE
|
||||||
# bone-attached pieces. Runs after the ragdoll sim is built (both drive the same bones).
|
# invisible-skinning bug is fixed in the 4.7 web templates). See Controls.rigid_skin_enabled().
|
||||||
if OS.has_feature("web"):
|
# When on, it runs after the ragdoll sim is built (both drive the same bones).
|
||||||
|
if Controls.rigid_skin_enabled():
|
||||||
RigidSkin.convert_tree(self)
|
RigidSkin.convert_tree(self)
|
||||||
if _anim_player:
|
if _anim_player:
|
||||||
for anim in [_ANIM_RUN, _ANIM_ATTACK] + _ANIM_TAUNTS:
|
for anim in [_ANIM_RUN, _ANIM_ATTACK] + _ANIM_TAUNTS:
|
||||||
@@ -973,6 +974,7 @@ func _enter_ragdoll(hit_dir: Vector3, bull_speed: float, up_boost: float = 0.0)
|
|||||||
var throw_dir := (hit_dir + Vector3(0.0, 0.5, 0.0)).normalized()
|
var throw_dir := (hit_dir + Vector3(0.0, 0.5, 0.0)).normalized()
|
||||||
|
|
||||||
_blood_burst.burst(global_position + Vector3(0.0, 0.9, 0.0), hit_dir)
|
_blood_burst.burst(global_position + Vector3(0.0, 0.9, 0.0), hit_dir)
|
||||||
|
Gore.splat(global_position, hit_dir, 0.55)
|
||||||
if _death_player:
|
if _death_player:
|
||||||
_death_player.pitch_scale = randf_range(0.9, 1.1)
|
_death_player.pitch_scale = randf_range(0.9, 1.1)
|
||||||
_death_player.play()
|
_death_player.play()
|
||||||
|
|||||||
+164
@@ -0,0 +1,164 @@
|
|||||||
|
extends CanvasLayer
|
||||||
|
## Mobile-only health readouts (instanced by hud.gd only when Controls.use_touch_ui()).
|
||||||
|
## The desktop HUD's bull pips are tiny and, on a phone, the on-screen thumb cluster crowds
|
||||||
|
## the bottom edge — so on touch we hide that cluster (hud.gd) and paint health up top where
|
||||||
|
## it's clear of the joystick and abilities:
|
||||||
|
## • bull health — a big pip row, top-left
|
||||||
|
## • boss health — a wide Souls-style bar, top-centre, shown only while a bear is alive
|
||||||
|
##
|
||||||
|
## Everything is drawn in one IGNORE-filtered Control (never eats a touch), same immediate-mode
|
||||||
|
## approach as touch_controls.gd. Cached values are refreshed from each character's
|
||||||
|
## health_changed signal; the bear is discovered by group (&"bear") because it spawns after the
|
||||||
|
## HUD, and a level swap frees it so we re-acquire the next one.
|
||||||
|
|
||||||
|
const _HP_FULL: Color = Color(0.82, 0.16, 0.12)
|
||||||
|
const _HP_BONUS: Color = Color(1.00, 0.84, 0.18)
|
||||||
|
const _SOCKET: Color = Color(0.05, 0.02, 0.02, 0.92)
|
||||||
|
const _PANEL_BG: Color = Color(0.06, 0.04, 0.02, 0.82)
|
||||||
|
|
||||||
|
const _PIP_W: float = 42.0
|
||||||
|
const _PIP_H: float = 28.0
|
||||||
|
const _PIP_GAP: float = 7.0
|
||||||
|
|
||||||
|
var _surface: Control
|
||||||
|
|
||||||
|
var _player: Node = null
|
||||||
|
var _bull_cur: int = 0
|
||||||
|
var _bull_max: int = 0
|
||||||
|
var _bull_bonus: int = 0
|
||||||
|
|
||||||
|
var _boss: Node = null
|
||||||
|
var _boss_alive: bool = false
|
||||||
|
var _boss_cur: int = 0
|
||||||
|
var _boss_max: int = 1
|
||||||
|
|
||||||
|
|
||||||
|
func _ready() -> void:
|
||||||
|
layer = 9 # below touch UI (10) and the HUD/game-over (11) so the outcome screen covers it
|
||||||
|
process_mode = Node.PROCESS_MODE_ALWAYS
|
||||||
|
|
||||||
|
_surface = Control.new()
|
||||||
|
_surface.set_anchors_preset(Control.PRESET_FULL_RECT)
|
||||||
|
_surface.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||||
|
_surface.draw.connect(_draw_surface)
|
||||||
|
add_child(_surface)
|
||||||
|
|
||||||
|
get_viewport().size_changed.connect(_surface.queue_redraw)
|
||||||
|
|
||||||
|
|
||||||
|
func _process(_delta: float) -> void:
|
||||||
|
if not Controls.use_touch_ui():
|
||||||
|
return
|
||||||
|
|
||||||
|
if _player == null:
|
||||||
|
var players := get_tree().get_nodes_in_group(&"player")
|
||||||
|
if not players.is_empty():
|
||||||
|
_player = players[0]
|
||||||
|
_player.health_changed.connect(_on_bull_health)
|
||||||
|
_on_bull_health(_player.call(&"get_hp"), _player.call(&"get_max_hp"))
|
||||||
|
|
||||||
|
# A level swap frees the old bear; drop the stale ref so the next level's bear is picked up.
|
||||||
|
if _boss != null and not is_instance_valid(_boss):
|
||||||
|
_boss = null
|
||||||
|
_boss_alive = false
|
||||||
|
_surface.queue_redraw()
|
||||||
|
if _boss == null:
|
||||||
|
var bears := get_tree().get_nodes_in_group(&"bear")
|
||||||
|
if not bears.is_empty():
|
||||||
|
_boss = bears[0]
|
||||||
|
_boss_alive = true
|
||||||
|
_boss.health_changed.connect(_on_boss_health)
|
||||||
|
if _boss.has_signal(&"killed"):
|
||||||
|
_boss.killed.connect(_on_boss_killed)
|
||||||
|
_boss_cur = int(_boss.call(&"get_hp"))
|
||||||
|
_boss_max = maxi(_boss_cur, int(DP.f("bear_max_hp")))
|
||||||
|
_surface.queue_redraw()
|
||||||
|
|
||||||
|
|
||||||
|
func _on_bull_health(current: int, max_hp: int) -> void:
|
||||||
|
_bull_cur = maxi(current, 0)
|
||||||
|
_bull_max = maxi(max_hp, 0)
|
||||||
|
_bull_bonus = 0
|
||||||
|
if _player != null and _player.has_method(&"get_bonus_hp"):
|
||||||
|
_bull_bonus = int(_player.call(&"get_bonus_hp"))
|
||||||
|
_surface.queue_redraw()
|
||||||
|
|
||||||
|
|
||||||
|
func _on_boss_health(current: int, max_hp: int) -> void:
|
||||||
|
_boss_cur = maxi(current, 0)
|
||||||
|
_boss_max = maxi(max_hp, maxi(_boss_cur, 1))
|
||||||
|
if _boss_cur <= 0:
|
||||||
|
_boss_alive = false
|
||||||
|
_surface.queue_redraw()
|
||||||
|
|
||||||
|
|
||||||
|
func _on_boss_killed() -> void:
|
||||||
|
_boss_alive = false
|
||||||
|
_surface.queue_redraw()
|
||||||
|
|
||||||
|
|
||||||
|
# ── Drawing ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func _draw_surface() -> void:
|
||||||
|
if not Controls.use_touch_ui():
|
||||||
|
return
|
||||||
|
var boss_showing := _boss != null and is_instance_valid(_boss) and _boss_alive and _boss_cur > 0
|
||||||
|
# The boss bar is centred at the very top; the bull pip row is top-left. On a narrow landscape
|
||||||
|
# phone a long pip row reaches the centre and collides with the boss bar, so when the bar is up
|
||||||
|
# the bull row drops to a second line just beneath it (guaranteed clear, whatever the HP count).
|
||||||
|
var bull_top := 22.0
|
||||||
|
if boss_showing:
|
||||||
|
bull_top = _draw_boss_bar() + 12.0
|
||||||
|
_draw_bull_health(bull_top)
|
||||||
|
|
||||||
|
|
||||||
|
# Big pip row in the top-left. Bonus pips (barrel reward) sit at the high end and light gold,
|
||||||
|
# matching the desktop HUD; base pips light red; spent pips read as dark sockets.
|
||||||
|
func _draw_bull_health(top: float) -> void:
|
||||||
|
var font := UiFonts.body()
|
||||||
|
var x := 24.0
|
||||||
|
var y := top
|
||||||
|
if font != null:
|
||||||
|
var label := "BULL"
|
||||||
|
var fs := 20
|
||||||
|
_surface.draw_string(
|
||||||
|
font, Vector2(x, y + _PIP_H * 0.85), label, HORIZONTAL_ALIGNMENT_LEFT, -1, fs,
|
||||||
|
UiTheme.GOLD)
|
||||||
|
x += font.get_string_size(label, HORIZONTAL_ALIGNMENT_LEFT, -1, fs).x + 12.0
|
||||||
|
|
||||||
|
var base_max := _bull_max - _bull_bonus
|
||||||
|
for i: int in _bull_max:
|
||||||
|
var r := Rect2(x + i * (_PIP_W + _PIP_GAP), y, _PIP_W, _PIP_H)
|
||||||
|
_surface.draw_rect(r, _SOCKET)
|
||||||
|
if i < _bull_cur:
|
||||||
|
var c := _HP_BONUS if i >= base_max else _HP_FULL
|
||||||
|
_surface.draw_rect(r.grow(-2.0), c)
|
||||||
|
_surface.draw_rect(r, Color(UiTheme.GOLD.r, UiTheme.GOLD.g, UiTheme.GOLD.b, 0.45), false, 2.0)
|
||||||
|
|
||||||
|
|
||||||
|
# Wide boss bar centred at the top: dark trough, red fill scaled to remaining HP, gold trim,
|
||||||
|
# name centred above it. Returns the bar's bottom Y so the bull pip row can sit clear beneath it.
|
||||||
|
func _draw_boss_bar() -> float:
|
||||||
|
var vp := get_viewport().get_visible_rect().size
|
||||||
|
var bw := clampf(vp.x * 0.55, 280.0, 620.0)
|
||||||
|
var bh := 30.0
|
||||||
|
var bx := (vp.x - bw) * 0.5
|
||||||
|
var by := 20.0
|
||||||
|
var bar := Rect2(bx, by, bw, bh)
|
||||||
|
|
||||||
|
_surface.draw_rect(bar, _PANEL_BG)
|
||||||
|
var frac := clampf(float(_boss_cur) / float(_boss_max), 0.0, 1.0)
|
||||||
|
var fill := bar.grow(-3.0)
|
||||||
|
fill.size.x *= frac
|
||||||
|
_surface.draw_rect(fill, _HP_FULL)
|
||||||
|
_surface.draw_rect(bar, UiTheme.BORDER, false, 2.0)
|
||||||
|
|
||||||
|
var font := UiFonts.body()
|
||||||
|
if font != null:
|
||||||
|
var boss_name := "BEAR"
|
||||||
|
var fs := 20
|
||||||
|
var tw := font.get_string_size(boss_name, HORIZONTAL_ALIGNMENT_LEFT, -1, fs)
|
||||||
|
_surface.draw_string(
|
||||||
|
font, Vector2(bx + (bw - tw.x) * 0.5, by - 6.0), boss_name,
|
||||||
|
HORIZONTAL_ALIGNMENT_LEFT, -1, fs, UiTheme.GOLD)
|
||||||
|
return by + bh
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://p8cqggisskor
|
||||||
@@ -28,6 +28,11 @@ var _was_on_floor: bool = false
|
|||||||
var _pre_slide_vel_y: float = 0.0
|
var _pre_slide_vel_y: float = 0.0
|
||||||
|
|
||||||
var _slam_ring_mesh: ArrayMesh = null
|
var _slam_ring_mesh: ArrayMesh = null
|
||||||
|
# Attack-FX resources are identical every swing, so build them once and reuse. Recreating a fresh
|
||||||
|
# StandardMaterial3D / SphereMesh per attack makes a mobile WebGL driver recompile the shader and
|
||||||
|
# re-upload the mesh each time — a main-thread hitch "when attacking" that never shows on desktop.
|
||||||
|
var _unshaded_mat: StandardMaterial3D = null
|
||||||
|
var _particle_sphere_cache: Dictionary = {} # "radius:seg:ring" -> SphereMesh (shared)
|
||||||
|
|
||||||
# Ability system — kick=0, dash=1, slam=2, roll=3
|
# Ability system — kick=0, dash=1, slam=2, roll=3
|
||||||
var ability_cd: Array[float] = [0.0, 0.0, 0.0, 0.0]
|
var ability_cd: Array[float] = [0.0, 0.0, 0.0, 0.0]
|
||||||
@@ -89,10 +94,9 @@ var _hit_flash_tween: Tween
|
|||||||
|
|
||||||
func _ready() -> void:
|
func _ready() -> void:
|
||||||
add_to_group(&"player")
|
add_to_group(&"player")
|
||||||
# Mali/ANGLE mobile GPUs can't run Godot's Compatibility vertex-skinning (transform
|
# Native GPU skinning by default (the old Mali/ANGLE invisible-skinning bug is fixed in the
|
||||||
# feedback) — skinned meshes render invisible on the web build. Rebuild the bull as
|
# 4.7 web templates). rigid_skin is now an opt-in fallback — see Controls.rigid_skin_enabled().
|
||||||
# non-skinned bone-attached pieces there; desktop keeps smooth GPU skinning.
|
if Controls.rigid_skin_enabled():
|
||||||
if OS.has_feature("web"):
|
|
||||||
RigidSkin.convert_tree(self)
|
RigidSkin.convert_tree(self)
|
||||||
_max_hp = maxi(1, int(DP.f("bull_max_hp")))
|
_max_hp = maxi(1, int(DP.f("bull_max_hp")))
|
||||||
_hp = _max_hp
|
_hp = _max_hp
|
||||||
@@ -681,23 +685,33 @@ func _hit_matadors_radius(range_m: float, strength: float, up_boost: float = 0.0
|
|||||||
# All FX in this script use unshaded, vertex-coloured, alpha-blended particles;
|
# All FX in this script use unshaded, vertex-coloured, alpha-blended particles;
|
||||||
# these three helpers remove the boilerplate each spawn function used to repeat.
|
# these three helpers remove the boilerplate each spawn function used to repeat.
|
||||||
|
|
||||||
|
# One shared unshaded material for every particle FX (per-particle colour comes from the
|
||||||
|
# CPUParticles3D colour_ramp via vertex colour, not the material) — built once so the mobile
|
||||||
|
# driver compiles this shader a single time instead of on every attack.
|
||||||
func _unshaded_material() -> StandardMaterial3D:
|
func _unshaded_material() -> StandardMaterial3D:
|
||||||
var mat := StandardMaterial3D.new()
|
if _unshaded_mat == null:
|
||||||
mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
|
_unshaded_mat = StandardMaterial3D.new()
|
||||||
mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
|
_unshaded_mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
|
||||||
mat.vertex_color_use_as_albedo = true
|
_unshaded_mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
|
||||||
mat.cull_mode = BaseMaterial3D.CULL_DISABLED
|
_unshaded_mat.vertex_color_use_as_albedo = true
|
||||||
return mat
|
_unshaded_mat.cull_mode = BaseMaterial3D.CULL_DISABLED
|
||||||
|
return _unshaded_mat
|
||||||
|
|
||||||
|
|
||||||
# Low-poly sphere sized for a particle mesh (height is always the diameter).
|
# Low-poly sphere sized for a particle mesh (height is always the diameter). Cached per size so
|
||||||
|
# repeat attacks reuse the same mesh (one GPU upload) instead of generating + uploading a new one.
|
||||||
func _particle_sphere(radius: float, segments: int, rings: int, mat: Material) -> SphereMesh:
|
func _particle_sphere(radius: float, segments: int, rings: int, mat: Material) -> SphereMesh:
|
||||||
|
var key := "%.4f:%d:%d" % [radius, segments, rings]
|
||||||
|
var cached: SphereMesh = _particle_sphere_cache.get(key)
|
||||||
|
if cached != null:
|
||||||
|
return cached
|
||||||
var sphere := SphereMesh.new()
|
var sphere := SphereMesh.new()
|
||||||
sphere.radius = radius
|
sphere.radius = radius
|
||||||
sphere.height = radius * 2.0
|
sphere.height = radius * 2.0
|
||||||
sphere.radial_segments = segments
|
sphere.radial_segments = segments
|
||||||
sphere.rings = rings
|
sphere.rings = rings
|
||||||
sphere.material = mat
|
sphere.material = mat
|
||||||
|
_particle_sphere_cache[key] = sphere
|
||||||
return sphere
|
return sphere
|
||||||
|
|
||||||
|
|
||||||
@@ -1191,6 +1205,9 @@ func take_sword_hit(cause: String = "", hit_pos: Vector3 = Vector3.ZERO) -> void
|
|||||||
# bull). Melee gores pass no position and skip the spurt; gore can be turned off.
|
# bull). Melee gores pass no position and skip the spurt; gore can be turned off.
|
||||||
if cause == "thrown" and Settings.gore:
|
if cause == "thrown" and Settings.gore:
|
||||||
_spawn_blood(hit_pos if hit_pos != Vector3.ZERO else global_position)
|
_spawn_blood(hit_pos if hit_pos != Vector3.ZERO else global_position)
|
||||||
|
# Any hit — melee or thrown — leaves a lasting stain on the ground the bull bled on.
|
||||||
|
var splat_dir := global_position - hit_pos if hit_pos != Vector3.ZERO else Vector3.ZERO
|
||||||
|
Gore.splat(global_position, splat_dir, 0.5)
|
||||||
if _huff_player:
|
if _huff_player:
|
||||||
_huff_player.pitch_scale = randf_range(0.7, 0.9)
|
_huff_player.pitch_scale = randf_range(0.7, 0.9)
|
||||||
_huff_player.play()
|
_huff_player.play()
|
||||||
|
|||||||
@@ -28,6 +28,8 @@ DebugDraw="*res://debug_draw.gd"
|
|||||||
Settings="*res://settings.gd"
|
Settings="*res://settings.gd"
|
||||||
Run="*res://levels/run_state.gd"
|
Run="*res://levels/run_state.gd"
|
||||||
OrientationGuard="*res://orientation_guard.gd"
|
OrientationGuard="*res://orientation_guard.gd"
|
||||||
|
WebStartGate="*res://web_start_gate.gd"
|
||||||
|
Gore="*res://gore.gd"
|
||||||
|
|
||||||
[display]
|
[display]
|
||||||
|
|
||||||
|
|||||||
+40
-17
@@ -43,9 +43,11 @@ static func _convert(skel: Skeleton3D, mi: MeshInstance3D) -> bool:
|
|||||||
bind_bone.append(bone)
|
bind_bone.append(bone)
|
||||||
bind_pose.append(skin.get_bind_pose(b))
|
bind_pose.append(skin.get_bind_pose(b))
|
||||||
|
|
||||||
# Accumulate SurfaceTool geometry per destination bone.
|
# Accumulate SurfaceTool geometry per (destination bone, source surface). Keying on the
|
||||||
var st_by_bone: Dictionary = {} # bone index -> SurfaceTool
|
# surface too — not just the bone — is what preserves materials: a head bone that carries
|
||||||
var mat_by_bone: Dictionary = {} # bone index -> Material (first seen)
|
# both the skin and hair surfaces must stay two pieces with two materials, otherwise every
|
||||||
|
# surface funnelled to a bone collapses onto one material (bald matadors, wrong uniforms).
|
||||||
|
var groups: Dictionary = {} # "bone:surface" -> {"st": SurfaceTool, "mat": Material, "bone": int}
|
||||||
|
|
||||||
for s: int in mesh.get_surface_count():
|
for s: int in mesh.get_surface_count():
|
||||||
var arr := mesh.surface_get_arrays(s)
|
var arr := mesh.surface_get_arrays(s)
|
||||||
@@ -56,7 +58,13 @@ static func _convert(skel: Skeleton3D, mi: MeshInstance3D) -> bool:
|
|||||||
var bones: PackedInt32Array = arr[Mesh.ARRAY_BONES]
|
var bones: PackedInt32Array = arr[Mesh.ARRAY_BONES]
|
||||||
var weights: PackedFloat32Array = arr[Mesh.ARRAY_WEIGHTS]
|
var weights: PackedFloat32Array = arr[Mesh.ARRAY_WEIGHTS]
|
||||||
var idx: PackedInt32Array = arr[Mesh.ARRAY_INDEX]
|
var idx: PackedInt32Array = arr[Mesh.ARRAY_INDEX]
|
||||||
var mat := mesh.surface_get_material(s)
|
# Honour a per-instance override (whole mesh) or per-surface override the scene set,
|
||||||
|
# falling back to the material baked into the mesh surface.
|
||||||
|
var mat: Material = mi.material_override
|
||||||
|
if mat == null:
|
||||||
|
mat = mi.get_surface_override_material(s)
|
||||||
|
if mat == null:
|
||||||
|
mat = mesh.surface_get_material(s)
|
||||||
var infl := 8 if (mesh.surface_get_format(s) & Mesh.ARRAY_FLAG_USE_8_BONE_WEIGHTS) else 4
|
var infl := 8 if (mesh.surface_get_format(s) & Mesh.ARRAY_FLAG_USE_8_BONE_WEIGHTS) else 4
|
||||||
|
|
||||||
var tri := PackedInt32Array()
|
var tri := PackedInt32Array()
|
||||||
@@ -74,12 +82,14 @@ static func _convert(skel: Skeleton3D, mi: MeshInstance3D) -> bool:
|
|||||||
var bind := _dominant_bind([a, b, c], bones, weights, infl)
|
var bind := _dominant_bind([a, b, c], bones, weights, infl)
|
||||||
var bone := bind_bone[bind]
|
var bone := bind_bone[bind]
|
||||||
var pose := bind_pose[bind]
|
var pose := bind_pose[bind]
|
||||||
var st: SurfaceTool = st_by_bone.get(bone)
|
var key := "%d:%d" % [bone, s]
|
||||||
if st == null:
|
var group: Dictionary = groups.get(key, {})
|
||||||
st = SurfaceTool.new()
|
if group.is_empty():
|
||||||
st.begin(Mesh.PRIMITIVE_TRIANGLES)
|
var new_st := SurfaceTool.new()
|
||||||
st_by_bone[bone] = st
|
new_st.begin(Mesh.PRIMITIVE_TRIANGLES)
|
||||||
mat_by_bone[bone] = mat
|
group = {"st": new_st, "mat": mat, "bone": bone}
|
||||||
|
groups[key] = group
|
||||||
|
var st: SurfaceTool = group["st"]
|
||||||
for v: int in [a, b, c]:
|
for v: int in [a, b, c]:
|
||||||
if cols.size() > v:
|
if cols.size() > v:
|
||||||
st.set_color(cols[v])
|
st.set_color(cols[v])
|
||||||
@@ -89,19 +99,32 @@ static func _convert(skel: Skeleton3D, mi: MeshInstance3D) -> bool:
|
|||||||
st.set_normal((pose.basis * norms[v]).normalized())
|
st.set_normal((pose.basis * norms[v]).normalized())
|
||||||
st.add_vertex(pose * verts[v])
|
st.add_vertex(pose * verts[v])
|
||||||
|
|
||||||
if st_by_bone.is_empty():
|
if groups.is_empty():
|
||||||
return false
|
return false
|
||||||
|
|
||||||
for bone: int in st_by_bone:
|
# Combine all of a bone's surface-groups into ONE mesh under ONE BoneAttachment (each group
|
||||||
|
# stays its own surface + material). Same draw calls as before, but roughly half the nodes to
|
||||||
|
# transform/cull every frame — which is what a phone feels while ragdolls drive the skeleton.
|
||||||
|
var by_bone: Dictionary = {} # bone:int -> Array[Dictionary]
|
||||||
|
for key: String in groups:
|
||||||
|
var group: Dictionary = groups[key]
|
||||||
|
var bone: int = group["bone"]
|
||||||
|
if not by_bone.has(bone):
|
||||||
|
by_bone[bone] = []
|
||||||
|
(by_bone[bone] as Array).append(group)
|
||||||
|
|
||||||
|
for bone: int in by_bone:
|
||||||
var att := BoneAttachment3D.new()
|
var att := BoneAttachment3D.new()
|
||||||
att.bone_name = skel.get_bone_name(bone)
|
att.bone_name = skel.get_bone_name(bone)
|
||||||
skel.add_child(att)
|
skel.add_child(att)
|
||||||
var piece := MeshInstance3D.new()
|
var piece := MeshInstance3D.new()
|
||||||
var st: SurfaceTool = st_by_bone[bone]
|
var combined := ArrayMesh.new()
|
||||||
var m := st.commit()
|
for group: Dictionary in by_bone[bone]:
|
||||||
if mat_by_bone[bone] != null:
|
var st: SurfaceTool = group["st"]
|
||||||
m.surface_set_material(0, mat_by_bone[bone])
|
st.commit(combined)
|
||||||
piece.mesh = m
|
if group["mat"] != null:
|
||||||
|
combined.surface_set_material(combined.get_surface_count() - 1, group["mat"])
|
||||||
|
piece.mesh = combined
|
||||||
att.add_child(piece)
|
att.add_child(piece)
|
||||||
|
|
||||||
# Hide (don't free) the original: keeps the node for any gameplay code that references
|
# Hide (don't free) the original: keeps the node for any gameplay code that references
|
||||||
|
|||||||
@@ -22,10 +22,26 @@ echo ""
|
|||||||
echo "=== Level switch tests (no cross-level geometry/collider leaks) ==="
|
echo "=== Level switch tests (no cross-level geometry/collider leaks) ==="
|
||||||
"$GODOT" --headless --script tests/level_switch_test.gd
|
"$GODOT" --headless --script tests/level_switch_test.gd
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "=== Touch control tests (joystick self-heals, never strands) ==="
|
||||||
|
"$GODOT" --headless --script tests/touch_controls_test.gd
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "=== Blood decal tests (floor+wall stamps, ring-buffer cap, gore gate) ==="
|
||||||
|
"$GODOT" --headless --script tests/blood_decals_test.gd
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "=== Rigid-skin tests (web mesh conversion keeps all materials) ==="
|
||||||
|
"$GODOT" --headless --script tests/rigid_skin_test.gd
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
echo "=== Performance tests (frame-budget regression guard) ==="
|
echo "=== Performance tests (frame-budget regression guard) ==="
|
||||||
"$GODOT" --headless --script tests/performance_test.gd
|
"$GODOT" --headless --script tests/performance_test.gd
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "=== Ragdoll perf (on-hit spike breakdown: build / sim-start / burst / mass hit) ==="
|
||||||
|
"$GODOT" --headless --script tests/ragdoll_perf_test.gd
|
||||||
|
|
||||||
if [[ "$SKIP_GAMEPLAY" -eq 0 ]]; then
|
if [[ "$SKIP_GAMEPLAY" -eq 0 ]]; then
|
||||||
echo ""
|
echo ""
|
||||||
echo "=== Gameplay tests (headless — assertions only, screenshots may be blank) ==="
|
echo "=== Gameplay tests (headless — assertions only, screenshots may be blank) ==="
|
||||||
|
|||||||
@@ -0,0 +1,97 @@
|
|||||||
|
extends SceneTree
|
||||||
|
## Persistent blood splats (blood_decals.gd + the Gore autoload). Guards four contracts:
|
||||||
|
## • a splat stamps quads onto the floor and onto a wall within reach of the hit;
|
||||||
|
## • the ring buffer caps the instance count no matter how many hits land;
|
||||||
|
## • Settings.gore = false spawns nothing;
|
||||||
|
## • the pool is a plain node, so it frees with its parent (a level swap clears the stains).
|
||||||
|
## blood_decals.gd references the DP autoload, so it's load()ed at runtime here (not a
|
||||||
|
## top-level preload) — under --script a preload compiles before autoloads register.
|
||||||
|
## Run: godot --headless --script tests/blood_decals_test.gd
|
||||||
|
|
||||||
|
var _pass := 0
|
||||||
|
var _fail := 0
|
||||||
|
|
||||||
|
|
||||||
|
func _init() -> void:
|
||||||
|
_run.call_deferred()
|
||||||
|
|
||||||
|
|
||||||
|
func _ok(c: bool, m: String) -> void:
|
||||||
|
if c:
|
||||||
|
_pass += 1
|
||||||
|
print(" PASS: ", m)
|
||||||
|
else:
|
||||||
|
_fail += 1
|
||||||
|
print(" FAIL: ", m)
|
||||||
|
|
||||||
|
|
||||||
|
# Count instances actually placed on the map. Only the first `visible_instance_count` slots of
|
||||||
|
# the ring buffer are drawn, so that's the live count once the buffer has wrapped.
|
||||||
|
func _live_instances(pool: Node) -> int:
|
||||||
|
var mm: MultiMesh = pool.multimesh
|
||||||
|
if mm == null:
|
||||||
|
return 0
|
||||||
|
return mm.visible_instance_count
|
||||||
|
|
||||||
|
|
||||||
|
func _static_box(size: Vector3, pos: Vector3) -> StaticBody3D:
|
||||||
|
var body := StaticBody3D.new()
|
||||||
|
body.collision_layer = 1 # PhysicsLayers.WORLD
|
||||||
|
body.position = pos
|
||||||
|
var shape := CollisionShape3D.new()
|
||||||
|
var box := BoxShape3D.new()
|
||||||
|
box.size = size
|
||||||
|
shape.shape = box
|
||||||
|
body.add_child(shape)
|
||||||
|
return body
|
||||||
|
|
||||||
|
|
||||||
|
func _run() -> void:
|
||||||
|
var dp: Node = root.get_node("DP")
|
||||||
|
var settings: Node = root.get_node("Settings")
|
||||||
|
var cap := int(dp.f("blood_cap"))
|
||||||
|
|
||||||
|
# A minimal world: a floor plane and one vertical wall, both on WORLD so the splat rays hit.
|
||||||
|
var world := Node3D.new()
|
||||||
|
root.add_child(world)
|
||||||
|
world.add_child(_static_box(Vector3(40, 1, 40), Vector3(0, -0.5, 0))) # floor
|
||||||
|
world.add_child(_static_box(Vector3(1, 6, 40), Vector3(1.4, 3, 0))) # wall at x≈1.4
|
||||||
|
|
||||||
|
var pool: Node = (load("res://blood_decals.gd") as GDScript).new()
|
||||||
|
world.add_child(pool)
|
||||||
|
await create_timer(0.4).timeout
|
||||||
|
|
||||||
|
# Baked atlas exists and is one row of 8 shapes.
|
||||||
|
var atlas: Texture2D = pool.material_override.get_shader_parameter("atlas")
|
||||||
|
_ok(atlas != null and atlas.get_width() == 8 * 64, "splat atlas baked (8 cells)")
|
||||||
|
|
||||||
|
# A hit next to the wall stains both the floor beneath it and the wall.
|
||||||
|
settings.gore = true
|
||||||
|
pool.splat(Vector3(0.8, 0.1, 0.0), Vector3(1, 0, 0), 0.6)
|
||||||
|
var after_one := _live_instances(pool)
|
||||||
|
_ok(after_one > 0, "a splat stamps the floor (got %d quads)" % after_one)
|
||||||
|
# Floor cluster is 1 + satellites; anything beyond that came from the wall fan.
|
||||||
|
var floor_max := 1 + int(dp.f("blood_satellites"))
|
||||||
|
_ok(
|
||||||
|
after_one > floor_max,
|
||||||
|
"a nearby wall also catches blood (%d > floor-only %d)" % [after_one, floor_max]
|
||||||
|
)
|
||||||
|
|
||||||
|
# Ring buffer: hammer far past the cap; live count never exceeds it.
|
||||||
|
for i in cap * 2:
|
||||||
|
pool.splat(Vector3(0.0, 0.1, 0.0), Vector3(1, 0, 0), 0.4)
|
||||||
|
_ok(_live_instances(pool) <= cap, "ring buffer holds at the cap (%d)" % cap)
|
||||||
|
|
||||||
|
# Gore off: no new stains. Route through the Gore autoload to prove the gate lives there.
|
||||||
|
var before_off := _live_instances(pool)
|
||||||
|
settings.gore = false
|
||||||
|
root.get_node("Gore").splat(Vector3(5, 0.1, 0.0), Vector3.ZERO, 0.6)
|
||||||
|
_ok(_live_instances(pool) == before_off, "gore off spawns nothing")
|
||||||
|
|
||||||
|
# Lifetime: the pool is a plain node, so freeing its parent (a level swap) clears it.
|
||||||
|
world.queue_free()
|
||||||
|
await create_timer(0.2).timeout
|
||||||
|
_ok(not is_instance_valid(pool), "pool frees with its parent (no leak into next level)")
|
||||||
|
|
||||||
|
print("Results: %d passed, %d failed" % [_pass, _fail])
|
||||||
|
quit(1 if _fail > 0 else 0)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://b8em8poj26a57
|
||||||
@@ -53,7 +53,14 @@ func _run() -> void:
|
|||||||
# Shell survives and hosts exactly one level module.
|
# Shell survives and hosts exactly one level module.
|
||||||
var level_root: Node = scene.get_node_or_null("LevelRoot")
|
var level_root: Node = scene.get_node_or_null("LevelRoot")
|
||||||
_ok(level_root != null, "shell has a LevelRoot")
|
_ok(level_root != null, "shell has a LevelRoot")
|
||||||
_ok(level_root != null and level_root.get_child_count() == 1, "exactly one level module loaded")
|
# LevelRoot holds the module plus the level's blood-splat pool; exactly one of them
|
||||||
|
# is the geometry module (the other is BloodDecals).
|
||||||
|
var module_children := 0
|
||||||
|
if level_root != null:
|
||||||
|
for child: Node in level_root.get_children():
|
||||||
|
if not child.is_in_group(&"blood_decals"):
|
||||||
|
module_children += 1
|
||||||
|
_ok(module_children == 1, "exactly one level module loaded")
|
||||||
_ok(not get_nodes_in_group(&"player").is_empty(), "player (shell) present")
|
_ok(not get_nodes_in_group(&"player").is_empty(), "player (shell) present")
|
||||||
|
|
||||||
# The leak we are guarding against: no arena geometry may exist in the bear level.
|
# The leak we are guarding against: no arena geometry may exist in the bear level.
|
||||||
|
|||||||
@@ -0,0 +1,201 @@
|
|||||||
|
extends SceneTree
|
||||||
|
## Micro-benchmark for the on-hit ragdoll spike.
|
||||||
|
##
|
||||||
|
## On mobile the profiler reads SCRIPT-bound (main-thread GDScript, one thread) exactly when the
|
||||||
|
## bull's attack connects and a matador is hit. This times the pieces of that synchronous path so
|
||||||
|
## we can see which op eats the milliseconds instead of guessing:
|
||||||
|
## • spawn — _matador.instantiate() + add_child (_ready builds the 21-bone ragdoll rig)
|
||||||
|
## • sim start — PhysicalBoneSimulator3D.physical_bones_start_simulation() (Jolt makes bodies)
|
||||||
|
## • blood burst — the CPUParticles3D one-shot fired on death
|
||||||
|
## • full hit — apply_ability_hit() end to end (what the player actually triggers)
|
||||||
|
## • MASS hit — hitting N matadors in ONE frame (what a slam / roll does — the real spike)
|
||||||
|
##
|
||||||
|
## Run: godot --headless --script res://tests/ragdoll_perf_test.gd
|
||||||
|
## It PRINTS per-op avg/worst ms and names the dominant cost; it also fails (exit 1) if a single
|
||||||
|
## hit or a mass-hit frame blows a generous budget, so it doubles as a regression guard.
|
||||||
|
|
||||||
|
# Loaded at runtime (not preload): a preload here would compile matador.gd at this entry
|
||||||
|
# script's parse time, before the autoloads (DP / Controls) register as global identifiers,
|
||||||
|
# so matador.gd's `Controls.rigid_skin_enabled()` would fail to resolve. load() in _run runs
|
||||||
|
# after the tree — and its autoloads — are up.
|
||||||
|
var _matador: PackedScene = null
|
||||||
|
|
||||||
|
const N := 8 # matadors sampled per op
|
||||||
|
const MASS := 6 # matadors hit in one frame (a slam catching a cluster)
|
||||||
|
const SINGLE_HIT_BUDGET_MS := 12.0
|
||||||
|
const MASS_HIT_BUDGET_MS := 33.0 # two 60fps frames — a slam may cost a hitch, not a freeze
|
||||||
|
|
||||||
|
var _fail := 0
|
||||||
|
|
||||||
|
|
||||||
|
func _init() -> void:
|
||||||
|
_run.call_deferred()
|
||||||
|
|
||||||
|
|
||||||
|
func _us() -> int:
|
||||||
|
return Time.get_ticks_usec()
|
||||||
|
|
||||||
|
|
||||||
|
func _stats(us: Array) -> Dictionary:
|
||||||
|
var total := 0
|
||||||
|
var worst := 0
|
||||||
|
for v: int in us:
|
||||||
|
total += v
|
||||||
|
worst = maxi(worst, v)
|
||||||
|
var avg := (total / us.size()) if us.size() > 0 else 0
|
||||||
|
return {"avg_ms": avg / 1000.0, "max_ms": worst / 1000.0}
|
||||||
|
|
||||||
|
|
||||||
|
func _sample(frames: int) -> Dictionary:
|
||||||
|
var proc_sum := 0.0
|
||||||
|
var phys_sum := 0.0
|
||||||
|
for f: int in frames:
|
||||||
|
await process_frame
|
||||||
|
proc_sum += Performance.get_monitor(Performance.TIME_PROCESS) * 1000.0
|
||||||
|
phys_sum += Performance.get_monitor(Performance.TIME_PHYSICS_PROCESS) * 1000.0
|
||||||
|
return {"proc_ms": proc_sum / frames, "phys_ms": phys_sum / frames}
|
||||||
|
|
||||||
|
|
||||||
|
func _spawn_one() -> Node3D:
|
||||||
|
var m: Node3D = _matador.instantiate()
|
||||||
|
m.position = Vector3(randf_range(-20.0, 20.0), 0.0, randf_range(-20.0, 20.0))
|
||||||
|
root.add_child(m)
|
||||||
|
return m
|
||||||
|
|
||||||
|
|
||||||
|
func _run() -> void:
|
||||||
|
# Warm up: first instance pays one-time import/JIT/shader costs that would skew sample 1.
|
||||||
|
_matador = load("res://Matador.tscn") as PackedScene
|
||||||
|
var warm := _spawn_one()
|
||||||
|
await process_frame
|
||||||
|
warm.call(&"apply_ability_hit", Vector3(1.0, 0.0, 0.0), 12.0)
|
||||||
|
await process_frame
|
||||||
|
warm.free()
|
||||||
|
await process_frame
|
||||||
|
print("[stage] warmup done")
|
||||||
|
|
||||||
|
# ── spawn: instantiate() vs add_child(_ready = ragdoll build) ───────────────
|
||||||
|
var inst_us: Array = []
|
||||||
|
var ready_us: Array = []
|
||||||
|
for i: int in N:
|
||||||
|
var t0 := _us()
|
||||||
|
var m: Node3D = _matador.instantiate()
|
||||||
|
var t1 := _us()
|
||||||
|
root.add_child(m) # _ready runs synchronously → MatadorRagdoll.build (21 bodies + shapes)
|
||||||
|
var t2 := _us()
|
||||||
|
inst_us.append(t1 - t0)
|
||||||
|
ready_us.append(t2 - t1)
|
||||||
|
m.free()
|
||||||
|
await process_frame
|
||||||
|
print("[stage] spawn done")
|
||||||
|
|
||||||
|
# ── component: sim start (Jolt body creation) and blood burst, in isolation ──
|
||||||
|
var simstart_us: Array = []
|
||||||
|
var burst_us: Array = []
|
||||||
|
for i: int in N:
|
||||||
|
var m := _spawn_one()
|
||||||
|
await process_frame
|
||||||
|
var sim: Node = m.get(&"_sim")
|
||||||
|
if sim != null:
|
||||||
|
sim.set("active", true)
|
||||||
|
var t0 := _us()
|
||||||
|
sim.call(&"physical_bones_start_simulation")
|
||||||
|
simstart_us.append(_us() - t0)
|
||||||
|
var blood: Node = m.get(&"_blood_burst")
|
||||||
|
if blood != null:
|
||||||
|
var t2 := _us()
|
||||||
|
blood.call(&"burst", m.global_position + Vector3(0, 0.9, 0), Vector3(1, 0, 0))
|
||||||
|
burst_us.append(_us() - t2)
|
||||||
|
m.free()
|
||||||
|
await process_frame
|
||||||
|
print("[stage] components done")
|
||||||
|
|
||||||
|
# ── full hit: apply_ability_hit end to end (state WANDER → RAGDOLL) ──────────
|
||||||
|
var hit_us: Array = []
|
||||||
|
for i: int in N:
|
||||||
|
var m := _spawn_one()
|
||||||
|
await process_frame
|
||||||
|
var t0 := _us()
|
||||||
|
m.call(&"apply_ability_hit", Vector3(1.0, 0.0, 0.0), 12.0)
|
||||||
|
hit_us.append(_us() - t0)
|
||||||
|
await process_frame
|
||||||
|
m.free()
|
||||||
|
await process_frame
|
||||||
|
print("[stage] full-hit done")
|
||||||
|
|
||||||
|
# ── MASS hit: MASS matadors ragdolled in ONE frame (a slam catching a cluster) ─
|
||||||
|
var cluster: Array = []
|
||||||
|
for i: int in MASS:
|
||||||
|
cluster.append(_spawn_one())
|
||||||
|
await process_frame
|
||||||
|
await process_frame
|
||||||
|
var mt0 := _us()
|
||||||
|
for m: Node3D in cluster:
|
||||||
|
m.call(&"apply_ability_hit", Vector3(1.0, 0.0, 0.0), 12.0)
|
||||||
|
var mass_ms := (_us() - mt0) / 1000.0
|
||||||
|
for m: Node3D in cluster:
|
||||||
|
m.free()
|
||||||
|
await process_frame
|
||||||
|
print("[stage] mass done")
|
||||||
|
|
||||||
|
# ── STEADY load: per-frame cost of live_n ragdolls ALIVE at once (they live ~4 s each) ─
|
||||||
|
# The hit is instantaneous; the drag is every ragdoll still simulating afterward. Sample
|
||||||
|
# the frame cost with live_n matadors idle, then with all live_n ragdolling, and report the delta —
|
||||||
|
# split process (idle-frame GDScript = the mobile "SCRIPT" bucket) vs physics (the Jolt step).
|
||||||
|
var live_n := 10
|
||||||
|
var steady: Array = []
|
||||||
|
for i: int in live_n:
|
||||||
|
steady.append(_spawn_one())
|
||||||
|
for f: int in 15:
|
||||||
|
await process_frame
|
||||||
|
var idle: Dictionary = await _sample(20)
|
||||||
|
for m: Node3D in steady:
|
||||||
|
m.call(&"apply_ability_hit", Vector3(1.0, 0.0, 0.0), 12.0)
|
||||||
|
for f: int in 3:
|
||||||
|
await process_frame
|
||||||
|
var active: Dictionary = await _sample(20)
|
||||||
|
for m: Node3D in steady:
|
||||||
|
if is_instance_valid(m):
|
||||||
|
m.free()
|
||||||
|
|
||||||
|
# ── report ──────────────────────────────────────────────────────────────────
|
||||||
|
var inst := _stats(inst_us)
|
||||||
|
var rdy := _stats(ready_us)
|
||||||
|
var ss := _stats(simstart_us)
|
||||||
|
var bu := _stats(burst_us)
|
||||||
|
var hit := _stats(hit_us)
|
||||||
|
|
||||||
|
print("\n==== on-hit ragdoll cost (per matador, avg / worst) ====")
|
||||||
|
print(" instantiate() %6.2f / %6.2f ms" % [inst["avg_ms"], inst["max_ms"]])
|
||||||
|
print(" add_child (_ready build)%6.2f / %6.2f ms" % [rdy["avg_ms"], rdy["max_ms"]])
|
||||||
|
print(" sim start (Jolt bodies) %6.2f / %6.2f ms" % [ss["avg_ms"], ss["max_ms"]])
|
||||||
|
print(" blood burst %6.2f / %6.2f ms" % [bu["avg_ms"], bu["max_ms"]])
|
||||||
|
print(" FULL apply_ability_hit %6.2f / %6.2f ms" % [hit["avg_ms"], hit["max_ms"]])
|
||||||
|
print(" MASS hit (%d in 1 frame) %6.2f ms total" % [MASS, mass_ms])
|
||||||
|
print("---- steady per-frame cost, %d matadors idle vs ragdolling ----" % live_n)
|
||||||
|
print(" idle: process %5.2f ms physics %5.2f ms" % [idle["proc_ms"], idle["phys_ms"]])
|
||||||
|
print(" ragdolling: process %5.2f ms physics %5.2f ms" % [active["proc_ms"], active["phys_ms"]])
|
||||||
|
print(" delta/%d ragdolls: process +%5.2f ms physics +%5.2f ms (per ragdoll ~%.2f / %.2f ms)" % [
|
||||||
|
live_n, active["proc_ms"] - idle["proc_ms"], active["phys_ms"] - idle["phys_ms"],
|
||||||
|
(active["proc_ms"] - idle["proc_ms"]) / live_n, (active["phys_ms"] - idle["phys_ms"]) / live_n])
|
||||||
|
|
||||||
|
# Name the dominant component of the full hit so the fix target is obvious.
|
||||||
|
var parts := {"sim start": ss["avg_ms"], "blood burst": bu["avg_ms"]}
|
||||||
|
var worst_name := "sim start"
|
||||||
|
var worst_val := -1.0
|
||||||
|
for k: String in parts:
|
||||||
|
if parts[k] > worst_val:
|
||||||
|
worst_val = parts[k]
|
||||||
|
worst_name = k
|
||||||
|
print(" >> dominant hit cost: %s (%.2f ms of the %.2f ms hit)" % [
|
||||||
|
worst_name, worst_val, hit["avg_ms"]])
|
||||||
|
|
||||||
|
if hit["max_ms"] > SINGLE_HIT_BUDGET_MS:
|
||||||
|
_fail += 1
|
||||||
|
print(" FAIL: worst single hit %.2f ms > %.1f ms budget" % [hit["max_ms"], SINGLE_HIT_BUDGET_MS])
|
||||||
|
if mass_ms > MASS_HIT_BUDGET_MS:
|
||||||
|
_fail += 1
|
||||||
|
print(" FAIL: mass hit %.2f ms > %.1f ms budget" % [mass_ms, MASS_HIT_BUDGET_MS])
|
||||||
|
|
||||||
|
print("Results: %s" % ("FAIL (%d)" % _fail if _fail > 0 else "PASS"))
|
||||||
|
quit(1 if _fail > 0 else 0)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://c8jvxwwmg7cp1
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
extends SceneTree
|
||||||
|
## Guard for the web/mobile rigid-skin conversion (rigid_skin.gd). The bug it protects against:
|
||||||
|
## the converter used to key geometry by destination bone only, so every surface funnelled to a
|
||||||
|
## bone collapsed onto one material — matadors came out bald with the wrong uniform colour on the
|
||||||
|
## web build. It now keys per (bone, surface), so all source materials survive. This asserts the
|
||||||
|
## matador keeps every distinct material after conversion, produces bone-attached pieces, and
|
||||||
|
## hides the original skinned meshes (so they never hit the invisible-on-Mali skinning path).
|
||||||
|
## Run: godot --headless --script res://tests/rigid_skin_test.gd
|
||||||
|
|
||||||
|
const RigidSkin = preload("res://rigid_skin.gd")
|
||||||
|
|
||||||
|
var _pass := 0
|
||||||
|
var _fail := 0
|
||||||
|
|
||||||
|
|
||||||
|
func _init() -> void:
|
||||||
|
_run.call_deferred()
|
||||||
|
|
||||||
|
|
||||||
|
func _ok(c: bool, m: String) -> void:
|
||||||
|
if c:
|
||||||
|
_pass += 1
|
||||||
|
print(" PASS: ", m)
|
||||||
|
else:
|
||||||
|
_fail += 1
|
||||||
|
print(" FAIL: ", m)
|
||||||
|
|
||||||
|
|
||||||
|
func _surface_mats(mi: MeshInstance3D) -> Array:
|
||||||
|
var out: Array = []
|
||||||
|
var mesh := mi.mesh
|
||||||
|
if mesh == null:
|
||||||
|
return out
|
||||||
|
for s: int in mesh.get_surface_count():
|
||||||
|
var mat: Material = mi.material_override
|
||||||
|
if mat == null:
|
||||||
|
mat = mi.get_surface_override_material(s)
|
||||||
|
if mat == null:
|
||||||
|
mat = mesh.surface_get_material(s)
|
||||||
|
if mat != null and not out.has(mat):
|
||||||
|
out.append(mat)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
func _run() -> void:
|
||||||
|
var inst: Node = (load("res://Assets/Matador.glb") as PackedScene).instantiate()
|
||||||
|
root.add_child(inst)
|
||||||
|
|
||||||
|
# Distinct materials on the skinned source meshes, before conversion.
|
||||||
|
var source: Array = []
|
||||||
|
var skinned: Array = []
|
||||||
|
for mi: MeshInstance3D in inst.find_children("*", "MeshInstance3D", true, false):
|
||||||
|
if mi.skin != null:
|
||||||
|
skinned.append(mi)
|
||||||
|
for mat: Material in _surface_mats(mi):
|
||||||
|
if not source.has(mat):
|
||||||
|
source.append(mat)
|
||||||
|
_ok(source.size() >= 2, "matador source has multiple distinct materials (%d)" % source.size())
|
||||||
|
|
||||||
|
var converted := RigidSkin.convert_tree(inst)
|
||||||
|
_ok(converted > 0, "convert_tree rebuilt %d skinned mesh(es)" % converted)
|
||||||
|
|
||||||
|
# Distinct materials on the generated bone-attached pieces.
|
||||||
|
var pieces := 0
|
||||||
|
var piece_mats: Array = []
|
||||||
|
for att: BoneAttachment3D in inst.find_children("*", "BoneAttachment3D", true, false):
|
||||||
|
for mi: MeshInstance3D in att.find_children("*", "MeshInstance3D", true, false):
|
||||||
|
pieces += 1
|
||||||
|
var mesh := mi.mesh
|
||||||
|
if mesh == null:
|
||||||
|
continue
|
||||||
|
# Pieces are multi-surface now (a bone's materials each stay their own surface), so
|
||||||
|
# scan every surface — not just surface 0 — to confirm none were dropped.
|
||||||
|
for s: int in mesh.get_surface_count():
|
||||||
|
var mat := mesh.surface_get_material(s)
|
||||||
|
if mat != null and not piece_mats.has(mat):
|
||||||
|
piece_mats.append(mat)
|
||||||
|
_ok(pieces > 0, "conversion produced bone-attached pieces (%d)" % pieces)
|
||||||
|
_ok(piece_mats.size() >= source.size(),
|
||||||
|
"every source material survives conversion (%d of %d kept)" % [piece_mats.size(), source.size()])
|
||||||
|
|
||||||
|
var all_hidden := true
|
||||||
|
for mi: MeshInstance3D in skinned:
|
||||||
|
if mi.visible:
|
||||||
|
all_hidden = false
|
||||||
|
_ok(all_hidden, "original skinned meshes are hidden (never drawn on the failing path)")
|
||||||
|
|
||||||
|
_finish()
|
||||||
|
|
||||||
|
|
||||||
|
func _finish() -> void:
|
||||||
|
print("Results: %d passed, %d failed" % [_pass, _fail])
|
||||||
|
quit(1 if _fail > 0 else 0)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://qmf1j0ytjag5
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
extends SceneTree
|
||||||
|
## Guardrail for the on-screen joystick's self-healing. The reported bug is a stick that
|
||||||
|
## "disappears / goes non-responsive": a touchend dropped during a resize/orientation flip (or
|
||||||
|
## an app backgrounding) leaves _stick_active stuck true, so the ghost hint hides and no new
|
||||||
|
## stick can start — and the bull keeps coasting on the held move actions. touch_controls.gd
|
||||||
|
## now resets on relayout + focus-out, prunes orphaned touches, and lets the latest left-zone
|
||||||
|
## touch re-acquire the stick. This drives synthetic touch events and asserts the stick never
|
||||||
|
## stays stranded and never leaves movement held.
|
||||||
|
## Run: godot --headless --script res://tests/touch_controls_test.gd
|
||||||
|
|
||||||
|
const _MOVE := [&"move_forward", &"move_back", &"move_left", &"move_right"]
|
||||||
|
|
||||||
|
var _pass := 0
|
||||||
|
var _fail := 0
|
||||||
|
var _tc: CanvasLayer
|
||||||
|
|
||||||
|
|
||||||
|
func _init() -> void:
|
||||||
|
_run.call_deferred()
|
||||||
|
|
||||||
|
|
||||||
|
func _ok(c: bool, m: String) -> void:
|
||||||
|
if c:
|
||||||
|
_pass += 1
|
||||||
|
print(" PASS: ", m)
|
||||||
|
else:
|
||||||
|
_fail += 1
|
||||||
|
print(" FAIL: ", m)
|
||||||
|
|
||||||
|
|
||||||
|
func _press(index: int, pos: Vector2) -> void:
|
||||||
|
var e := InputEventScreenTouch.new()
|
||||||
|
e.index = index
|
||||||
|
e.position = pos
|
||||||
|
e.pressed = true
|
||||||
|
_tc._input(e)
|
||||||
|
|
||||||
|
|
||||||
|
func _release(index: int, pos: Vector2) -> void:
|
||||||
|
var e := InputEventScreenTouch.new()
|
||||||
|
e.index = index
|
||||||
|
e.position = pos
|
||||||
|
e.pressed = false
|
||||||
|
_tc._input(e)
|
||||||
|
|
||||||
|
|
||||||
|
func _drag(index: int, pos: Vector2) -> void:
|
||||||
|
var e := InputEventScreenDrag.new()
|
||||||
|
e.index = index
|
||||||
|
e.position = pos
|
||||||
|
_tc._input(e)
|
||||||
|
|
||||||
|
|
||||||
|
func _any_move_held() -> bool:
|
||||||
|
for a: StringName in _MOVE:
|
||||||
|
if Input.is_action_pressed(a):
|
||||||
|
return true
|
||||||
|
return false
|
||||||
|
|
||||||
|
|
||||||
|
# A point safely inside the left-hand joystick zone (independent of viewport size), plus the
|
||||||
|
# same point pushed a full radius "up" so the stick engages move_forward.
|
||||||
|
func _stick_origin() -> Vector2:
|
||||||
|
return _tc._stick_zone.position + _tc._stick_zone.size * Vector2(0.25, 0.5)
|
||||||
|
|
||||||
|
|
||||||
|
func _run() -> void:
|
||||||
|
var dp: Node = root.get_node("/root/DP")
|
||||||
|
var controls: Node = root.get_node("/root/Controls")
|
||||||
|
dp.call("set_value", "force_touch_controls", true)
|
||||||
|
_ok(controls.call("use_touch_ui"), "force flag makes use_touch_ui() true (touch UI live)")
|
||||||
|
|
||||||
|
# Headless boots a 64x64 root viewport, which collapses every UI rect on top of each other.
|
||||||
|
# Give it a real phone-ish landscape size so the joystick zone / buttons lay out sanely.
|
||||||
|
root.size = Vector2i(1152, 648)
|
||||||
|
_tc = (load("res://touch_controls.gd") as Script).new()
|
||||||
|
root.add_child(_tc)
|
||||||
|
await process_frame # _ready builds _surface and runs the first _relayout
|
||||||
|
_tc._relayout() # ensure the layout reflects the size set above
|
||||||
|
|
||||||
|
var o := _stick_origin()
|
||||||
|
var up := o + Vector2(0.0, -_tc._stick_radius)
|
||||||
|
|
||||||
|
# (a) A resize mid-drag (dropped touchend) must not strand the stick or keep the bull moving.
|
||||||
|
_tc._reset_touch_state()
|
||||||
|
_press(0, o)
|
||||||
|
_drag(0, up)
|
||||||
|
_ok(_tc._stick_active and _any_move_held(), "drag engages the stick and holds movement")
|
||||||
|
_tc._relayout() # the resize/orientation flip that used to swallow the release
|
||||||
|
_ok(not _tc._stick_active, "a relayout mid-drag stands the stick down (not stranded)")
|
||||||
|
_ok(not _any_move_held(), "movement is released on relayout (bull doesn't coast)")
|
||||||
|
|
||||||
|
# (b) App backgrounded / focus lost — same self-heal via the notification hook.
|
||||||
|
_tc._reset_touch_state()
|
||||||
|
_press(0, o)
|
||||||
|
_drag(0, up)
|
||||||
|
_tc._notification(Node.NOTIFICATION_APPLICATION_FOCUS_OUT)
|
||||||
|
_ok(not _tc._stick_active and not _any_move_held(), "focus-out clears the stick and movement")
|
||||||
|
|
||||||
|
# (c) A stranded stick self-heals: with finger 0's release lost, a fresh finger 1 in the zone
|
||||||
|
# re-acquires the stick; finger 0's late release must NOT kill finger 1's stick.
|
||||||
|
_tc._reset_touch_state()
|
||||||
|
_press(0, o) # finger 0 owns the stick
|
||||||
|
_press(1, o + Vector2(20, 10)) # finger 1 lands while 0 is still (wrongly) held → takes over
|
||||||
|
_ok(_tc._stick_active and _tc._stick_index == 1, "latest left-zone touch re-acquires the stick")
|
||||||
|
_release(0, o) # the stranded finger finally reports up
|
||||||
|
_ok(_tc._stick_active and _tc._stick_index == 1, "a superseded finger's release doesn't end the stick")
|
||||||
|
_release(1, o)
|
||||||
|
_ok(not _tc._stick_active, "releasing the owning finger ends the stick")
|
||||||
|
|
||||||
|
# (d) Watchdog: a touch that vanishes from tracking (no release event at all) is pruned.
|
||||||
|
_tc._reset_touch_state()
|
||||||
|
_press(0, o)
|
||||||
|
_tc._touches.erase(0) # simulate the finger silently disappearing
|
||||||
|
_tc._process(0.016) # prune runs each frame
|
||||||
|
_ok(not _tc._stick_active and not _any_move_held(), "watchdog prunes a vanished touch and frees the stick")
|
||||||
|
|
||||||
|
_finish()
|
||||||
|
|
||||||
|
|
||||||
|
func _finish() -> void:
|
||||||
|
print("Results: %d passed, %d failed" % [_pass, _fail])
|
||||||
|
quit(1 if _fail > 0 else 0)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://behtmgyi2ktm0
|
||||||
+190
-46
@@ -54,8 +54,13 @@ var _ability_style: Array[StyleBoxFlat] = []
|
|||||||
var _menu_style: StyleBoxFlat
|
var _menu_style: StyleBoxFlat
|
||||||
|
|
||||||
# Layout rects in screen space — the single source of truth for drawing AND hit-testing.
|
# Layout rects in screen space — the single source of truth for drawing AND hit-testing.
|
||||||
|
# Ability buttons are hit-tested against a rect grown by _ability_hit_pad (fills the gaps
|
||||||
|
# between them) so a near-miss still fires — the visual box stays the smaller drawn rect.
|
||||||
|
# _slot_pressed drives a brief press highlight so a tap reads as registered.
|
||||||
var _stick_zone: Rect2 = Rect2()
|
var _stick_zone: Rect2 = Rect2()
|
||||||
var _ability_rects: Array[Rect2] = []
|
var _ability_rects: Array[Rect2] = []
|
||||||
|
var _ability_hit_pad: float = 0.0
|
||||||
|
var _slot_pressed: Array[bool] = [false, false, false, false]
|
||||||
var _menu_rect: Rect2 = Rect2()
|
var _menu_rect: Rect2 = Rect2()
|
||||||
|
|
||||||
# Resting "drag to move" affordance drawn in the lower-left when the stick is idle, so a
|
# Resting "drag to move" affordance drawn in the lower-left when the stick is idle, so a
|
||||||
@@ -65,8 +70,12 @@ var _stick_hint_center: Vector2 = Vector2()
|
|||||||
var _hint_pulse: float = 0.0
|
var _hint_pulse: float = 0.0
|
||||||
|
|
||||||
# Floating-joystick state. _origin is where the thumb first touched (base centre), _knob is
|
# Floating-joystick state. _origin is where the thumb first touched (base centre), _knob is
|
||||||
# the current thumb position; both in screen coordinates.
|
# the current thumb position; both in screen coordinates. _stick_index is the finger driving
|
||||||
|
# the stick (-1 = none): keying release/drag off it (not just the owner dict) lets the latest
|
||||||
|
# left-zone touch always re-acquire the stick, so a stranded stick — e.g. a touchend lost to a
|
||||||
|
# resize — self-heals the moment the player touches down again.
|
||||||
var _stick_active: bool = false
|
var _stick_active: bool = false
|
||||||
|
var _stick_index: int = -1
|
||||||
var _origin: Vector2 = Vector2.ZERO
|
var _origin: Vector2 = Vector2.ZERO
|
||||||
var _knob: Vector2 = Vector2.ZERO
|
var _knob: Vector2 = Vector2.ZERO
|
||||||
var _stick_radius: float = 110.0
|
var _stick_radius: float = 110.0
|
||||||
@@ -88,6 +97,21 @@ var _pinch_dist: float = 0.0
|
|||||||
var _dbg_events: int = 0
|
var _dbg_events: int = 0
|
||||||
var _dbg_last_pos: Vector2 = Vector2.ZERO
|
var _dbg_last_pos: Vector2 = Vector2.ZERO
|
||||||
|
|
||||||
|
# Profiler peak-hold (debug overlay). Godot's game logic — every _process / _physics_process —
|
||||||
|
# runs on ONE thread on web regardless of thread_support, so a lag spike shows up as a jump in
|
||||||
|
# process(script) or physics(ragdoll) ms, NOT in FPS alone. We hold the worst value seen in the
|
||||||
|
# last _PEAK_HOLD_MS so an ability-press spike stays readable instead of flashing by in a frame.
|
||||||
|
# JS-backed fields (fullscreen status, cross-origin isolation) are cached so the overlay's own
|
||||||
|
# per-frame JavaScriptBridge.eval doesn't inflate the very process time it's trying to measure.
|
||||||
|
const _PEAK_HOLD_MS: float = 2000.0
|
||||||
|
var _peak_proc_ms: float = 0.0
|
||||||
|
var _peak_phys_ms: float = 0.0
|
||||||
|
var _peak_proc_t: int = 0
|
||||||
|
var _peak_phys_t: int = 0
|
||||||
|
var _iso_cached: int = -1
|
||||||
|
var _fs_cache: String = "?"
|
||||||
|
var _fs_cache_t: int = 0
|
||||||
|
|
||||||
# Cached real GPU name. RenderingServer.get_video_adapter_name() returns the browser's
|
# Cached real GPU name. RenderingServer.get_video_adapter_name() returns the browser's
|
||||||
# privacy-masked "WebKit WebGL" on the web; the true chip (Adreno/Mali/…) — which decides
|
# privacy-masked "WebKit WebGL" on the web; the true chip (Adreno/Mali/…) — which decides
|
||||||
# whether an invisible skinned mesh is a known mobile-driver bug — is only reachable via
|
# whether an invisible skinned mesh is a known mobile-driver bug — is only reachable via
|
||||||
@@ -143,14 +167,19 @@ func _ready() -> void:
|
|||||||
# the ability cluster and menu button sit in the right half so they never overlap it.
|
# the ability cluster and menu button sit in the right half so they never overlap it.
|
||||||
|
|
||||||
func _relayout() -> void:
|
func _relayout() -> void:
|
||||||
|
# A resize / orientation flip invalidates every rect and can swallow an in-flight touchend
|
||||||
|
# (the browser drops it mid-transition), which is exactly what used to strand the joystick.
|
||||||
|
# Clearing touch state here means the layout always comes back to a clean slate.
|
||||||
|
_reset_touch_state()
|
||||||
var vp := get_viewport().get_visible_rect().size
|
var vp := get_viewport().get_visible_rect().size
|
||||||
var short := minf(vp.x, vp.y)
|
var short := minf(vp.x, vp.y)
|
||||||
_stick_radius = clampf(short * 0.16, 80.0, 150.0)
|
_stick_radius = clampf(short * 0.16, 80.0, 150.0)
|
||||||
_stick_zone = Rect2(Vector2.ZERO, Vector2(vp.x * 0.5, vp.y))
|
_stick_zone = Rect2(Vector2.ZERO, Vector2(vp.x * 0.5, vp.y))
|
||||||
|
|
||||||
var bs := clampf(short * 0.16, 72.0, 120.0) # ability button edge
|
var bs := clampf(short * 0.22, 108.0, 152.0) # ability button edge — bigger, easier thumb target
|
||||||
var gap := bs * 0.18
|
var gap := bs * 0.22
|
||||||
var margin := bs * 0.28
|
var margin := bs * 0.34
|
||||||
|
_ability_hit_pad = gap * 0.5 # grow hit rects to meet in the gaps; near-misses still register
|
||||||
var right := vp.x - margin
|
var right := vp.x - margin
|
||||||
var bottom := vp.y - margin
|
var bottom := vp.y - margin
|
||||||
var col0 := right - bs * 2.0 - gap
|
var col0 := right - bs * 2.0 - gap
|
||||||
@@ -164,8 +193,9 @@ func _relayout() -> void:
|
|||||||
Rect2(col1, row1, bs, bs), # roll
|
Rect2(col1, row1, bs, bs), # roll
|
||||||
]
|
]
|
||||||
|
|
||||||
var ms := clampf(short * 0.09, 48.0, 72.0)
|
var ms := clampf(short * 0.11, 60.0, 84.0) # bigger menu button
|
||||||
_menu_rect = Rect2(vp.x - ms - 16.0, 16.0, ms, ms)
|
var mm := ms * 0.34 # and pulled further off the corner
|
||||||
|
_menu_rect = Rect2(vp.x - ms - mm, mm, ms, ms)
|
||||||
|
|
||||||
_stick_hint_center = Vector2(vp.x * 0.20, vp.y * 0.74)
|
_stick_hint_center = Vector2(vp.x * 0.20, vp.y * 0.74)
|
||||||
_surface.queue_redraw()
|
_surface.queue_redraw()
|
||||||
@@ -230,14 +260,17 @@ func _on_press(index: int, pos: Vector2) -> void:
|
|||||||
_touch_owner[index] = "menu"
|
_touch_owner[index] = "menu"
|
||||||
return
|
return
|
||||||
for slot: int in _ability_rects.size():
|
for slot: int in _ability_rects.size():
|
||||||
if _ability_rects[slot].has_point(pos):
|
if _ability_rects[slot].grow(_ability_hit_pad).has_point(pos):
|
||||||
_touch_owner[index] = slot
|
_touch_owner[index] = slot
|
||||||
|
_slot_pressed[slot] = true
|
||||||
if _player != null:
|
if _player != null:
|
||||||
_player.call(&"try_activate_ability", slot)
|
_player.call(&"try_activate_ability", slot)
|
||||||
return
|
return
|
||||||
if _stick_zone.has_point(pos) and not _stick_active:
|
if _stick_zone.has_point(pos):
|
||||||
|
# Last touch in the zone (re)acquires the stick — this is what recovers a stranded stick
|
||||||
|
# whose finger's release was never delivered. A prior owner is superseded via _stick_index.
|
||||||
_touch_owner[index] = "stick"
|
_touch_owner[index] = "stick"
|
||||||
_begin_stick(pos)
|
_begin_stick(index, pos)
|
||||||
return
|
return
|
||||||
_touch_owner[index] = "camera"
|
_touch_owner[index] = "camera"
|
||||||
|
|
||||||
@@ -245,28 +278,36 @@ func _on_press(index: int, pos: Vector2) -> void:
|
|||||||
func _on_drag(index: int, pos: Vector2) -> void:
|
func _on_drag(index: int, pos: Vector2) -> void:
|
||||||
match _touch_owner.get(index, null):
|
match _touch_owner.get(index, null):
|
||||||
"stick":
|
"stick":
|
||||||
_update_stick(pos)
|
if index == _stick_index:
|
||||||
|
_update_stick(pos)
|
||||||
"camera":
|
"camera":
|
||||||
if _pinching:
|
if _pinching:
|
||||||
_apply_pinch()
|
_apply_pinch()
|
||||||
|
|
||||||
|
|
||||||
func _on_release(index: int, pos: Vector2) -> void:
|
func _on_release(index: int, pos: Vector2) -> void:
|
||||||
match _touch_owner.get(index, null):
|
var owner: Variant = _touch_owner.get(index, null)
|
||||||
|
match owner:
|
||||||
"stick":
|
"stick":
|
||||||
_end_stick()
|
# Only the finger that currently owns the stick ends it; a superseded finger
|
||||||
|
# (another thumb took over) just drops its own mapping below.
|
||||||
|
if index == _stick_index:
|
||||||
|
_end_stick()
|
||||||
"menu":
|
"menu":
|
||||||
if _menu_rect.has_point(pos):
|
if _menu_rect.has_point(pos):
|
||||||
_touch_owner.erase(index)
|
_touch_owner.erase(index)
|
||||||
_return_to_menu()
|
_return_to_menu()
|
||||||
return
|
return
|
||||||
|
if owner is int:
|
||||||
|
_slot_pressed[owner] = false
|
||||||
_touch_owner.erase(index)
|
_touch_owner.erase(index)
|
||||||
|
|
||||||
|
|
||||||
# ── Joystick ────────────────────────────────────────────────────────────────
|
# ── Joystick ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
func _begin_stick(pos: Vector2) -> void:
|
func _begin_stick(index: int, pos: Vector2) -> void:
|
||||||
_stick_active = true
|
_stick_active = true
|
||||||
|
_stick_index = index
|
||||||
_origin = pos
|
_origin = pos
|
||||||
_knob = pos
|
_knob = pos
|
||||||
|
|
||||||
@@ -283,6 +324,7 @@ func _update_stick(pos: Vector2) -> void:
|
|||||||
|
|
||||||
func _end_stick() -> void:
|
func _end_stick() -> void:
|
||||||
_stick_active = false
|
_stick_active = false
|
||||||
|
_stick_index = -1
|
||||||
_release_move()
|
_release_move()
|
||||||
|
|
||||||
|
|
||||||
@@ -345,7 +387,11 @@ func _draw_surface() -> void:
|
|||||||
for slot: int in _ability_rects.size():
|
for slot: int in _ability_rects.size():
|
||||||
var r: Rect2 = _ability_rects[slot]
|
var r: Rect2 = _ability_rects[slot]
|
||||||
_surface.draw_style_box(_ability_style[slot], r)
|
_surface.draw_style_box(_ability_style[slot], r)
|
||||||
var inner := r.grow(-r.size.x * 0.14)
|
if _slot_pressed[slot]:
|
||||||
|
var glow := _ABILITY_ACCENT[slot]
|
||||||
|
glow.a = 0.28
|
||||||
|
_surface.draw_rect(r.grow(-r.size.x * 0.10), glow) # press flash so a tap reads as registered
|
||||||
|
var inner := r.grow(-r.size.x * 0.06) # small inset so the icon nearly fills the button
|
||||||
if _ability_tex[slot] != null:
|
if _ability_tex[slot] != null:
|
||||||
_draw_icon_fit(_ability_tex[slot], inner)
|
_draw_icon_fit(_ability_tex[slot], inner)
|
||||||
else:
|
else:
|
||||||
@@ -424,49 +470,105 @@ func _draw_hamburger(r: Rect2) -> void:
|
|||||||
_surface.draw_rect(Rect2(x, cy + i * gap - bh * 0.5, bw, bh), col)
|
_surface.draw_rect(Rect2(x, cy + i * gap - bh * 0.5, bw, bh), col)
|
||||||
|
|
||||||
|
|
||||||
|
# Live on-device profiler. Reads Godot's Performance monitors so we can see WHICH stage is slow
|
||||||
|
# during a lag spike instead of guessing: process(script) is main-thread GDScript, physics is the
|
||||||
|
# ragdoll/Jolt step, and low-both-but-low-FPS means the GPU (fill rate / resolution) is the wall.
|
||||||
|
# The verdict line names the bottleneck. Enable on the phone with ?debug=1 in the page URL.
|
||||||
func _draw_debug() -> void:
|
func _draw_debug() -> void:
|
||||||
var font := ThemeDB.fallback_font
|
var font := ThemeDB.fallback_font
|
||||||
if font == null:
|
if font == null:
|
||||||
return
|
return
|
||||||
var players := get_tree().get_nodes_in_group(&"player")
|
|
||||||
|
var fps: float = Performance.get_monitor(Performance.TIME_FPS)
|
||||||
|
var proc_ms: float = Performance.get_monitor(Performance.TIME_PROCESS) * 1000.0
|
||||||
|
var phys_ms: float = Performance.get_monitor(Performance.TIME_PHYSICS_PROCESS) * 1000.0
|
||||||
|
var draws: int = int(Performance.get_monitor(Performance.RENDER_TOTAL_DRAW_CALLS_IN_FRAME))
|
||||||
|
var objs: int = int(Performance.get_monitor(Performance.RENDER_TOTAL_OBJECTS_IN_FRAME))
|
||||||
|
var prims: int = int(Performance.get_monitor(Performance.RENDER_TOTAL_PRIMITIVES_IN_FRAME))
|
||||||
|
var node_n: int = int(Performance.get_monitor(Performance.OBJECT_NODE_COUNT))
|
||||||
|
var bodies: int = int(Performance.get_monitor(Performance.PHYSICS_3D_ACTIVE_OBJECTS))
|
||||||
|
var vmem: float = Performance.get_monitor(Performance.RENDER_VIDEO_MEM_USED) / 1048576.0
|
||||||
|
|
||||||
|
var now := Time.get_ticks_msec()
|
||||||
|
if proc_ms >= _peak_proc_ms or now - _peak_proc_t > _PEAK_HOLD_MS:
|
||||||
|
_peak_proc_ms = proc_ms
|
||||||
|
_peak_proc_t = now
|
||||||
|
if phys_ms >= _peak_phys_ms or now - _peak_phys_t > _PEAK_HOLD_MS:
|
||||||
|
_peak_phys_ms = phys_ms
|
||||||
|
_peak_phys_t = now
|
||||||
|
|
||||||
var mats := get_tree().get_nodes_in_group(&"matador")
|
var mats := get_tree().get_nodes_in_group(&"matador")
|
||||||
|
var rt := get_viewport().get_texture()
|
||||||
|
var res: Vector2i = rt.get_size() if rt != null else Vector2i.ZERO
|
||||||
|
|
||||||
var lines := [
|
var lines := [
|
||||||
"GPU: %s" % RenderingServer.get_video_adapter_name(),
|
"FPS %d frame %.1fms >> %s" % [
|
||||||
"chip: %s" % _unmasked_gpu(),
|
int(round(fps)), 1000.0 / maxf(fps, 1.0), _perf_verdict(fps, proc_ms, phys_ms)],
|
||||||
"API: %s" % RenderingServer.get_video_adapter_api_version(),
|
"process(script) %5.1fms peak %.1f" % [proc_ms, _peak_proc_ms],
|
||||||
"method=%s web=%s touch_ui=%s" % [
|
"physics(ragdoll) %5.1fms peak %.1f bodies %d" % [phys_ms, _peak_phys_ms, bodies],
|
||||||
ProjectSettings.get_setting("rendering/renderer/rendering_method", "?"),
|
"draws %d objs %d prims %s" % [draws, objs, _kfmt(prims)],
|
||||||
OS.has_feature("web"), Controls.use_touch_ui()],
|
"nodes %d matadors %d vmem %.0fMB" % [node_n, mats.size(), vmem],
|
||||||
"bull: %s" % _node_report(players),
|
"render %dx%d threads(iso)=%s" % [res.x, res.y, _iso_str()],
|
||||||
"matador(%d): %s" % [mats.size(), _node_report(mats)],
|
"chip: %s fs: %s" % [_unmasked_gpu(), _fs_status_throttled()],
|
||||||
"events=%d last=%s touches=%d stick=%s" % [
|
|
||||||
_dbg_events, str(_dbg_last_pos.round()), _touches.size(), _stick_active],
|
|
||||||
]
|
]
|
||||||
var fs := 18
|
|
||||||
|
var fsz := 18
|
||||||
var line_h := 24.0
|
var line_h := 24.0
|
||||||
var w := 0.0
|
var w := 0.0
|
||||||
for line: String in lines:
|
for line: String in lines:
|
||||||
w = maxf(w, font.get_string_size(line, HORIZONTAL_ALIGNMENT_LEFT, -1, fs).x)
|
w = maxf(w, font.get_string_size(line, HORIZONTAL_ALIGNMENT_LEFT, -1, fsz).x)
|
||||||
|
var top := clampf(get_viewport().get_visible_rect().size.y * 0.28, 96.0, 220.0)
|
||||||
_surface.draw_rect(
|
_surface.draw_rect(
|
||||||
Rect2(10.0, 34.0, w + 16.0, line_h * lines.size() + 12.0), Color(0.0, 0.0, 0.0, 0.62))
|
Rect2(10.0, top, w + 16.0, line_h * lines.size() + 12.0), Color(0.0, 0.0, 0.0, 0.72))
|
||||||
var y := 34.0 + 22.0
|
var y := top + 22.0
|
||||||
for line: String in lines:
|
for line: String in lines:
|
||||||
_surface.draw_string(
|
_surface.draw_string(
|
||||||
font, Vector2(18.0, y), line, HORIZONTAL_ALIGNMENT_LEFT, -1, fs,
|
font, Vector2(18.0, y), line, HORIZONTAL_ALIGNMENT_LEFT, -1, fsz, Color(1.0, 1.0, 0.35))
|
||||||
Color(1.0, 1.0, 0.3))
|
|
||||||
y += line_h
|
y += line_h
|
||||||
|
|
||||||
|
|
||||||
# One-line "does this character exist / is it drawable / where is it" report for the debug
|
# Names the current bottleneck from the frame breakdown. Directly tests the "CPU bound to one
|
||||||
# overlay, so an invisible bull/matador on-device can be pinned to spawn vs. position vs.
|
# thread" hypothesis: if process or physics ms is high, that's the single main thread saturated
|
||||||
# GPU-draw failure.
|
# and threads can't help it; if both are low while FPS is low, the GPU (fill rate) is the wall.
|
||||||
func _node_report(nodes: Array) -> String:
|
func _perf_verdict(fps: float, proc_ms: float, phys_ms: float) -> String:
|
||||||
if nodes.is_empty():
|
if fps >= 55.0:
|
||||||
return "NONE"
|
return "smooth"
|
||||||
var n := nodes[0] as Node3D
|
if phys_ms >= 6.0 and phys_ms >= proc_ms:
|
||||||
if n == null:
|
return "PHYSICS-bound (ragdolls, 1 thread)"
|
||||||
return "not Node3D"
|
if proc_ms >= 6.0:
|
||||||
return "vis=%s pos=%s" % [n.is_visible_in_tree(), str(n.global_position.round())]
|
return "SCRIPT-bound (GDScript, 1 thread)"
|
||||||
|
if proc_ms + phys_ms < 5.0:
|
||||||
|
return "GPU/fill-bound (resolution/shaders)"
|
||||||
|
return "mixed CPU"
|
||||||
|
|
||||||
|
|
||||||
|
func _kfmt(n: int) -> String:
|
||||||
|
if n >= 1000000:
|
||||||
|
return "%.1fM" % (n / 1000000.0)
|
||||||
|
if n >= 1000:
|
||||||
|
return "%.0fk" % (n / 1000.0)
|
||||||
|
return str(n)
|
||||||
|
|
||||||
|
|
||||||
|
# crossOriginIsolated: whether SharedArrayBuffer/threads are even permitted (needs COOP+COEP). Even
|
||||||
|
# when true, Godot's per-frame game logic stays single-threaded — threads only aid render/audio/load.
|
||||||
|
func _iso_str() -> String:
|
||||||
|
if not OS.has_feature("web"):
|
||||||
|
return "native"
|
||||||
|
if _iso_cached == -1:
|
||||||
|
var r: Variant = JavaScriptBridge.eval("window.crossOriginIsolated?1:0", true)
|
||||||
|
_iso_cached = 1 if ((r is bool and r) or ((r is int or r is float) and int(r) != 0)) else 0
|
||||||
|
return str(_iso_cached)
|
||||||
|
|
||||||
|
|
||||||
|
# fullscreen_status() does a JavaScriptBridge.eval; throttle it so the overlay isn't paying that
|
||||||
|
# cost (and skewing the process-ms it reports) every single frame.
|
||||||
|
func _fs_status_throttled() -> String:
|
||||||
|
var now := Time.get_ticks_msec()
|
||||||
|
if now - _fs_cache_t > 500 or _fs_cache == "?":
|
||||||
|
_fs_cache = Controls.fullscreen_status()
|
||||||
|
_fs_cache_t = now
|
||||||
|
return _fs_cache
|
||||||
|
|
||||||
|
|
||||||
func _button_style(accent: Color) -> StyleBoxFlat:
|
func _button_style(accent: Color) -> StyleBoxFlat:
|
||||||
@@ -484,6 +586,9 @@ func _button_style(accent: Color) -> StyleBoxFlat:
|
|||||||
func _return_to_menu() -> void:
|
func _return_to_menu() -> void:
|
||||||
get_tree().paused = false
|
get_tree().paused = false
|
||||||
Input.mouse_mode = Input.MOUSE_MODE_VISIBLE
|
Input.mouse_mode = Input.MOUSE_MODE_VISIBLE
|
||||||
|
# Stay fullscreen across menu ↔ game so tapping Play never lands you windowed; the player
|
||||||
|
# leaves fullscreen only via the browser's own back / swipe / Esc, and the start gate then
|
||||||
|
# re-appears so the next tap goes back in.
|
||||||
get_tree().change_scene_to_file("res://MainMenu.tscn")
|
get_tree().change_scene_to_file("res://MainMenu.tscn")
|
||||||
|
|
||||||
|
|
||||||
@@ -497,16 +602,55 @@ func _process(delta: float) -> void:
|
|||||||
if not active:
|
if not active:
|
||||||
if _surface.visible:
|
if _surface.visible:
|
||||||
_surface.visible = false
|
_surface.visible = false
|
||||||
if _stick_active:
|
_reset_touch_state()
|
||||||
_end_stick()
|
|
||||||
_touches.clear()
|
|
||||||
_touch_owner.clear()
|
|
||||||
_pinching = false
|
|
||||||
return
|
return
|
||||||
|
|
||||||
_surface.visible = true
|
_surface.visible = true
|
||||||
|
_prune_touch_state()
|
||||||
if _player == null:
|
if _player == null:
|
||||||
var players := get_tree().get_nodes_in_group(&"player")
|
var players := get_tree().get_nodes_in_group(&"player")
|
||||||
if not players.is_empty():
|
if not players.is_empty():
|
||||||
_player = players[0]
|
_player = players[0]
|
||||||
_surface.queue_redraw() # cooldown overlays animate every frame
|
_surface.queue_redraw() # cooldown overlays animate every frame
|
||||||
|
|
||||||
|
|
||||||
|
# Fully drop every trace of in-flight touch input: release held movement, clear ownership,
|
||||||
|
# end the stick, cancel any pinch. Used on stand-down, on relayout, and on focus/pause loss.
|
||||||
|
func _reset_touch_state() -> void:
|
||||||
|
_end_stick()
|
||||||
|
_release_move()
|
||||||
|
_touches.clear()
|
||||||
|
_touch_owner.clear()
|
||||||
|
for i: int in _slot_pressed.size():
|
||||||
|
_slot_pressed[i] = false
|
||||||
|
_pinching = false
|
||||||
|
_pinch_dist = 0.0
|
||||||
|
|
||||||
|
|
||||||
|
# Per-frame self-heal: drop ownership entries whose finger is no longer down, and stand the
|
||||||
|
# stick down if the finger driving it has lifted. Catches a release that slipped through
|
||||||
|
# without a matching event so state can't quietly rot into a stuck stick.
|
||||||
|
func _prune_touch_state() -> void:
|
||||||
|
if not _touch_owner.is_empty():
|
||||||
|
var orphans: Array = []
|
||||||
|
for idx: int in _touch_owner:
|
||||||
|
if not _touches.has(idx):
|
||||||
|
orphans.append(idx)
|
||||||
|
for idx: int in orphans:
|
||||||
|
var owner: Variant = _touch_owner[idx]
|
||||||
|
if owner is int:
|
||||||
|
_slot_pressed[owner] = false
|
||||||
|
if _stick_active and idx == _stick_index:
|
||||||
|
_end_stick()
|
||||||
|
_touch_owner.erase(idx)
|
||||||
|
if _stick_active and not _touches.has(_stick_index):
|
||||||
|
_end_stick()
|
||||||
|
|
||||||
|
|
||||||
|
func _notification(what: int) -> void:
|
||||||
|
if (
|
||||||
|
what == NOTIFICATION_APPLICATION_FOCUS_OUT
|
||||||
|
or what == NOTIFICATION_WM_WINDOW_FOCUS_OUT
|
||||||
|
or what == NOTIFICATION_APPLICATION_PAUSED
|
||||||
|
):
|
||||||
|
_reset_touch_state()
|
||||||
|
|||||||
@@ -0,0 +1,105 @@
|
|||||||
|
extends CanvasLayer
|
||||||
|
## "TAP TO PLAY" gate for the web/touch build (autoload `WebStartGate`). Browsers only allow
|
||||||
|
## requestFullscreen / screen.orientation.lock from inside a user gesture, so instead of
|
||||||
|
## piggybacking the first *gameplay* tap (which used to race the resize/orientation flip and
|
||||||
|
## strand the joystick — a dropped touchend left the stick stuck), we capture a dedicated tap
|
||||||
|
## on a full-screen panel BEFORE play. The tap drives Controls.request_fullscreen_landscape()
|
||||||
|
## and the gate dismisses, so all the fullscreen/orientation churn happens with no in-flight
|
||||||
|
## touch to lose.
|
||||||
|
##
|
||||||
|
## Re-armable, so the player is never stuck windowed: whenever we're NOT fullscreen (boot, or
|
||||||
|
## after they left via Esc / Android back / edge swipe) the gate re-appears and its tap goes
|
||||||
|
## back in — fullscreen otherwise persists across menu ↔ game, so tapping Play never lands you
|
||||||
|
## windowed. Where fullscreen is impossible (iPhone Safari has no requestFullscreen) it shows
|
||||||
|
## once and then stays out of the way instead of nagging. Only active on web touch; no-op else.
|
||||||
|
|
||||||
|
const _POLL: float = 0.4 # seconds between browser fullscreen-state polls
|
||||||
|
|
||||||
|
var _panel: ColorRect
|
||||||
|
var _active: bool = false # web + touch: the only case this gate does anything
|
||||||
|
var _fs_supported: bool = true
|
||||||
|
var _dismissed_unsupported: bool = false # iPhone: tapped once, don't show again
|
||||||
|
var _poll_accum: float = 0.0
|
||||||
|
|
||||||
|
|
||||||
|
func _ready() -> void:
|
||||||
|
layer = 127 # above HUD/touch UI, just below OrientationGuard (128) so "rotate" wins in portrait
|
||||||
|
process_mode = Node.PROCESS_MODE_ALWAYS
|
||||||
|
|
||||||
|
_active = OS.has_feature("web") and Controls.use_touch_ui()
|
||||||
|
if not _active:
|
||||||
|
return
|
||||||
|
_fs_supported = Controls.fullscreen_supported()
|
||||||
|
|
||||||
|
_panel = ColorRect.new()
|
||||||
|
_panel.color = Color(0.05, 0.03, 0.02, 0.94)
|
||||||
|
_panel.set_anchors_preset(Control.PRESET_FULL_RECT)
|
||||||
|
_panel.mouse_filter = Control.MOUSE_FILTER_STOP # swallow the tap from the game behind it
|
||||||
|
add_child(_panel)
|
||||||
|
|
||||||
|
var center := CenterContainer.new()
|
||||||
|
center.set_anchors_preset(Control.PRESET_FULL_RECT)
|
||||||
|
center.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||||
|
_panel.add_child(center)
|
||||||
|
|
||||||
|
var box := VBoxContainer.new()
|
||||||
|
box.alignment = BoxContainer.ALIGNMENT_CENTER
|
||||||
|
box.add_theme_constant_override("separation", 16)
|
||||||
|
center.add_child(box)
|
||||||
|
|
||||||
|
var title := Label.new()
|
||||||
|
title.text = "TAP TO PLAY"
|
||||||
|
title.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||||||
|
title.add_theme_font_size_override("font_size", 52)
|
||||||
|
title.add_theme_color_override("font_color", Color(1.0, 0.78, 0.22))
|
||||||
|
box.add_child(title)
|
||||||
|
|
||||||
|
var sub := Label.new()
|
||||||
|
sub.text = "Fullscreen, landscape"
|
||||||
|
sub.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||||||
|
sub.add_theme_font_size_override("font_size", 22)
|
||||||
|
sub.add_theme_color_override("font_color", Color(0.85, 0.85, 0.85))
|
||||||
|
box.add_child(sub)
|
||||||
|
|
||||||
|
|
||||||
|
func _input(event: InputEvent) -> void:
|
||||||
|
if not _active or not _panel.visible:
|
||||||
|
return
|
||||||
|
var gesture := (event is InputEventScreenTouch and (event as InputEventScreenTouch).pressed) \
|
||||||
|
or (event is InputEventMouseButton and (event as InputEventMouseButton).pressed)
|
||||||
|
if gesture:
|
||||||
|
# The native DOM listener (controls_manager.gd) enters fullscreen synchronously on this
|
||||||
|
# same tap; this is the belt-and-suspenders explicit call.
|
||||||
|
Controls.request_fullscreen_landscape()
|
||||||
|
_panel.visible = false
|
||||||
|
if not _fs_supported:
|
||||||
|
_dismissed_unsupported = true # iPhone: don't reappear, there's nothing to enter
|
||||||
|
get_viewport().set_input_as_handled()
|
||||||
|
|
||||||
|
|
||||||
|
func _process(delta: float) -> void:
|
||||||
|
if not _active:
|
||||||
|
return
|
||||||
|
_poll_accum += delta
|
||||||
|
if _poll_accum < _POLL:
|
||||||
|
return
|
||||||
|
_poll_accum = 0.0
|
||||||
|
|
||||||
|
# Portrait: OrientationGuard owns the screen — stand down until they turn the device.
|
||||||
|
if _is_portrait():
|
||||||
|
_panel.visible = false
|
||||||
|
return
|
||||||
|
if not _fs_supported:
|
||||||
|
# Can't ever enter fullscreen (iPhone) — offer the tap once, then get out of the way.
|
||||||
|
_panel.visible = not _dismissed_unsupported
|
||||||
|
return
|
||||||
|
# Show the gate whenever we're not fullscreen, and arm so the next tap (re-)enters.
|
||||||
|
var fs := Controls.is_browser_fullscreen()
|
||||||
|
_panel.visible = not fs
|
||||||
|
if not fs:
|
||||||
|
Controls.arm_fullscreen()
|
||||||
|
|
||||||
|
|
||||||
|
func _is_portrait() -> bool:
|
||||||
|
var s := get_viewport().get_visible_rect().size
|
||||||
|
return s.y > s.x
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://esiec4is2ap5
|
||||||
Reference in New Issue
Block a user