mobile support tweaks

This commit is contained in:
2026-08-23 20:32:52 +03:00
parent 008650a87e
commit ccc92f201f
15 changed files with 380 additions and 0 deletions
+7
View File
@@ -78,6 +78,9 @@ var _claw_l: CPUParticles3D = null
var _claw_r: CPUParticles3D = null
const RigidSkin = preload("res://rigid_skin.gd")
func _ready() -> void:
add_to_group(&"matador") # so every existing bull ability already targets the bear
add_to_group(&"bear")
@@ -85,6 +88,10 @@ func _ready() -> void:
health_changed.emit(_hp, _hp)
_skeleton = _find_skeleton(_mesh)
_anim_player = _find_anim_player(_mesh)
# Web (Mali/ANGLE) can't run Compatibility vertex-skinning; rebuild the bear as
# non-skinned bone-attached pieces there. Desktop keeps smooth GPU skinning.
if OS.has_feature("web"):
RigidSkin.convert_tree(self)
if _anim_player:
_anim_idle = _resolve_anim(["IDLE_ON4LEGS", "IDLE"])
_anim_run = _resolve_anim(["RUN_ON4LEGS", "RUN"])
+30
View File
@@ -25,6 +25,36 @@ func _ready() -> void:
load_saved()
# ── Web fullscreen + landscape lock ─────────────────────────────────────────────
# Browsers only allow requestFullscreen / screen.orientation.lock from inside a user
# gesture, so we piggyback on the first tap/click of the web build to go fullscreen and
# lock to landscape. One-shot; input still flows to the menu/game normally (never consumed).
var _fs_triggered: bool = false
func _input(event: InputEvent) -> void:
if _fs_triggered or not OS.has_feature("web"):
return
var gesture := (event is InputEventScreenTouch and (event as InputEventScreenTouch).pressed) \
or (event is InputEventMouseButton and (event as InputEventMouseButton).pressed)
if gesture:
_fs_triggered = true
request_fullscreen_landscape()
## Enter browser fullscreen and lock to landscape. Must be called from a user-gesture
## context (first tap, or a button press). No-op off web. Android Chrome/Brave honour the
## orientation lock; iOS Safari ignores it (unsupported), so the .catch() swallows the reject.
func request_fullscreen_landscape() -> void:
if not OS.has_feature("web"):
return
DisplayServer.window_set_mode(DisplayServer.WINDOW_MODE_FULLSCREEN)
JavaScriptBridge.eval(
"(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)
## Whether to drive the game with the on-screen touch UI (joystick + buttons)
## instead of keyboard hints. Platform detection is cached (it can't change mid-run)
## so the per-frame/per-input callers don't repay the cost — but the DP force flag is
+7
View File
@@ -93,12 +93,19 @@ const _TURN_FACE: float = 16.0 # face the bull while engaging
const _TURN_SHARP: float = 24.0 # snap onto a sidestep / roll direction
const RigidSkin = preload("res://rigid_skin.gd")
func _ready() -> void:
add_to_group(&"matador")
_skeleton = _find_skeleton(_mesh)
_anim_player = _find_anim_player(_mesh)
if _skeleton:
_sim = MatadorRagdoll.build(_skeleton)
# Web (Mali/ANGLE) can't run Compatibility vertex-skinning; rebuild as non-skinned
# bone-attached pieces. Runs after the ragdoll sim is built (both drive the same bones).
if OS.has_feature("web"):
RigidSkin.convert_tree(self)
if _anim_player:
for anim in [_ANIM_RUN, _ANIM_ATTACK] + _ANIM_TAUNTS:
_ensure_loop(anim)
+63
View File
@@ -0,0 +1,63 @@
extends CanvasLayer
## Universal "rotate to landscape" prompt (autoload `OrientationGuard`). The cross-device
## half of landscape enforcement: screen.orientation.lock (controls_manager.gd) handles
## Android, but iOS Safari has no lock API, and some browsers only lock in fullscreen — so
## wherever the device ends up portrait, we simply ask the player to turn it. No canvas
## rotation or input remapping (those break touch coordinates), so it works on every phone
## and browser cleanly. Only ever shows on touch-primary devices, only while portrait.
var _panel: ColorRect
func _ready() -> void:
layer = 128 # above HUD, touch UI, everything
process_mode = Node.PROCESS_MODE_ALWAYS
_panel = ColorRect.new()
_panel.color = Color(0.05, 0.03, 0.02, 0.97)
_panel.set_anchors_preset(Control.PRESET_FULL_RECT)
_panel.mouse_filter = Control.MOUSE_FILTER_STOP # swallow taps to the game behind it
_panel.visible = false
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", 14)
center.add_child(box)
var title := Label.new()
title.text = "Rotate your device"
title.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
title.add_theme_font_size_override("font_size", 44)
title.add_theme_color_override("font_color", Color(1.0, 0.78, 0.22))
box.add_child(title)
var sub := Label.new()
sub.text = "Turn it sideways to play in 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)
get_viewport().size_changed.connect(_update)
_update.call_deferred()
func _update() -> void:
_panel.visible = _should_guard() and _is_portrait()
func _is_portrait() -> bool:
var s := get_viewport().get_visible_rect().size
return s.y > s.x
# Touch-primary devices only — never a desktop browser with a tall window (they can't rotate
# a monitor). Reuses the same conservative detection the touch UI uses.
func _should_guard() -> bool:
return Controls.use_touch_ui()
+1
View File
@@ -0,0 +1 @@
uid://cmh7kd85wi5gx
+8
View File
@@ -79,8 +79,16 @@ var _walk_dust_ramp: Gradient = null
var _charge_dust_ramp: Gradient = null
const RigidSkin = preload("res://rigid_skin.gd")
func _ready() -> void:
add_to_group(&"player")
# Mali/ANGLE mobile GPUs can't run Godot's Compatibility vertex-skinning (transform
# feedback) — skinned meshes render invisible on the web build. Rebuild the bull as
# non-skinned bone-attached pieces there; desktop keeps smooth GPU skinning.
if OS.has_feature("web"):
RigidSkin.convert_tree(self)
_max_hp = maxi(1, int(DP.f("bull_max_hp")))
_hp = _max_hp
_cube_scale = cube_guy.scale
+2
View File
@@ -27,10 +27,12 @@ Controls="*res://controls_manager.gd"
DebugDraw="*res://debug_draw.gd"
Settings="*res://settings.gd"
Run="*res://levels/run_state.gd"
OrientationGuard="*res://orientation_guard.gd"
[display]
window/size/mode=3
window/handheld/orientation=4
[editor_plugins]
+128
View File
@@ -0,0 +1,128 @@
extends RefCounted
## Converts Skeleton3D-skinned meshes into NON-skinned mesh pieces parented to
## BoneAttachment3D nodes, so rendering never uses GPU vertex-skinning (transform
## feedback). Required on ANGLE/Vulkan/Mali mobile GPUs (e.g. Mali-G720 in mobile
## Chrome/Brave), where Godot's Compatibility skinning path renders skinned meshes
## invisible while non-skinned meshes draw fine.
##
## Each triangle is assigned wholesale to its dominant bone (highest summed weight),
## so segments stay watertight; joints become rigid (no smooth bending), which suits
## the low-poly look. Call RigidSkin.convert_tree(character_root) once after the scene
## is instanced (bones don't need to be posed yet — pieces are baked in bind space).
static func convert_tree(root: Node) -> int:
var converted := 0
for skel: Skeleton3D in _find(root, "Skeleton3D", []):
for mi: Node in skel.get_children():
if mi is MeshInstance3D and (mi as MeshInstance3D).skin != null \
and (mi as MeshInstance3D).mesh is ArrayMesh:
if _convert(skel, mi as MeshInstance3D):
converted += 1
return converted
static func _find(n: Node, klass: String, acc: Array) -> Array:
if n.is_class(klass):
acc.append(n)
for c: Node in n.get_children():
_find(c, klass, acc)
return acc
static func _convert(skel: Skeleton3D, mi: MeshInstance3D) -> bool:
var mesh := mi.mesh as ArrayMesh
var skin := mi.skin
# bind index -> (bone index, bind pose = mesh-space -> bone-local)
var bind_bone: PackedInt32Array = []
var bind_pose: Array[Transform3D] = []
for b: int in skin.get_bind_count():
var bone := skin.get_bind_bone(b)
if bone < 0:
bone = skel.find_bone(skin.get_bind_name(b))
bind_bone.append(bone)
bind_pose.append(skin.get_bind_pose(b))
# Accumulate SurfaceTool geometry per destination bone.
var st_by_bone: Dictionary = {} # bone index -> SurfaceTool
var mat_by_bone: Dictionary = {} # bone index -> Material (first seen)
for s: int in mesh.get_surface_count():
var arr := mesh.surface_get_arrays(s)
var verts: PackedVector3Array = arr[Mesh.ARRAY_VERTEX]
var norms: PackedVector3Array = arr[Mesh.ARRAY_NORMAL]
var uvs: PackedVector2Array = arr[Mesh.ARRAY_TEX_UV] if arr[Mesh.ARRAY_TEX_UV] != null else PackedVector2Array()
var cols: PackedColorArray = arr[Mesh.ARRAY_COLOR] if arr[Mesh.ARRAY_COLOR] != null else PackedColorArray()
var bones: PackedInt32Array = arr[Mesh.ARRAY_BONES]
var weights: PackedFloat32Array = arr[Mesh.ARRAY_WEIGHTS]
var idx: PackedInt32Array = arr[Mesh.ARRAY_INDEX]
var 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 tri := PackedInt32Array()
if idx != null and idx.size() > 0:
tri = idx
else:
tri.resize(verts.size())
for i: int in verts.size():
tri[i] = i
for t: int in range(0, tri.size(), 3):
var a := tri[t]
var b := tri[t + 1]
var c := tri[t + 2]
var bind := _dominant_bind([a, b, c], bones, weights, infl)
var bone := bind_bone[bind]
var pose := bind_pose[bind]
var st: SurfaceTool = st_by_bone.get(bone)
if st == null:
st = SurfaceTool.new()
st.begin(Mesh.PRIMITIVE_TRIANGLES)
st_by_bone[bone] = st
mat_by_bone[bone] = mat
for v: int in [a, b, c]:
if cols.size() > v:
st.set_color(cols[v])
if uvs.size() > v:
st.set_uv(uvs[v])
if norms.size() > v:
st.set_normal((pose.basis * norms[v]).normalized())
st.add_vertex(pose * verts[v])
if st_by_bone.is_empty():
return false
for bone: int in st_by_bone:
var att := BoneAttachment3D.new()
att.bone_name = skel.get_bone_name(bone)
skel.add_child(att)
var piece := MeshInstance3D.new()
var st: SurfaceTool = st_by_bone[bone]
var m := st.commit()
if mat_by_bone[bone] != null:
m.surface_set_material(0, mat_by_bone[bone])
piece.mesh = m
att.add_child(piece)
# Hide (don't free) the original: keeps the node for any gameplay code that references
# it, and a hidden skinned mesh isn't drawn so it never hits the transform-feedback path.
mi.visible = false
return true
static func _dominant_bind(vs: Array, bones: PackedInt32Array, weights: PackedFloat32Array, infl: int) -> int:
var acc: Dictionary = {}
for v: int in vs:
for k: int in infl:
var w := weights[v * infl + k]
if w <= 0.0:
continue
var bind := bones[v * infl + k]
acc[bind] = float(acc.get(bind, 0.0)) + w
var best := 0
var best_w := -1.0
for bind: int in acc:
if acc[bind] > best_w:
best_w = acc[bind]
best = bind
return best
+1
View File
@@ -0,0 +1 @@
uid://dymd8vcfmtjts
+66
View File
@@ -0,0 +1,66 @@
extends SceneTree
## Repro harness for the "bull + matador invisible on phone" bug. Loads the shell with the
## arena level, waits for spawn, prints vis/pos for the skinned characters and dumps a real
## screenshot so the Compatibility (mobile/web) render path can be compared to Forward+.
## godot --rendering-method gl_compatibility --script tests/mobile_render_repro.gd
## godot --rendering-method forward_plus --script tests/mobile_render_repro.gd
func _init() -> void:
_run.call_deferred()
func _report(nodes: Array, label: String) -> void:
if nodes.is_empty():
print(" ", label, ": NONE")
return
var n := nodes[0] as Node3D
print(" ", label, ": vis=", n.is_visible_in_tree(), " pos=", str(n.global_position.round()))
# Walk to the skinned MeshInstance3D and report its state + AABB.
_dump_meshes(n, " ")
func _dump_meshes(node: Node, indent: String) -> void:
if node is MeshInstance3D:
var mi := node as MeshInstance3D
var aabb := mi.get_aabb()
var lod_count := 0
var surf := 0
if mi.mesh != null:
surf = mi.mesh.get_surface_count()
print(indent, "MeshInstance3D '", mi.name, "' vis=", mi.visible,
" skin=", mi.skin != null, " gscale=", str(mi.global_transform.basis.get_scale()),
"\n", indent, " aabb_pos=", aabb.position, " aabb_size=", aabb.size,
" custom_aabb=", mi.custom_aabb.size, " surfaces=", surf)
for c: Node in node.get_children():
_dump_meshes(c, indent)
func _run() -> void:
# Compatibility (OpenGL) has no RenderingDevice; Forward+/Mobile do. This reflects the
# renderer actually chosen by --rendering-method, unlike the static project setting.
var method := "gl_compatibility" if RenderingServer.get_rendering_device() == null else "forward_plus"
print("=== RENDER REPRO method=", method, " gpu=", RenderingServer.get_video_adapter_name(), " ===")
var run: Node = root.get_node("Run")
run.ensure_run()
run.debug_level_override = run.get_level(&"arena")
var scene: Node = load("res://scene.tscn").instantiate()
root.add_child(scene)
current_scene = scene
await create_timer(1.2).timeout
print("bull + matador state:")
_report(get_nodes_in_group(&"player"), "bull")
_report(get_nodes_in_group(&"matador"), "matador")
# Force a couple more draw frames, then grab the framebuffer.
for i in 4:
await process_frame
var img := root.get_viewport().get_texture().get_image()
var out := "tests/output/render_%s.png" % method
DirAccess.make_dir_recursive_absolute("res://tests/output")
img.save_png("res://" + out)
print("screenshot -> ", out, " (", img.get_width(), "x", img.get_height(), ")")
quit()
+1
View File
@@ -0,0 +1 @@
uid://dvkw2a6cf7848
+32
View File
@@ -0,0 +1,32 @@
extends Node3D
## Minimal skinned-mesh probe for the web/mobile invisibility repro. Boots straight to a
## single bull (Skeleton3D-skinned) framed by a camera + light, no menus, no autoload deps.
## Set as main_scene, exported to web, and screenshotted in a browser to test whether the
## skinned mesh draws under a given engine build.
func _ready() -> void:
var bull := (load("res://Assets/bull.fbx") as PackedScene).instantiate() as Node3D
bull.scale = Vector3(100.0, 100.0, 100.0)
add_child(bull)
var key := DirectionalLight3D.new()
key.rotation_degrees = Vector3(-50.0, -40.0, 0.0)
key.light_energy = 1.5
add_child(key)
var we := WorldEnvironment.new()
var env := Environment.new()
env.background_mode = Environment.BG_COLOR
env.background_color = Color(0.15, 0.35, 0.65)
env.ambient_light_source = Environment.AMBIENT_SOURCE_COLOR
env.ambient_light_color = Color(0.6, 0.6, 0.6)
env.ambient_light_energy = 1.0
we.environment = env
add_child(we)
var cam := Camera3D.new()
cam.position = Vector3(0.0, 2.0, 6.0)
cam.fov = 55.0
add_child(cam)
cam.look_at(Vector3(0.0, 1.2, 0.0), Vector3.UP)
cam.current = true
+1
View File
@@ -0,0 +1 @@
uid://dhu2og7i2gdfa
+6
View File
@@ -0,0 +1,6 @@
[gd_scene load_steps=2 format=3 uid="uid://cwebprobe0001"]
[ext_resource type="Script" path="res://tests/web_probe.gd" id="1"]
[node name="WebProbe" type="Node3D"]
script = ExtResource("1")
+27
View File
@@ -88,6 +88,32 @@ var _pinch_dist: float = 0.0
var _dbg_events: int = 0
var _dbg_last_pos: Vector2 = Vector2.ZERO
# 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
# whether an invisible skinned mesh is a known mobile-driver bug — is only reachable via
# the WEBGL_debug_renderer_info extension on Godot's own canvas context. "" = not yet probed.
var _gpu_unmasked: String = ""
func _unmasked_gpu() -> String:
if _gpu_unmasked != "":
return _gpu_unmasked
_gpu_unmasked = "n/a"
if OS.has_feature("web"):
var js := """(function(){try{
var c=document.getElementById('canvas');
var gl=c?c.getContext('webgl2'):null;
if(!gl){var t=document.createElement('canvas');gl=t.getContext('webgl2')||t.getContext('webgl');}
if(!gl)return 'no-gl';
var e=gl.getExtension('WEBGL_debug_renderer_info');
if(!e)return 'masked';
return String(gl.getParameter(e.UNMASKED_RENDERER_WEBGL));
}catch(err){return 'err';}})()"""
var r: Variant = JavaScriptBridge.eval(js, true)
if r is String and (r as String) != "":
_gpu_unmasked = r
return _gpu_unmasked
func _ready() -> void:
layer = 10 # below the HUD (11) so the game-over screen always draws on top
@@ -406,6 +432,7 @@ func _draw_debug() -> void:
var mats := get_tree().get_nodes_in_group(&"matador")
var lines := [
"GPU: %s" % RenderingServer.get_video_adapter_name(),
"chip: %s" % _unmasked_gpu(),
"API: %s" % RenderingServer.get_video_adapter_api_version(),
"method=%s web=%s touch_ui=%s" % [
ProjectSettings.get_setting("rendering/renderer/rendering_method", "?"),