mobile support tweaks
This commit is contained in:
@@ -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"])
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
@@ -0,0 +1 @@
|
||||
uid://cmh7kd85wi5gx
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
uid://dymd8vcfmtjts
|
||||
@@ -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()
|
||||
@@ -0,0 +1 @@
|
||||
uid://dvkw2a6cf7848
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
uid://dhu2og7i2gdfa
|
||||
@@ -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")
|
||||
@@ -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", "?"),
|
||||
|
||||
Reference in New Issue
Block a user