Reorganize into role-based folders and consolidate art
Move every script and scene into role-based directories (actors/, systems/, ui/, levels/, props/) and all raw art under art/ (grouped by role, snake_case, Extract-Textures GLBs), dropping Blender version-suffix noise. Filenames are snake_case (PascalCase reserved for class_name) to avoid case-insensitive filesystem aliasing on the web/macOS targets. Routes the bull's hits through the typed DamageEvent pipeline and wires the bull's abilities to the Fx library. Updates CLAUDE.md's folder-layout overview to match. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CCyaJPTHCH9EDthpf4A5Sj
This commit is contained in:
@@ -0,0 +1,16 @@
|
||||
shader_type canvas_item;
|
||||
|
||||
// Faint red gradient hugging the screen edges, flashed on a hit. `flash` is tweened
|
||||
// 0.9 → 0 by the HUD; the vignette is transparent in the centre and reddens toward
|
||||
// the corners so the middle of the screen stays clear.
|
||||
uniform float flash : hint_range(0.0, 1.0) = 0.0;
|
||||
uniform vec3 tint : source_color = vec3(0.75, 0.0, 0.0);
|
||||
uniform float inner : hint_range(0.0, 1.0) = 0.45;
|
||||
uniform float outer : hint_range(0.0, 1.0) = 1.0;
|
||||
|
||||
void fragment() {
|
||||
// Distance from centre normalised so the corners land at ~1.0.
|
||||
float dist = length(UV - vec2(0.5)) / 0.70710678;
|
||||
float v = smoothstep(inner, outer, dist);
|
||||
COLOR = vec4(tint, v * flash);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
uid://dys23pu1kw5fl
|
||||
@@ -0,0 +1,33 @@
|
||||
class_name GameVersion
|
||||
extends RefCounted
|
||||
## Single source of truth for the game's version string.
|
||||
##
|
||||
## The numeric version lives in project.godot (application/config/version) so it
|
||||
## also feeds export metadata; here we read it back and layer on the pre-release
|
||||
## CHANNEL and a human-readable STAGE label. We're pre-1.0 — every 0.x build is
|
||||
## understood to be unstable, in-development software (see the recommendations in
|
||||
## the commit / PR notes for why 0.x is the convention for early game dev).
|
||||
|
||||
## Pre-release channel appended to the number, SemVer-style (e.g. 0.1.0-alpha).
|
||||
const CHANNEL: String = "alpha"
|
||||
|
||||
## Friendly stage banner shown to players so it's unmistakably an early build.
|
||||
const STAGE: String = "Early Development Build"
|
||||
|
||||
|
||||
## Bare numeric version, e.g. "0.1.0". Read from project settings.
|
||||
static func number() -> String:
|
||||
return str(ProjectSettings.get_setting("application/config/version", "0.0.0"))
|
||||
|
||||
|
||||
## Full version tag, e.g. "v0.1.0-alpha".
|
||||
static func string() -> String:
|
||||
var s := "v" + number()
|
||||
if not CHANNEL.is_empty():
|
||||
s += "-" + CHANNEL
|
||||
return s
|
||||
|
||||
|
||||
## Version tag + stage banner, e.g. "v0.1.0-alpha · Early Development Build".
|
||||
static func full() -> String:
|
||||
return "%s · %s" % [string(), STAGE]
|
||||
@@ -0,0 +1 @@
|
||||
uid://f4bvu1mnvpiu
|
||||
@@ -0,0 +1,886 @@
|
||||
extends CanvasLayer
|
||||
|
||||
const _GOLD := Color(1.00, 0.78, 0.22)
|
||||
const _CREAM := Color(0.93, 0.88, 0.72)
|
||||
const _MUTED := Color(0.66, 0.60, 0.46)
|
||||
|
||||
# Per-ability trim colour, tying each tile (and its key cap) to that ability's FX.
|
||||
# Order matches _ABILITY_ICONS: kick, dash, slam, roll.
|
||||
const _ABILITY_ACCENT: Array[Color] = [
|
||||
Color(1.00, 0.62, 0.16), # Kick — ember orange
|
||||
Color(0.45, 0.75, 1.00), # Dash — sky blue
|
||||
Color(0.98, 0.34, 0.20), # Slam — crimson
|
||||
Color(1.00, 0.82, 0.32), # Roll — gold
|
||||
]
|
||||
|
||||
const _ABILITY_ICONS: Array[String] = [
|
||||
"res://art/icons/kick.png",
|
||||
"res://art/icons/dash.png",
|
||||
"res://art/icons/slam.png",
|
||||
"res://art/icons/roll.png",
|
||||
]
|
||||
const _ABILITY_ACTIONS: Array[StringName] = [
|
||||
&"ability_kick", &"ability_dash", &"ability_slam", &"ability_roll",
|
||||
]
|
||||
# Fallback label shown in a slot whose icon file fails to load.
|
||||
const _ABILITY_NAMES: Array[String] = ["Kick", "Dash", "Slam", "Roll"]
|
||||
|
||||
var _controls_visible: bool = false
|
||||
var _controls_panel: PanelContainer
|
||||
var _toggle_btn: Button
|
||||
var _tab_hint: Label
|
||||
var _spawner: Node
|
||||
var _player: Node = null
|
||||
var _died_connected: bool = false
|
||||
var _cd_overlays: Array[ColorRect] = []
|
||||
var _cd_labels: Array[Label] = []
|
||||
var _hp_label: Label
|
||||
var _hp_segments: HBoxContainer
|
||||
var _hp_seg_fills: Array[ColorRect] = []
|
||||
var _bottom_col: VBoxContainer
|
||||
var _ability_bar: HBoxContainer
|
||||
var _ability_panels: Array[PanelContainer] = []
|
||||
var _bull_bg: TextureRect
|
||||
|
||||
# Lobby barrel-smash reward — tally the barrels present on load; when the last one bursts,
|
||||
# throw confetti + a "WOooOoW!" and grant the bull two yellow bonus pips. Latched so it
|
||||
# fires once per lobby visit.
|
||||
var _barrels_remaining: int = 0
|
||||
var _barrel_reward_given: bool = false
|
||||
const _BARREL_BONUS_HP: int = 2
|
||||
|
||||
# Red damage vignette — a full-screen shader ColorRect flashed on every hit.
|
||||
const _VIGNETTE_SHADER := "res://ui/damage_vignette.gdshader"
|
||||
var _vignette: ColorRect
|
||||
var _vignette_mat: ShaderMaterial
|
||||
|
||||
# Bull-head backdrop for the gameplay HUD. The image is a wide horned silhouette; its
|
||||
# solid "face" occupies the centred middle band, with horns flaring above. We size the
|
||||
# image so that face band wraps the ability+HP cluster (horns crowning it above).
|
||||
const _BULL_BG := "res://art/ui/bull_hud_bg.png"
|
||||
const _BULL_FACE_W: float = 0.529 # face width as a fraction of image width
|
||||
const _BULL_ASPECT: float = 0.2741 # image height / width (267 / 974)
|
||||
const _BULL_FACE_PAD: float = 1.15 # breathing room around the cluster within the face
|
||||
|
||||
# Win/lose screen. _game_over latches so the result is shown once; _result_win
|
||||
# records which way it went (read by the gameplay test).
|
||||
const _WIN_VIDEO := "res://videos/bull_win.ogv" # the bull triumphs
|
||||
const _LOSS_VIDEO := "res://videos/matador_win.ogv" # the matador triumphs
|
||||
|
||||
# Beat between the run ending and the outcome screen. A loss is a short freeze on the
|
||||
# dead bull; a win gives the player a longer stretch to strut a victory lap around the
|
||||
# arena before the screen (and the pause) takes over.
|
||||
const _LOSS_DELAY := 2.0
|
||||
const _WIN_DELAY := 3.0
|
||||
|
||||
var _game_over: bool = false
|
||||
var _result_win: bool = false
|
||||
var _over_root: Control
|
||||
var _over_video: VideoStreamPlayer
|
||||
var _over_label: Label
|
||||
var _over_continue: Button
|
||||
var _over_again: Button
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
layer = 11
|
||||
process_mode = Node.PROCESS_MODE_ALWAYS
|
||||
_build_controls_panel()
|
||||
_build_tab_hint()
|
||||
_build_bottom_hud()
|
||||
_build_bull_hud_bg()
|
||||
_build_ability_bar()
|
||||
_build_health()
|
||||
_build_version_label()
|
||||
_build_damage_vignette()
|
||||
_build_game_over()
|
||||
_build_touch_controls()
|
||||
_build_mobile_hud()
|
||||
_update_control_hints()
|
||||
_find_spawner.call_deferred()
|
||||
_watch_barrels.call_deferred()
|
||||
|
||||
|
||||
# On-screen joystick + ability buttons for touch devices. Self-gating: the overlay
|
||||
# only shows itself when a touchscreen is present (or DP force_touch_controls is on),
|
||||
# so this is a no-op on desktop.
|
||||
func _build_touch_controls() -> void:
|
||||
add_child((load("res://systems/input/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://ui/mobile_hud.gd") as Script).new())
|
||||
|
||||
|
||||
# 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.
|
||||
func _build_bottom_hud() -> void:
|
||||
_bottom_col = VBoxContainer.new()
|
||||
_bottom_col.add_theme_constant_override("separation", 8)
|
||||
_bottom_col.alignment = BoxContainer.ALIGNMENT_END
|
||||
# Collapsed horizontal anchors so the column shrinks to its content and stays
|
||||
# bottom-centred; the bull backdrop is sized against this content rect.
|
||||
_bottom_col.anchor_left = 0.5
|
||||
_bottom_col.anchor_right = 0.5
|
||||
_bottom_col.anchor_top = 1.0
|
||||
_bottom_col.anchor_bottom = 1.0
|
||||
_bottom_col.grow_horizontal = Control.GROW_DIRECTION_BOTH
|
||||
_bottom_col.grow_vertical = Control.GROW_DIRECTION_BEGIN
|
||||
_bottom_col.offset_bottom = -16.0
|
||||
_bottom_col.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
add_child(_bottom_col)
|
||||
|
||||
|
||||
# Bull-head plate that sits behind the ability + health cluster. It's a sibling of the
|
||||
# cluster (not a parent) so it never constrains the cluster's layout; we size and place
|
||||
# it against the cluster's rect in _position_bull_bg.
|
||||
# Skipped on touch/phone: the on-screen thumb cluster already crowds the bottom edge, so
|
||||
# the wide horned plate just adds clutter there.
|
||||
func _build_bull_hud_bg() -> void:
|
||||
if Controls.use_touch_ui():
|
||||
return
|
||||
_bull_bg = TextureRect.new()
|
||||
if ResourceLoader.exists(_BULL_BG):
|
||||
_bull_bg.texture = load(_BULL_BG)
|
||||
# We size the rect ourselves to the image's aspect, so STRETCH_SCALE fills it
|
||||
# exactly with no letterboxing.
|
||||
_bull_bg.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
|
||||
_bull_bg.stretch_mode = TextureRect.STRETCH_SCALE
|
||||
_bull_bg.modulate.a = 0.95
|
||||
_bull_bg.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
add_child(_bull_bg)
|
||||
# Draw the plate behind the cluster.
|
||||
move_child(_bull_bg, _bottom_col.get_index())
|
||||
|
||||
_bottom_col.resized.connect(_position_bull_bg)
|
||||
get_viewport().size_changed.connect(_position_bull_bg)
|
||||
_position_bull_bg.call_deferred()
|
||||
|
||||
|
||||
# Wrap the bull's solid "face" band around the current cluster rect: scale the image up
|
||||
# so its face spans the cluster (plus padding), centre it horizontally on the cluster,
|
||||
# and pin its bottom edge to the bottom of the screen so the plate is always seated flush
|
||||
# against the screen edge on any resolution (horns crowning the cluster above).
|
||||
func _position_bull_bg() -> void:
|
||||
if _bull_bg == null or _bottom_col == null:
|
||||
return
|
||||
var r := _bottom_col.get_global_rect()
|
||||
if r.size.x <= 1.0:
|
||||
return
|
||||
var vp_h := get_viewport().get_visible_rect().size.y
|
||||
var bw := (r.size.x * _BULL_FACE_PAD) / _BULL_FACE_W
|
||||
var bh := bw * _BULL_ASPECT
|
||||
_bull_bg.size = Vector2(bw, bh)
|
||||
_bull_bg.position = Vector2(
|
||||
r.position.x + r.size.x * 0.5 - bw * 0.5,
|
||||
vp_h - bh)
|
||||
|
||||
|
||||
# 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:
|
||||
var lbl := Label.new()
|
||||
lbl.text = GameVersion.full()
|
||||
lbl.add_theme_color_override("font_color", Color(0.90, 0.86, 0.72))
|
||||
lbl.add_theme_color_override("font_outline_color", Color(0, 0, 0, 0.85))
|
||||
lbl.add_theme_constant_override("outline_size", 4)
|
||||
lbl.add_theme_font_size_override("font_size", 16)
|
||||
lbl.grow_vertical = Control.GROW_DIRECTION_BEGIN
|
||||
lbl.offset_bottom = -10.0
|
||||
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)
|
||||
|
||||
|
||||
func _process(_delta: float) -> void:
|
||||
_update_control_hints()
|
||||
if _player == null:
|
||||
var players := get_tree().get_nodes_in_group(&"player")
|
||||
if not players.is_empty():
|
||||
_player = players[0]
|
||||
return
|
||||
if not _died_connected:
|
||||
_died_connected = true
|
||||
_player.died.connect(_on_player_died)
|
||||
_player.health_changed.connect(_on_health_changed)
|
||||
_player.hit_taken.connect(_on_hit_taken)
|
||||
_on_health_changed(_player.call(&"get_hp"), _player.call(&"get_max_hp"))
|
||||
for i: int in _cd_overlays.size():
|
||||
var fraction: float = _player.call(&"ability_cooldown_fraction", i)
|
||||
var ov := _cd_overlays[i]
|
||||
ov.anchor_top = 1.0 - fraction
|
||||
ov.anchor_bottom = 1.0
|
||||
var remaining: float = _player.call(&"ability_cooldown_remaining", i)
|
||||
var lbl := _cd_labels[i]
|
||||
if remaining > 0.0:
|
||||
lbl.text = "%.1fs" % remaining
|
||||
lbl.visible = true
|
||||
else:
|
||||
lbl.visible = false
|
||||
|
||||
|
||||
func _find_spawner() -> void:
|
||||
var spawners := get_tree().get_nodes_in_group(&"matador_spawn")
|
||||
if spawners.is_empty():
|
||||
return
|
||||
_spawner = spawners[0]
|
||||
_spawner.all_defeated.connect(_on_all_defeated)
|
||||
|
||||
|
||||
# ── Lobby barrel-smash reward ───────────────────────────────────────────────────
|
||||
|
||||
# Tally the barrels the current level loaded with and listen for each one bursting. Only
|
||||
# the lobby has barrels, so on every other level this simply finds none and stands down.
|
||||
func _watch_barrels() -> void:
|
||||
var barrels := get_tree().get_nodes_in_group(&"barrel")
|
||||
_barrels_remaining = barrels.size()
|
||||
if _barrels_remaining == 0:
|
||||
return
|
||||
for barrel: Node in barrels:
|
||||
barrel.destroyed.connect(_on_barrel_destroyed)
|
||||
|
||||
|
||||
func _on_barrel_destroyed() -> void:
|
||||
_barrels_remaining -= 1
|
||||
if _barrels_remaining > 0 or _barrel_reward_given:
|
||||
return
|
||||
_barrel_reward_given = true
|
||||
# Bank the reward run-wide (capped at +2). Only heal the bull for the pips that were
|
||||
# actually added — if it already carried the cap over from an earlier lobby, it keeps
|
||||
# the cheer but no new health.
|
||||
var granted := Run.add_bonus_hp(_BARREL_BONUS_HP)
|
||||
if granted > 0 and _player != null and _player.has_method(&"grant_bonus_hp"):
|
||||
_player.call(&"grant_bonus_hp", granted)
|
||||
_celebrate()
|
||||
|
||||
|
||||
# The "cleared the lobby" flourish: a burst of confetti and a big springy "WOooOoW!".
|
||||
func _celebrate() -> void:
|
||||
_spawn_confetti()
|
||||
_show_wow()
|
||||
|
||||
|
||||
func _show_wow() -> void:
|
||||
var wow := Label.new()
|
||||
wow.text = "WOooOoW!"
|
||||
wow.set_anchors_preset(Control.PRESET_CENTER)
|
||||
wow.grow_horizontal = Control.GROW_DIRECTION_BOTH
|
||||
wow.grow_vertical = Control.GROW_DIRECTION_BOTH
|
||||
wow.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||||
wow.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
|
||||
wow.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
wow.add_theme_font_override("font", UiFonts.title())
|
||||
wow.add_theme_font_size_override("font_size", 96)
|
||||
wow.add_theme_color_override("font_color", _HP_BONUS)
|
||||
wow.add_theme_color_override("font_outline_color", Color(0.0, 0.0, 0.0, 0.9))
|
||||
wow.add_theme_constant_override("outline_size", 8)
|
||||
wow.pivot_offset = Vector2.ZERO
|
||||
wow.scale = Vector2(0.2, 0.2)
|
||||
add_child(wow)
|
||||
# Keep the pop growing from the label's centre once it has a real size.
|
||||
await get_tree().process_frame
|
||||
wow.pivot_offset = wow.size * 0.5
|
||||
|
||||
var tw := create_tween()
|
||||
tw.tween_property(wow, "scale", Vector2.ONE, 0.35).set_trans(Tween.TRANS_BACK).set_ease(
|
||||
Tween.EASE_OUT)
|
||||
tw.tween_interval(1.1)
|
||||
tw.parallel().tween_property(wow, "modulate:a", 0.0, 0.6)
|
||||
tw.parallel().tween_property(wow, "position:y", wow.position.y - 40.0, 0.6)
|
||||
tw.tween_callback(wow.queue_free)
|
||||
|
||||
|
||||
func _spawn_confetti() -> void:
|
||||
const _CONFETTI_COLORS: Array[Color] = [
|
||||
Color(0.98, 0.24, 0.28), # red
|
||||
Color(1.00, 0.78, 0.20), # gold
|
||||
Color(0.30, 0.80, 0.40), # green
|
||||
Color(0.30, 0.62, 1.00), # blue
|
||||
Color(0.85, 0.40, 0.95), # violet
|
||||
Color(1.00, 1.00, 1.00), # white
|
||||
]
|
||||
var vp := get_viewport().get_visible_rect().size
|
||||
# One emitter per colour, raining down across the top of the screen.
|
||||
for col: Color in _CONFETTI_COLORS:
|
||||
var p := CPUParticles2D.new()
|
||||
p.position = Vector2(vp.x * 0.5, -20.0)
|
||||
p.emission_shape = CPUParticles2D.EMISSION_SHAPE_RECTANGLE
|
||||
p.emission_rect_extents = Vector2(vp.x * 0.5, 6.0)
|
||||
p.amount = 40
|
||||
p.lifetime = 2.6
|
||||
p.one_shot = true
|
||||
p.explosiveness = 0.35
|
||||
p.direction = Vector2(0.0, 1.0)
|
||||
p.spread = 25.0
|
||||
p.gravity = Vector2(0.0, 420.0)
|
||||
p.initial_velocity_min = 120.0
|
||||
p.initial_velocity_max = 260.0
|
||||
p.angular_velocity_min = -360.0
|
||||
p.angular_velocity_max = 360.0
|
||||
p.scale_amount_min = 4.0
|
||||
p.scale_amount_max = 8.0
|
||||
p.color = col
|
||||
add_child(p)
|
||||
# Self-clean once the burst has fully fallen.
|
||||
get_tree().create_timer(p.lifetime + 0.5).timeout.connect(p.queue_free)
|
||||
|
||||
|
||||
func _unhandled_input(event: InputEvent) -> void:
|
||||
if event is InputEventKey and event.pressed and not event.echo:
|
||||
if _game_over and event.physical_keycode in [KEY_ENTER, KEY_KP_ENTER]:
|
||||
if _result_win:
|
||||
_continue_to_lobby()
|
||||
else:
|
||||
_restart()
|
||||
elif event.physical_keycode == KEY_TAB and not _game_over:
|
||||
_toggle_controls()
|
||||
elif event.physical_keycode == KEY_ESCAPE:
|
||||
_return_to_menu()
|
||||
|
||||
|
||||
func _return_to_menu() -> void:
|
||||
get_tree().paused = false
|
||||
Input.mouse_mode = Input.MOUSE_MODE_VISIBLE
|
||||
get_tree().change_scene_to_file("res://ui/main_menu.tscn")
|
||||
|
||||
|
||||
func _restart() -> void:
|
||||
get_tree().paused = false
|
||||
Input.mouse_mode = Input.MOUSE_MODE_CAPTURED
|
||||
get_tree().reload_current_scene()
|
||||
|
||||
|
||||
# A cleared level drops the player back into the lobby hub: bank the win (which opens the
|
||||
# gates it unlocks) and reload the shell so it rebuilds the lobby.
|
||||
func _continue_to_lobby() -> void:
|
||||
get_tree().paused = false
|
||||
Input.mouse_mode = Input.MOUSE_MODE_CAPTURED
|
||||
Run.return_to_lobby()
|
||||
get_tree().change_scene_to_file(Run.active_level().scene_path)
|
||||
|
||||
|
||||
func _toggle_controls() -> void:
|
||||
_controls_visible = not _controls_visible
|
||||
_toggle_btn.text = "Hide controls" if _controls_visible else "Show controls"
|
||||
_update_control_hints()
|
||||
|
||||
|
||||
# The Tab hint and the keyboard controls plaque only make sense with a keyboard, so
|
||||
# they stand down on touch devices — where touch_controls.gd shows the on-screen
|
||||
# joystick and ability buttons instead. Shares Controls.use_touch_ui() with that
|
||||
# overlay so exactly one control scheme shows on any given device.
|
||||
func _update_control_hints() -> void:
|
||||
var touch := Controls.use_touch_ui()
|
||||
# The desktop tiles carry keyboard keycaps that mean nothing on a phone, and their
|
||||
# STOP-filtered panels would sit over the joystick zone eating touches — touch_controls
|
||||
# shows its own ability buttons instead, so drop the desktop bar on touch devices.
|
||||
if _ability_bar != null:
|
||||
_ability_bar.visible = not touch
|
||||
if _game_over or touch:
|
||||
_controls_panel.visible = false
|
||||
_tab_hint.visible = false
|
||||
else:
|
||||
_controls_panel.visible = _controls_visible
|
||||
_tab_hint.visible = not _controls_visible
|
||||
|
||||
|
||||
# ── Damage vignette ───────────────────────────────────────────────────────────
|
||||
# A full-screen ColorRect driven by damage_vignette.gdshader: transparent centre, red
|
||||
# toward the edges, alpha ("flash") tweened up on a hit and eased back to nothing. Built
|
||||
# before the game-over layer so the outcome screen always draws over it.
|
||||
|
||||
func _build_damage_vignette() -> void:
|
||||
_vignette = ColorRect.new()
|
||||
_vignette.set_anchors_preset(Control.PRESET_FULL_RECT)
|
||||
_vignette.color = Color.WHITE # the shader writes COLOR outright; this is just a base
|
||||
_vignette.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
if ResourceLoader.exists(_VIGNETTE_SHADER):
|
||||
_vignette_mat = ShaderMaterial.new()
|
||||
_vignette_mat.shader = load(_VIGNETTE_SHADER)
|
||||
_vignette_mat.set_shader_parameter("flash", 0.0)
|
||||
_vignette.material = _vignette_mat
|
||||
add_child(_vignette)
|
||||
|
||||
|
||||
func _on_hit_taken(_cause: String) -> void:
|
||||
if _vignette_mat == null:
|
||||
return
|
||||
var set_flash := func(v: float) -> void:
|
||||
_vignette_mat.set_shader_parameter("flash", v)
|
||||
create_tween().tween_method(set_flash, 0.9, 0.0, 0.5)\
|
||||
.set_trans(Tween.TRANS_SINE).set_ease(Tween.EASE_OUT)
|
||||
|
||||
|
||||
# ── Win / lose ──────────────────────────────────────────────────────────────
|
||||
# The bull takes one clean blade and it's over; clear the last matador and it's a
|
||||
# victory. Both routes end here — the arena freezes and the result is proclaimed.
|
||||
|
||||
func _on_player_died(cause: String = "") -> void:
|
||||
_show_game_over(false, cause)
|
||||
|
||||
|
||||
func _on_all_defeated() -> void:
|
||||
_show_game_over(true)
|
||||
|
||||
|
||||
func _show_game_over(win: bool, _cause: String = "") -> void:
|
||||
if _game_over:
|
||||
return
|
||||
_game_over = true
|
||||
_result_win = win
|
||||
|
||||
if _controls_panel:
|
||||
_controls_panel.visible = false
|
||||
if _tab_hint:
|
||||
_tab_hint.visible = false
|
||||
|
||||
await get_tree().create_timer(_WIN_DELAY if win else _LOSS_DELAY, false, false, true).timeout
|
||||
|
||||
_over_label.text = "Bull Won!" if _result_win else "Matadors Won!"
|
||||
# A win advances the run (on to the map); a loss ends it (retry from the top).
|
||||
_over_continue.visible = _result_win
|
||||
_over_again.visible = not _result_win
|
||||
|
||||
var path := _WIN_VIDEO if _result_win else _LOSS_VIDEO
|
||||
if ResourceLoader.exists(path):
|
||||
_over_video.stream = load(path)
|
||||
_over_video.play()
|
||||
|
||||
_over_root.visible = true
|
||||
_over_root.modulate.a = 0.0
|
||||
Input.mouse_mode = Input.MOUSE_MODE_VISIBLE
|
||||
get_tree().paused = true
|
||||
|
||||
create_tween().tween_property(_over_root, "modulate:a", 1.0, 0.5)
|
||||
|
||||
|
||||
func _build_game_over() -> void:
|
||||
_over_root = Control.new()
|
||||
_over_root.set_anchors_preset(Control.PRESET_FULL_RECT)
|
||||
_over_root.process_mode = Node.PROCESS_MODE_ALWAYS
|
||||
_over_root.visible = false
|
||||
add_child(_over_root)
|
||||
|
||||
# Full-screen outcome clip (bull win / matador win), looping.
|
||||
# AspectRatioContainer keeps the 16:9 video's ratio and crops overflow (COVER),
|
||||
# so it fills any screen shape without stretching or leaving dead bars.
|
||||
var arc := AspectRatioContainer.new()
|
||||
arc.set_anchors_preset(Control.PRESET_FULL_RECT)
|
||||
arc.ratio = 16.0 / 9.0
|
||||
arc.stretch_mode = AspectRatioContainer.STRETCH_COVER
|
||||
arc.alignment_horizontal = AspectRatioContainer.ALIGNMENT_CENTER
|
||||
arc.alignment_vertical = AspectRatioContainer.ALIGNMENT_CENTER
|
||||
arc.process_mode = Node.PROCESS_MODE_ALWAYS
|
||||
arc.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
_over_root.add_child(arc)
|
||||
|
||||
_over_video = VideoStreamPlayer.new()
|
||||
_over_video.set_anchors_preset(Control.PRESET_FULL_RECT)
|
||||
_over_video.expand = true
|
||||
_over_video.process_mode = Node.PROCESS_MODE_ALWAYS
|
||||
_over_video.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
_over_video.finished.connect(_over_video.play) # loop the outcome clip
|
||||
arc.add_child(_over_video)
|
||||
|
||||
# Outcome text centred over the video.
|
||||
_over_label = Label.new()
|
||||
_over_label.set_anchors_preset(Control.PRESET_CENTER_TOP)
|
||||
_over_label.grow_horizontal = Control.GROW_DIRECTION_BOTH
|
||||
_over_label.grow_vertical = Control.GROW_DIRECTION_END
|
||||
_over_label.offset_top = 80.0
|
||||
_over_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||||
_over_label.add_theme_font_override("font", UiFonts.title())
|
||||
_over_label.add_theme_font_size_override("font_size", 64)
|
||||
_over_label.add_theme_color_override("font_color", _GOLD)
|
||||
_over_label.add_theme_color_override("font_outline_color", Color(0.0, 0.0, 0.0, 0.9))
|
||||
_over_label.add_theme_constant_override("outline_size", 6)
|
||||
_over_label.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
_over_root.add_child(_over_label)
|
||||
|
||||
# Buttons overlaid at the bottom of the clip.
|
||||
var row := HBoxContainer.new()
|
||||
row.alignment = BoxContainer.ALIGNMENT_CENTER
|
||||
row.add_theme_constant_override("separation", 32 if Controls.use_touch_ui() else 16)
|
||||
row.anchor_left = 0.5
|
||||
row.anchor_right = 0.5
|
||||
row.anchor_top = 1.0
|
||||
row.anchor_bottom = 1.0
|
||||
row.grow_horizontal = Control.GROW_DIRECTION_BOTH
|
||||
row.grow_vertical = Control.GROW_DIRECTION_BEGIN
|
||||
row.offset_bottom = -48.0
|
||||
_over_root.add_child(row)
|
||||
|
||||
# Win → continue to the run map; loss → restart from the top. Shown per-result in
|
||||
# _show_game_over; both share the Enter shortcut and label.
|
||||
_over_continue = _make_button("Continue (Enter)")
|
||||
_over_continue.pressed.connect(_continue_to_lobby)
|
||||
row.add_child(_over_continue)
|
||||
|
||||
_over_again = _make_button("Continue (Enter)")
|
||||
_over_again.pressed.connect(_restart)
|
||||
row.add_child(_over_again)
|
||||
|
||||
var menu := _make_button("Main Menu")
|
||||
menu.pressed.connect(_return_to_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 ───────────────────────────────────────────────────────────────
|
||||
# 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
|
||||
# crimson as the bull is worn down; spent pips read as empty dark sockets.
|
||||
|
||||
const _HP_FULL: Color = Color(0.82, 0.16, 0.12)
|
||||
const _HP_EMPTY: Color = Color(0.35, 0.05, 0.05)
|
||||
const _HP_SOCKET: Color = Color(0.05, 0.02, 0.02, 0.92)
|
||||
# Bonus pips (barrel-smash reward) read as bright gold-yellow so they stand apart from the
|
||||
# red life bar.
|
||||
const _HP_BONUS: Color = Color(1.00, 0.84, 0.18)
|
||||
|
||||
const _HP_PIP_W: float = 22.0
|
||||
const _HP_PIP_H: float = 14.0
|
||||
|
||||
|
||||
func _build_health() -> void:
|
||||
var panel := PanelContainer.new()
|
||||
panel.add_theme_stylebox_override(
|
||||
"panel", UiTheme.plaque(UiTheme.LEATHER, UiTheme.BORDER, 4, 12.0, 6.0))
|
||||
panel.size_flags_horizontal = Control.SIZE_SHRINK_CENTER
|
||||
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)
|
||||
|
||||
var row := HBoxContainer.new()
|
||||
row.add_theme_constant_override("separation", 8)
|
||||
panel.add_child(row)
|
||||
|
||||
_hp_label = Label.new()
|
||||
_hp_label.text = "BULL"
|
||||
_hp_label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
|
||||
_hp_label.add_theme_font_override("font", UiFonts.body())
|
||||
_hp_label.add_theme_color_override("font_color", _GOLD)
|
||||
_hp_label.add_theme_color_override("font_outline_color", Color(0, 0, 0, 0.85))
|
||||
_hp_label.add_theme_constant_override("outline_size", 3)
|
||||
_hp_label.add_theme_font_size_override("font_size", 14)
|
||||
row.add_child(_hp_label)
|
||||
|
||||
_hp_segments = HBoxContainer.new()
|
||||
_hp_segments.add_theme_constant_override("separation", 4)
|
||||
_hp_segments.size_flags_vertical = Control.SIZE_SHRINK_CENTER
|
||||
_hp_segments.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
row.add_child(_hp_segments)
|
||||
|
||||
|
||||
# The gold-underlined socket behind each pip — shared, non-mutating, so one instance
|
||||
# is safe across every pip.
|
||||
func _hp_socket_style() -> StyleBoxFlat:
|
||||
var s := StyleBoxFlat.new()
|
||||
s.bg_color = _HP_SOCKET
|
||||
s.set_corner_radius_all(3)
|
||||
s.corner_detail = 4
|
||||
s.border_width_bottom = 2
|
||||
s.border_color = Color(UiTheme.GOLD.r, UiTheme.GOLD.g, UiTheme.GOLD.b, 0.45)
|
||||
s.content_margin_left = 2.0
|
||||
s.content_margin_right = 2.0
|
||||
s.content_margin_top = 2.0
|
||||
s.content_margin_bottom = 2.0
|
||||
return s
|
||||
|
||||
|
||||
func _rebuild_hp_pips(max_hp: int) -> void:
|
||||
for child in _hp_segments.get_children():
|
||||
child.queue_free()
|
||||
_hp_seg_fills.clear()
|
||||
var socket_style := _hp_socket_style()
|
||||
for _i: int in maxi(max_hp, 0):
|
||||
var socket := PanelContainer.new()
|
||||
socket.custom_minimum_size = Vector2(_HP_PIP_W, _HP_PIP_H)
|
||||
socket.clip_contents = true
|
||||
socket.add_theme_stylebox_override("panel", socket_style)
|
||||
socket.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
_hp_segments.add_child(socket)
|
||||
|
||||
var fill := ColorRect.new()
|
||||
fill.color = _HP_FULL
|
||||
fill.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
socket.add_child(fill)
|
||||
_hp_seg_fills.append(fill)
|
||||
|
||||
|
||||
func _on_health_changed(current: int, max_hp: int) -> void:
|
||||
if _hp_segments == null:
|
||||
return
|
||||
if _hp_seg_fills.size() != max_hp:
|
||||
_rebuild_hp_pips(max_hp)
|
||||
var cur := maxi(current, 0)
|
||||
var frac := 0.0 if max_hp <= 0 else clampf(float(cur) / float(max_hp), 0.0, 1.0)
|
||||
var lit := _HP_FULL.lerp(_HP_EMPTY, 1.0 - frac)
|
||||
# Bonus pips sit at the high end of the bar; paint those top pips yellow when lit.
|
||||
var bonus := 0
|
||||
if _player != null and _player.has_method(&"get_bonus_hp"):
|
||||
bonus = _player.call(&"get_bonus_hp")
|
||||
var base_max := max_hp - bonus
|
||||
for i: int in _hp_seg_fills.size():
|
||||
var fill := _hp_seg_fills[i]
|
||||
fill.visible = i < cur
|
||||
fill.color = _HP_BONUS if i >= base_max else lit
|
||||
|
||||
|
||||
const _SLOT_SEPARATION: int = 10
|
||||
|
||||
|
||||
func _build_ability_bar() -> void:
|
||||
_ability_bar = HBoxContainer.new()
|
||||
_ability_bar.add_theme_constant_override("separation", _SLOT_SEPARATION)
|
||||
_ability_bar.size_flags_horizontal = Control.SIZE_SHRINK_CENTER
|
||||
_ability_bar.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
_bottom_col.add_child(_ability_bar)
|
||||
for i: int in 4:
|
||||
_ability_bar.add_child(_build_ability_slot(i))
|
||||
_layout_ability_bar()
|
||||
get_viewport().size_changed.connect(_layout_ability_bar)
|
||||
|
||||
|
||||
# Size the ability slots as a fraction of the viewport (clamped) so the bar scales
|
||||
# with the window; the bottom column keeps the bar horizontally centred.
|
||||
func _layout_ability_bar() -> void:
|
||||
var vp := get_viewport().get_visible_rect().size
|
||||
var slot := clampf(minf(vp.x * 0.055, vp.y * 0.11), 56.0, 104.0)
|
||||
for panel: PanelContainer in _ability_panels:
|
||||
panel.custom_minimum_size = Vector2(slot, slot)
|
||||
|
||||
|
||||
func _build_ability_slot(idx: int) -> Control:
|
||||
var vbox := VBoxContainer.new()
|
||||
vbox.add_theme_constant_override("separation", 3)
|
||||
|
||||
var panel := PanelContainer.new()
|
||||
panel.custom_minimum_size = Vector2(72.0, 72.0)
|
||||
panel.add_theme_stylebox_override(
|
||||
"panel", UiTheme.plaque(UiTheme.LEATHER, _ABILITY_ACCENT[idx], 4, 5.0, 5.0))
|
||||
vbox.add_child(panel)
|
||||
_ability_panels.append(panel)
|
||||
|
||||
var stack := Control.new()
|
||||
stack.set_anchors_preset(Control.PRESET_FULL_RECT)
|
||||
panel.add_child(stack)
|
||||
|
||||
if ResourceLoader.exists(_ABILITY_ICONS[idx]):
|
||||
var icon := TextureRect.new()
|
||||
icon.texture = load(_ABILITY_ICONS[idx])
|
||||
# Ignore the texture's native size so a large source PNG doesn't blow the
|
||||
# slot's minimum size out; scale-to-fit the box while keeping aspect.
|
||||
icon.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
|
||||
icon.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
|
||||
icon.set_anchors_preset(Control.PRESET_FULL_RECT)
|
||||
icon.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
stack.add_child(icon)
|
||||
else:
|
||||
var name_lbl := Label.new()
|
||||
name_lbl.text = _ABILITY_NAMES[idx]
|
||||
name_lbl.set_anchors_preset(Control.PRESET_FULL_RECT)
|
||||
name_lbl.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||||
name_lbl.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
|
||||
name_lbl.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
name_lbl.add_theme_color_override("font_color", _GOLD)
|
||||
name_lbl.add_theme_font_size_override("font_size", 16)
|
||||
stack.add_child(name_lbl)
|
||||
|
||||
var cd_overlay := ColorRect.new()
|
||||
cd_overlay.color = Color(0.0, 0.0, 0.0, 0.70)
|
||||
cd_overlay.anchor_left = 0.0
|
||||
cd_overlay.anchor_right = 1.0
|
||||
cd_overlay.anchor_top = 1.0
|
||||
cd_overlay.anchor_bottom = 1.0
|
||||
cd_overlay.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
stack.add_child(cd_overlay)
|
||||
_cd_overlays.append(cd_overlay)
|
||||
|
||||
var cd_lbl := Label.new()
|
||||
cd_lbl.set_anchors_preset(Control.PRESET_FULL_RECT)
|
||||
cd_lbl.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||||
cd_lbl.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
|
||||
cd_lbl.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
cd_lbl.visible = false
|
||||
cd_lbl.add_theme_color_override("font_color", Color.WHITE)
|
||||
cd_lbl.add_theme_font_size_override("font_size", 18)
|
||||
stack.add_child(cd_lbl)
|
||||
_cd_labels.append(cd_lbl)
|
||||
|
||||
# Keycap tucked directly beneath the tile — a solid dark plate wearing the
|
||||
# ability's accent underline, so it reads as attached to its box without ever
|
||||
# covering the artwork. Centred under the tile and tightly spaced.
|
||||
var keycap := PanelContainer.new()
|
||||
keycap.add_theme_stylebox_override("panel", _keycap_style(idx))
|
||||
keycap.size_flags_horizontal = Control.SIZE_SHRINK_CENTER
|
||||
keycap.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
vbox.add_child(keycap)
|
||||
|
||||
var key_lbl := Label.new()
|
||||
key_lbl.text = Controls.get_key_label(_ABILITY_ACTIONS[idx])
|
||||
key_lbl.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||||
key_lbl.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
|
||||
key_lbl.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
key_lbl.add_theme_font_override("font", UiFonts.body_bold())
|
||||
key_lbl.add_theme_color_override("font_color", _CREAM)
|
||||
key_lbl.add_theme_color_override("font_outline_color", Color(0, 0, 0, 0.9))
|
||||
key_lbl.add_theme_constant_override("outline_size", 3)
|
||||
key_lbl.add_theme_font_size_override("font_size", 14)
|
||||
keycap.add_child(key_lbl)
|
||||
|
||||
return vbox
|
||||
|
||||
|
||||
# Solid keycap plate for an ability bind: near-black fill with the ability's accent
|
||||
# as a chunky top edge, so the accent bridges up to the tile it sits under (reads as
|
||||
# attached) while the key stays on a high-contrast background against busy scenes.
|
||||
func _keycap_style(idx: int) -> StyleBoxFlat:
|
||||
var s := StyleBoxFlat.new()
|
||||
s.bg_color = Color(0.04, 0.03, 0.02, 0.94)
|
||||
s.set_corner_radius_all(4)
|
||||
s.corner_detail = 4
|
||||
s.set_border_width_all(1)
|
||||
s.border_width_top = 3
|
||||
s.border_color = _ABILITY_ACCENT[idx]
|
||||
s.content_margin_left = 7.0
|
||||
s.content_margin_right = 7.0
|
||||
s.content_margin_top = 2.0
|
||||
s.content_margin_bottom = 1.0
|
||||
return s
|
||||
|
||||
|
||||
func _build_tab_hint() -> void:
|
||||
_tab_hint = Label.new()
|
||||
_tab_hint.text = "Press 'Tab' to see controls"
|
||||
_tab_hint.anchor_left = 0.0
|
||||
_tab_hint.anchor_top = 1.0
|
||||
_tab_hint.anchor_right = 0.0
|
||||
_tab_hint.anchor_bottom = 1.0
|
||||
_tab_hint.grow_vertical = Control.GROW_DIRECTION_BEGIN
|
||||
_tab_hint.offset_left = 20.0
|
||||
_tab_hint.offset_bottom = -20.0
|
||||
_tab_hint.add_theme_color_override("font_color", _MUTED)
|
||||
_tab_hint.add_theme_font_size_override("font_size", 12)
|
||||
add_child(_tab_hint)
|
||||
|
||||
|
||||
func _build_controls_panel() -> void:
|
||||
_controls_panel = PanelContainer.new()
|
||||
_controls_panel.add_theme_stylebox_override(
|
||||
"panel", UiTheme.plaque(UiTheme.LEATHER, UiTheme.BORDER, 4, 16.0, 12.0))
|
||||
|
||||
_controls_panel.anchor_left = 0.0
|
||||
_controls_panel.anchor_top = 1.0
|
||||
_controls_panel.anchor_right = 0.0
|
||||
_controls_panel.anchor_bottom = 1.0
|
||||
_controls_panel.grow_vertical = Control.GROW_DIRECTION_BEGIN
|
||||
_controls_panel.offset_left = 20.0
|
||||
_controls_panel.offset_bottom = -20.0
|
||||
_controls_panel.visible = false
|
||||
add_child(_controls_panel)
|
||||
|
||||
var vbox := VBoxContainer.new()
|
||||
vbox.add_theme_constant_override("separation", 5)
|
||||
_controls_panel.add_child(vbox)
|
||||
|
||||
var move_keys := "%s %s %s %s" % [
|
||||
Controls.get_key_label(&"move_forward"),
|
||||
Controls.get_key_label(&"move_left"),
|
||||
Controls.get_key_label(&"move_back"),
|
||||
Controls.get_key_label(&"move_right"),
|
||||
]
|
||||
_row(vbox, move_keys, "Move", _CREAM)
|
||||
_row(vbox, Controls.get_key_label(&"ability_kick"), "Kick", _CREAM)
|
||||
_row(vbox, Controls.get_key_label(&"ability_dash"), "Dash", _CREAM)
|
||||
_row(vbox, Controls.get_key_label(&"ability_slam"), "Slam", _CREAM)
|
||||
_row(vbox, Controls.get_key_label(&"ability_roll"), "Roll (Powerball: ram the matador!)", _CREAM)
|
||||
_row(vbox, "Scroll", "Zoom", _CREAM)
|
||||
_row(vbox, "Tab", "Toggle controls", _MUTED)
|
||||
_row(vbox, "Esc", "Back to menu", _MUTED)
|
||||
|
||||
var sep := HSeparator.new()
|
||||
sep.add_theme_stylebox_override("separator", UiTheme.rule())
|
||||
vbox.add_child(sep)
|
||||
|
||||
_toggle_btn = _make_button("Show controls")
|
||||
_toggle_btn.pressed.connect(_toggle_controls)
|
||||
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:
|
||||
var btn := Button.new()
|
||||
btn.text = label_text
|
||||
btn.process_mode = Node.PROCESS_MODE_ALWAYS
|
||||
|
||||
var normal := UiTheme.plaque(Color(0.10, 0.07, 0.03, 0.90), _GOLD, 3, 18.0, 9.0)
|
||||
var hover := UiTheme.plaque(Color(0.16, 0.10, 0.04, 0.96), _CREAM, 3, 18.0, 9.0)
|
||||
|
||||
btn.add_theme_stylebox_override("normal", normal)
|
||||
btn.add_theme_stylebox_override("hover", hover)
|
||||
btn.add_theme_stylebox_override("pressed", hover)
|
||||
btn.add_theme_stylebox_override("focus", hover)
|
||||
btn.add_theme_font_override("font", UiFonts.body())
|
||||
btn.add_theme_color_override("font_color", _GOLD)
|
||||
btn.add_theme_color_override("font_hover_color", _CREAM)
|
||||
btn.add_theme_font_size_override("font_size", 18)
|
||||
|
||||
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:
|
||||
var row := HBoxContainer.new()
|
||||
row.add_theme_constant_override("separation", 10)
|
||||
parent.add_child(row)
|
||||
|
||||
var badge := PanelContainer.new()
|
||||
badge.add_theme_stylebox_override("panel", UiTheme.badge())
|
||||
badge.custom_minimum_size = Vector2(100.0, 0.0)
|
||||
row.add_child(badge)
|
||||
|
||||
var key_lbl := Label.new()
|
||||
key_lbl.text = key
|
||||
key_lbl.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||||
key_lbl.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
|
||||
key_lbl.add_theme_color_override("font_color", _GOLD)
|
||||
key_lbl.add_theme_font_size_override("font_size", 12)
|
||||
badge.add_child(key_lbl)
|
||||
|
||||
var desc_lbl := Label.new()
|
||||
desc_lbl.text = desc
|
||||
desc_lbl.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
|
||||
desc_lbl.add_theme_color_override("font_color", desc_color)
|
||||
desc_lbl.add_theme_font_size_override("font_size", 12)
|
||||
row.add_child(desc_lbl)
|
||||
@@ -0,0 +1 @@
|
||||
uid://butd6b3tdwxrv
|
||||
+392
@@ -0,0 +1,392 @@
|
||||
extends Control
|
||||
|
||||
## Intro menu: Siim's arena animation as a looping video background.
|
||||
## Play / Options / Credits / Exit UI are overlaid; Play follows the cape.
|
||||
|
||||
const GAME_SCENE := "res://levels/game_shell.tscn"
|
||||
const FADE_TIME := 0.6
|
||||
|
||||
# Arena palette: aged gold on dark leather, warm cream on hover.
|
||||
const MENU_GOLD := Color(0.85, 0.66, 0.20)
|
||||
const MENU_CREAM := Color(0.93, 0.88, 0.72)
|
||||
|
||||
## Cape centroid per frame (normalized 0-1), detected from red-pixel centroid.
|
||||
## 34 frames at 24 fps = 1.4167 s loop.
|
||||
const VIDEO_FPS := 24.0
|
||||
const CAPE_POS: Array = [
|
||||
Vector2(0.4530, 0.6513),
|
||||
Vector2(0.4521, 0.6516),
|
||||
Vector2(0.4495, 0.6524),
|
||||
Vector2(0.4456, 0.6537),
|
||||
Vector2(0.4415, 0.6550),
|
||||
Vector2(0.4373, 0.6578),
|
||||
Vector2(0.4338, 0.6616),
|
||||
Vector2(0.4306, 0.6669),
|
||||
Vector2(0.4273, 0.6673),
|
||||
Vector2(0.4256, 0.6586),
|
||||
Vector2(0.4258, 0.6428),
|
||||
Vector2(0.4281, 0.6243),
|
||||
Vector2(0.4324, 0.6086),
|
||||
Vector2(0.4382, 0.5997),
|
||||
Vector2(0.4439, 0.5972),
|
||||
Vector2(0.4483, 0.5970),
|
||||
Vector2(0.4512, 0.5986),
|
||||
Vector2(0.4529, 0.6025),
|
||||
Vector2(0.4535, 0.6078),
|
||||
Vector2(0.4533, 0.6135),
|
||||
Vector2(0.4539, 0.6159),
|
||||
Vector2(0.4545, 0.6120),
|
||||
Vector2(0.4546, 0.6050),
|
||||
Vector2(0.4541, 0.5988),
|
||||
Vector2(0.4528, 0.5961),
|
||||
Vector2(0.4508, 0.6012),
|
||||
Vector2(0.4485, 0.6123),
|
||||
Vector2(0.4466, 0.6256),
|
||||
Vector2(0.4456, 0.6388),
|
||||
Vector2(0.4457, 0.6491),
|
||||
Vector2(0.4473, 0.6552),
|
||||
Vector2(0.4492, 0.6567),
|
||||
Vector2(0.4513, 0.6556),
|
||||
Vector2(0.4524, 0.6526),
|
||||
]
|
||||
|
||||
## Friendly labels for the rebindable actions (order defines list order).
|
||||
const ACTION_LABELS: Dictionary = {
|
||||
&"move_forward": "Move Forward",
|
||||
&"move_back": "Move Back",
|
||||
&"move_left": "Move Left",
|
||||
&"move_right": "Move Right",
|
||||
&"ability_kick": "Kick",
|
||||
&"ability_dash": "Dash",
|
||||
&"ability_slam": "Slam",
|
||||
&"ability_roll": "Roll",
|
||||
}
|
||||
|
||||
@onready var main_panel: Control = $MainPanel
|
||||
@onready var options_panel: Control = $OptionsPanel
|
||||
@onready var credits_panel: Control = $CreditsPanel
|
||||
@onready var options_vbox: VBoxContainer = $OptionsPanel/VBox
|
||||
@onready var options_scroll: ScrollContainer = $OptionsPanel/VBox/Scroll
|
||||
@onready var rebind_list: VBoxContainer = $OptionsPanel/VBox/Scroll/RebindList
|
||||
@onready var fade_rect: ColorRect = $FadeRect
|
||||
@onready var play_button: Button = $VideoAspect/CapeLayer/PlayButton
|
||||
@onready var options_button: Button = $MainPanel/OptionsButton
|
||||
@onready var credits_button: Button = $MainPanel/CreditsButton
|
||||
@onready var exit_button: Button = $MainPanel/ExitButton
|
||||
@onready var options_back: Button = $OptionsPanel/VBox/BackButton
|
||||
@onready var credits_back: Button = $CreditsPanel/VBox/BackButton
|
||||
@onready var video: VideoStreamPlayer = $VideoAspect/VideoStreamPlayer
|
||||
@onready var blur_video: VideoStreamPlayer = $VideoBlur/BlurVideo
|
||||
|
||||
var _listening_action: StringName = &""
|
||||
var _listening_button: Button = null
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
Input.mouse_mode = Input.MOUSE_MODE_VISIBLE
|
||||
options_panel.visible = false
|
||||
credits_panel.visible = false
|
||||
fade_rect.color = Color(0, 0, 0, 0)
|
||||
if video.stream != null:
|
||||
video.play()
|
||||
video.finished.connect(video.play) # loop the background clip
|
||||
if blur_video.stream != null:
|
||||
blur_video.play()
|
||||
blur_video.finished.connect(blur_video.play) # loop the blurred backdrop
|
||||
|
||||
play_button.pressed.connect(_on_play_pressed)
|
||||
options_button.pressed.connect(_on_options_pressed)
|
||||
credits_button.pressed.connect(_on_credits_pressed)
|
||||
exit_button.pressed.connect(_on_exit_pressed)
|
||||
options_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()
|
||||
_build_version_label()
|
||||
_build_options_toggles()
|
||||
_build_rebind_rows()
|
||||
play_button.grab_focus()
|
||||
|
||||
|
||||
# Version banner in the bottom-right corner so it's clear this is an early build.
|
||||
func _build_version_label() -> void:
|
||||
var lbl := Label.new()
|
||||
lbl.text = GameVersion.full()
|
||||
lbl.add_theme_font_override("font", UiFonts.body())
|
||||
lbl.add_theme_font_size_override("font_size", 16)
|
||||
lbl.add_theme_color_override("font_color", MENU_CREAM)
|
||||
lbl.add_theme_color_override("font_outline_color", Color.BLACK)
|
||||
lbl.add_theme_constant_override("outline_size", 4)
|
||||
lbl.set_anchors_preset(Control.PRESET_BOTTOM_RIGHT)
|
||||
lbl.grow_horizontal = Control.GROW_DIRECTION_BEGIN
|
||||
lbl.grow_vertical = Control.GROW_DIRECTION_BEGIN
|
||||
lbl.offset_right = -16.0
|
||||
lbl.offset_bottom = -12.0
|
||||
lbl.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
add_child(lbl)
|
||||
|
||||
|
||||
# Give the whole menu the Cinzel look and swap the generic translucent boxes for
|
||||
# engraved bronze plaques (dark leather fill, gold border with a heavier bottom
|
||||
# edge for a chiselled-tablet feel).
|
||||
func _apply_theme() -> void:
|
||||
_style_button(play_button, true)
|
||||
for btn: Button in [options_button, credits_button, exit_button, options_back, credits_back]:
|
||||
_style_button(btn, false)
|
||||
for path: String in ["OptionsPanel/VBox/TitleLabel", "CreditsPanel/VBox/TitleLabel"]:
|
||||
var title := get_node_or_null(path) as Label
|
||||
if title != null:
|
||||
title.add_theme_font_override("font", UiFonts.title())
|
||||
title.add_theme_color_override("font_color", MENU_GOLD)
|
||||
var hint := get_node_or_null("OptionsPanel/VBox/Hint") as Label
|
||||
if hint != null:
|
||||
hint.add_theme_font_override("font", UiFonts.body())
|
||||
hint.add_theme_color_override("font_color", MENU_CREAM)
|
||||
_style_credits(get_node_or_null("CreditsPanel/VBox/Names") as RichTextLabel)
|
||||
|
||||
|
||||
# The credits use a RichTextLabel so the Instagram handles are clickable links
|
||||
# ([url] tags). meta_clicked opens the profile — in the web export OS.shell_open
|
||||
# pops a new browser tab.
|
||||
func _style_credits(names: RichTextLabel) -> void:
|
||||
if names == null:
|
||||
return
|
||||
names.add_theme_font_override("normal_font", UiFonts.body())
|
||||
names.add_theme_font_override("bold_font", UiFonts.title())
|
||||
names.add_theme_color_override("default_color", MENU_CREAM)
|
||||
names.meta_clicked.connect(func(meta: Variant) -> void:
|
||||
OS.shell_open(str(meta)))
|
||||
|
||||
|
||||
func _style_button(btn: Button, big: bool) -> void:
|
||||
btn.add_theme_font_override("font", UiFonts.title() if big else UiFonts.body())
|
||||
btn.add_theme_color_override("font_color", MENU_GOLD)
|
||||
btn.add_theme_color_override("font_hover_color", MENU_CREAM)
|
||||
btn.add_theme_color_override("font_focus_color", MENU_CREAM)
|
||||
btn.add_theme_color_override("font_pressed_color", MENU_CREAM)
|
||||
btn.add_theme_color_override("font_outline_color", Color(0, 0, 0, 1))
|
||||
btn.add_theme_constant_override("outline_size", 8 if big else 5)
|
||||
btn.add_theme_stylebox_override("normal", _plaque(false))
|
||||
btn.add_theme_stylebox_override("hover", _plaque(true))
|
||||
btn.add_theme_stylebox_override("pressed", _plaque(true))
|
||||
btn.add_theme_stylebox_override("focus", _plaque(true))
|
||||
|
||||
|
||||
func _plaque(hot: bool) -> StyleBoxFlat:
|
||||
var bg := Color(0.14, 0.09, 0.04, 0.94) if hot else Color(0.07, 0.05, 0.03, 0.82)
|
||||
var trim := MENU_CREAM if hot else MENU_GOLD
|
||||
return UiTheme.plaque(bg, trim, 2, 24.0, 9.0)
|
||||
|
||||
|
||||
func _process(_delta: float) -> void:
|
||||
if not main_panel.visible or video.stream == null:
|
||||
return
|
||||
var frame_count := CAPE_POS.size()
|
||||
var t := video.stream_position
|
||||
var frame_idx := int(t * VIDEO_FPS) % frame_count
|
||||
var cape: Vector2 = CAPE_POS[frame_idx]
|
||||
play_button.anchor_left = cape.x
|
||||
play_button.anchor_right = cape.x
|
||||
play_button.anchor_top = cape.y
|
||||
play_button.anchor_bottom = cape.y
|
||||
|
||||
|
||||
# Player-facing option toggles and audio sliders, seated above the rebind list.
|
||||
func _build_options_toggles() -> void:
|
||||
var idx := options_scroll.get_index()
|
||||
_add_section_label("── Audio ──", idx); idx += 1
|
||||
_add_slider_row("Master Volume", Settings.volume_master, Settings.set_volume_master, idx); idx += 1
|
||||
_add_slider_row("Ambient Volume", Settings.volume_ambient, Settings.set_volume_ambient, idx); idx += 1
|
||||
_add_slider_row("Effects Volume", Settings.volume_effects, Settings.set_volume_effects, idx); idx += 1
|
||||
_add_toggle_row("Mute All", Settings.mute_all, Settings.set_mute_all, idx); idx += 1
|
||||
_add_section_label("── Gameplay ──", idx); idx += 1
|
||||
_add_toggle_row("Screen Shake", Settings.screen_shake, Settings.set_screen_shake, idx); idx += 1
|
||||
_add_toggle_row("Blood & Gore", Settings.gore, Settings.set_gore, idx); idx += 1
|
||||
_add_section_label("── Controls ──", idx)
|
||||
|
||||
|
||||
func _add_section_label(text: String, at_index: int) -> void:
|
||||
var lbl := Label.new()
|
||||
lbl.text = text
|
||||
lbl.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||||
lbl.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
lbl.add_theme_font_override("font", UiFonts.title())
|
||||
lbl.add_theme_font_size_override("font_size", 18)
|
||||
lbl.add_theme_color_override("font_color", MENU_GOLD)
|
||||
lbl.add_theme_color_override("font_outline_color", Color.BLACK)
|
||||
lbl.add_theme_constant_override("outline_size", 4)
|
||||
options_vbox.add_child(lbl)
|
||||
options_vbox.move_child(lbl, at_index)
|
||||
|
||||
|
||||
func _add_slider_row(label_text: String, value: float, setter: Callable, at_index: int) -> void:
|
||||
var row := HBoxContainer.new()
|
||||
row.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
|
||||
var name_label := Label.new()
|
||||
name_label.text = label_text
|
||||
name_label.custom_minimum_size = Vector2(160, 0)
|
||||
name_label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
|
||||
name_label.add_theme_font_override("font", UiFonts.body())
|
||||
name_label.add_theme_font_size_override("font_size", 20)
|
||||
name_label.add_theme_color_override("font_color", MENU_CREAM)
|
||||
name_label.add_theme_color_override("font_outline_color", Color.BLACK)
|
||||
name_label.add_theme_constant_override("outline_size", 5)
|
||||
row.add_child(name_label)
|
||||
|
||||
var slider := HSlider.new()
|
||||
slider.min_value = 0.0
|
||||
slider.max_value = 1.0
|
||||
slider.step = 0.01
|
||||
slider.value = value
|
||||
slider.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
slider.custom_minimum_size = Vector2(0, 28)
|
||||
row.add_child(slider)
|
||||
|
||||
var pct_label := Label.new()
|
||||
pct_label.text = "%d%%" % roundi(value * 100.0)
|
||||
pct_label.custom_minimum_size = Vector2(46, 0)
|
||||
pct_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT
|
||||
pct_label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
|
||||
pct_label.add_theme_font_override("font", UiFonts.body())
|
||||
pct_label.add_theme_font_size_override("font_size", 18)
|
||||
pct_label.add_theme_color_override("font_color", MENU_GOLD)
|
||||
row.add_child(pct_label)
|
||||
|
||||
slider.value_changed.connect(func(v: float) -> void:
|
||||
setter.call(v)
|
||||
pct_label.text = "%d%%" % roundi(v * 100.0))
|
||||
|
||||
options_vbox.add_child(row)
|
||||
options_vbox.move_child(row, at_index)
|
||||
|
||||
|
||||
func _add_toggle_row(label_text: String, value: bool, setter: Callable, at_index: int) -> void:
|
||||
var row := HBoxContainer.new()
|
||||
row.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
|
||||
var name_label := Label.new()
|
||||
name_label.text = label_text
|
||||
name_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
name_label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
|
||||
name_label.add_theme_font_override("font", UiFonts.body())
|
||||
name_label.add_theme_font_size_override("font_size", 20)
|
||||
name_label.add_theme_color_override("font_color", MENU_CREAM)
|
||||
name_label.add_theme_color_override("font_outline_color", Color.BLACK)
|
||||
name_label.add_theme_constant_override("outline_size", 5)
|
||||
row.add_child(name_label)
|
||||
|
||||
var toggle := CheckButton.new()
|
||||
toggle.button_pressed = value
|
||||
toggle.add_theme_color_override("font_color", MENU_GOLD)
|
||||
toggle.toggled.connect(func(on: bool) -> void: setter.call(on))
|
||||
row.add_child(toggle)
|
||||
|
||||
options_vbox.add_child(row)
|
||||
options_vbox.move_child(row, at_index)
|
||||
|
||||
|
||||
func _build_rebind_rows() -> void:
|
||||
for child in rebind_list.get_children():
|
||||
child.queue_free()
|
||||
for action: StringName in ACTION_LABELS:
|
||||
var row := HBoxContainer.new()
|
||||
row.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
|
||||
var name_label := Label.new()
|
||||
name_label.text = ACTION_LABELS[action]
|
||||
name_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
name_label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
|
||||
name_label.add_theme_font_override("font", UiFonts.body())
|
||||
name_label.add_theme_font_size_override("font_size", 20)
|
||||
name_label.add_theme_color_override("font_color", MENU_CREAM)
|
||||
name_label.add_theme_color_override("font_outline_color", Color.BLACK)
|
||||
name_label.add_theme_constant_override("outline_size", 5)
|
||||
row.add_child(name_label)
|
||||
|
||||
var key_button := Button.new()
|
||||
key_button.custom_minimum_size = Vector2(160, 0)
|
||||
key_button.text = Controls.get_key_label(action)
|
||||
_style_button(key_button, false)
|
||||
key_button.pressed.connect(_on_rebind_pressed.bind(action, key_button))
|
||||
row.add_child(key_button)
|
||||
|
||||
rebind_list.add_child(row)
|
||||
|
||||
|
||||
func _on_play_pressed() -> void:
|
||||
_set_buttons_disabled(true)
|
||||
Run.start_new_run() # fresh map for this run; the arena is the intro fight
|
||||
var tween := create_tween()
|
||||
tween.tween_property(fade_rect, "color:a", 1.0, FADE_TIME)
|
||||
tween.tween_callback(func() -> void:
|
||||
get_tree().change_scene_to_file(GAME_SCENE))
|
||||
|
||||
|
||||
func _on_options_pressed() -> void:
|
||||
main_panel.visible = false
|
||||
play_button.visible = false
|
||||
options_panel.visible = true
|
||||
options_back.grab_focus()
|
||||
|
||||
|
||||
func _on_credits_pressed() -> void:
|
||||
main_panel.visible = false
|
||||
play_button.visible = false
|
||||
credits_panel.visible = true
|
||||
credits_back.grab_focus()
|
||||
|
||||
|
||||
func _on_exit_pressed() -> void:
|
||||
get_tree().quit()
|
||||
|
||||
|
||||
func _close_subpanels() -> void:
|
||||
_cancel_listening()
|
||||
options_panel.visible = false
|
||||
credits_panel.visible = false
|
||||
main_panel.visible = true
|
||||
play_button.visible = true
|
||||
play_button.grab_focus()
|
||||
|
||||
|
||||
func _set_buttons_disabled(value: bool) -> void:
|
||||
play_button.disabled = value
|
||||
options_button.disabled = value
|
||||
credits_button.disabled = value
|
||||
exit_button.disabled = value
|
||||
|
||||
|
||||
func _on_rebind_pressed(action: StringName, button: Button) -> void:
|
||||
_cancel_listening()
|
||||
_listening_action = action
|
||||
_listening_button = button
|
||||
button.text = "Press a key…"
|
||||
|
||||
|
||||
func _cancel_listening() -> void:
|
||||
if _listening_button != null:
|
||||
_listening_button.text = Controls.get_key_label(_listening_action)
|
||||
_listening_action = &""
|
||||
_listening_button = null
|
||||
|
||||
|
||||
func _unhandled_input(event: InputEvent) -> void:
|
||||
if _listening_action == &"":
|
||||
return
|
||||
if event is InputEventKey and event.pressed and not event.echo:
|
||||
if event.physical_keycode == KEY_ESCAPE:
|
||||
_cancel_listening()
|
||||
else:
|
||||
var new_event := InputEventKey.new()
|
||||
new_event.physical_keycode = event.physical_keycode
|
||||
Controls.rebind(_listening_action, new_event)
|
||||
_listening_button.text = Controls.get_key_label(_listening_action)
|
||||
_listening_action = &""
|
||||
_listening_button = null
|
||||
get_viewport().set_input_as_handled()
|
||||
@@ -0,0 +1 @@
|
||||
uid://46y10alai56q
|
||||
@@ -0,0 +1,307 @@
|
||||
[gd_scene format=3 uid="uid://bmnmenu0intr1"]
|
||||
|
||||
[ext_resource type="Script" uid="uid://46y10alai56q" path="res://ui/main_menu.gd" id="1_menu"]
|
||||
[ext_resource type="VideoStream" uid="uid://csxxlnq74fej4" path="res://art/ui/intro_bg.ogv" id="2_video"]
|
||||
[ext_resource type="Shader" uid="uid://bmdtg6qlh7wri" path="res://ui/menu_blur.gdshader" id="3_blur"]
|
||||
|
||||
[sub_resource type="ShaderMaterial" id="mat_blur"]
|
||||
shader = ExtResource("3_blur")
|
||||
shader_parameter/blur_radius = 3.0
|
||||
shader_parameter/darken = 0.35
|
||||
|
||||
[sub_resource type="StyleBoxFlat" id="sb_normal"]
|
||||
content_margin_left = 16.0
|
||||
content_margin_top = 8.0
|
||||
content_margin_right = 16.0
|
||||
content_margin_bottom = 8.0
|
||||
bg_color = Color(0, 0, 0, 0.4)
|
||||
corner_radius_top_left = 8
|
||||
corner_radius_top_right = 8
|
||||
corner_radius_bottom_right = 8
|
||||
corner_radius_bottom_left = 8
|
||||
|
||||
[sub_resource type="StyleBoxFlat" id="sb_hover"]
|
||||
content_margin_left = 16.0
|
||||
content_margin_top = 8.0
|
||||
content_margin_right = 16.0
|
||||
content_margin_bottom = 8.0
|
||||
bg_color = Color(0, 0, 0, 0.65)
|
||||
border_width_left = 2
|
||||
border_width_top = 2
|
||||
border_width_right = 2
|
||||
border_width_bottom = 2
|
||||
border_color = Color(1, 0.85, 0.2, 1)
|
||||
corner_radius_top_left = 8
|
||||
corner_radius_top_right = 8
|
||||
corner_radius_bottom_right = 8
|
||||
corner_radius_bottom_left = 8
|
||||
|
||||
[node name="MainMenu" type="Control" unique_id=371457725]
|
||||
layout_mode = 3
|
||||
anchors_preset = 15
|
||||
anchor_right = 1.0
|
||||
anchor_bottom = 1.0
|
||||
grow_horizontal = 2
|
||||
grow_vertical = 2
|
||||
script = ExtResource("1_menu")
|
||||
|
||||
[node name="VideoBlur" type="AspectRatioContainer" parent="." unique_id=374854263]
|
||||
layout_mode = 1
|
||||
anchors_preset = 15
|
||||
anchor_right = 1.0
|
||||
anchor_bottom = 1.0
|
||||
grow_horizontal = 2
|
||||
grow_vertical = 2
|
||||
mouse_filter = 2
|
||||
ratio = 1.7778
|
||||
stretch_mode = 3
|
||||
|
||||
[node name="BlurVideo" type="VideoStreamPlayer" parent="VideoBlur" unique_id=374854262]
|
||||
material = SubResource("mat_blur")
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
size_flags_vertical = 3
|
||||
mouse_filter = 2
|
||||
stream = ExtResource("2_video")
|
||||
expand = true
|
||||
|
||||
[node name="VideoAspect" type="AspectRatioContainer" parent="." unique_id=374854264]
|
||||
layout_mode = 1
|
||||
anchors_preset = 15
|
||||
anchor_right = 1.0
|
||||
anchor_bottom = 1.0
|
||||
grow_horizontal = 2
|
||||
grow_vertical = 2
|
||||
mouse_filter = 2
|
||||
ratio = 1.7778
|
||||
|
||||
[node name="VideoStreamPlayer" type="VideoStreamPlayer" parent="VideoAspect" unique_id=374854265]
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
size_flags_vertical = 3
|
||||
mouse_filter = 2
|
||||
stream = ExtResource("2_video")
|
||||
expand = true
|
||||
|
||||
[node name="CapeLayer" type="Control" parent="VideoAspect" unique_id=374854266]
|
||||
layout_mode = 2
|
||||
mouse_filter = 2
|
||||
|
||||
[node name="PlayButton" type="Button" parent="VideoAspect/CapeLayer" unique_id=743089847]
|
||||
layout_mode = 1
|
||||
anchors_preset = -1
|
||||
anchor_left = 0.453
|
||||
anchor_top = 0.6513
|
||||
anchor_right = 0.453
|
||||
anchor_bottom = 0.6513
|
||||
offset_left = -120.0
|
||||
offset_top = -55.0
|
||||
offset_right = 120.0
|
||||
offset_bottom = 55.0
|
||||
grow_horizontal = 2
|
||||
grow_vertical = 2
|
||||
theme_override_colors/font_color = Color(1, 1, 1, 1)
|
||||
theme_override_colors/font_outline_color = Color(0, 0, 0, 1)
|
||||
theme_override_constants/outline_size = 8
|
||||
theme_override_font_sizes/font_size = 56
|
||||
theme_override_styles/normal = SubResource("sb_normal")
|
||||
theme_override_styles/pressed = SubResource("sb_hover")
|
||||
theme_override_styles/hover = SubResource("sb_hover")
|
||||
theme_override_styles/focus = SubResource("sb_hover")
|
||||
text = "PLAY"
|
||||
|
||||
[node name="MainPanel" type="Control" parent="." unique_id=1263320918]
|
||||
layout_mode = 1
|
||||
anchors_preset = 15
|
||||
anchor_right = 1.0
|
||||
anchor_bottom = 1.0
|
||||
grow_horizontal = 2
|
||||
grow_vertical = 2
|
||||
mouse_filter = 2
|
||||
|
||||
[node name="OptionsButton" type="Button" parent="MainPanel" unique_id=673211179]
|
||||
layout_mode = 1
|
||||
anchors_preset = -1
|
||||
anchor_left = 0.11
|
||||
anchor_top = 0.94
|
||||
anchor_right = 0.11
|
||||
anchor_bottom = 0.94
|
||||
offset_left = -100.0
|
||||
offset_top = -28.0
|
||||
offset_right = 100.0
|
||||
offset_bottom = 28.0
|
||||
grow_horizontal = 2
|
||||
grow_vertical = 2
|
||||
theme_override_colors/font_color = Color(1, 1, 1, 1)
|
||||
theme_override_colors/font_outline_color = Color(0, 0, 0, 1)
|
||||
theme_override_constants/outline_size = 6
|
||||
theme_override_font_sizes/font_size = 30
|
||||
theme_override_styles/normal = SubResource("sb_normal")
|
||||
theme_override_styles/pressed = SubResource("sb_hover")
|
||||
theme_override_styles/hover = SubResource("sb_hover")
|
||||
theme_override_styles/focus = SubResource("sb_hover")
|
||||
text = "OPTIONS"
|
||||
|
||||
[node name="CreditsButton" type="Button" parent="MainPanel" unique_id=882339461]
|
||||
layout_mode = 1
|
||||
anchors_preset = -1
|
||||
anchor_left = 0.489
|
||||
anchor_top = 0.951
|
||||
anchor_right = 0.489
|
||||
anchor_bottom = 0.951
|
||||
offset_left = -100.0
|
||||
offset_top = -28.0
|
||||
offset_right = 100.0
|
||||
offset_bottom = 28.0
|
||||
grow_horizontal = 2
|
||||
grow_vertical = 2
|
||||
theme_override_colors/font_color = Color(1, 1, 1, 1)
|
||||
theme_override_colors/font_outline_color = Color(0, 0, 0, 1)
|
||||
theme_override_constants/outline_size = 6
|
||||
theme_override_font_sizes/font_size = 30
|
||||
theme_override_styles/normal = SubResource("sb_normal")
|
||||
theme_override_styles/pressed = SubResource("sb_hover")
|
||||
theme_override_styles/hover = SubResource("sb_hover")
|
||||
theme_override_styles/focus = SubResource("sb_hover")
|
||||
text = "CREDITS"
|
||||
|
||||
[node name="ExitButton" type="Button" parent="MainPanel" unique_id=1064586744]
|
||||
layout_mode = 1
|
||||
anchors_preset = -1
|
||||
anchor_left = 0.92
|
||||
anchor_top = 0.951
|
||||
anchor_right = 0.92
|
||||
anchor_bottom = 0.951
|
||||
offset_left = -100.0
|
||||
offset_top = -28.0
|
||||
offset_right = 100.0
|
||||
offset_bottom = 28.0
|
||||
grow_horizontal = 2
|
||||
grow_vertical = 2
|
||||
theme_override_colors/font_color = Color(1, 1, 1, 1)
|
||||
theme_override_colors/font_outline_color = Color(0, 0, 0, 1)
|
||||
theme_override_constants/outline_size = 6
|
||||
theme_override_font_sizes/font_size = 30
|
||||
theme_override_styles/normal = SubResource("sb_normal")
|
||||
theme_override_styles/pressed = SubResource("sb_hover")
|
||||
theme_override_styles/hover = SubResource("sb_hover")
|
||||
theme_override_styles/focus = SubResource("sb_hover")
|
||||
text = "EXIT"
|
||||
|
||||
[node name="OptionsPanel" type="CenterContainer" parent="." unique_id=593823446]
|
||||
visible = false
|
||||
layout_mode = 1
|
||||
anchors_preset = 15
|
||||
anchor_right = 1.0
|
||||
anchor_bottom = 1.0
|
||||
grow_horizontal = 2
|
||||
grow_vertical = 2
|
||||
|
||||
[node name="Backdrop" type="ColorRect" parent="OptionsPanel" unique_id=1811982549]
|
||||
layout_mode = 2
|
||||
mouse_filter = 2
|
||||
color = Color(0, 0, 0, 0.55)
|
||||
|
||||
[node name="VBox" type="VBoxContainer" parent="OptionsPanel" unique_id=2125052135]
|
||||
custom_minimum_size = Vector2(560, 480)
|
||||
layout_mode = 2
|
||||
theme_override_constants/separation = 16
|
||||
|
||||
[node name="TitleLabel" type="Label" parent="OptionsPanel/VBox" unique_id=853979265]
|
||||
layout_mode = 2
|
||||
theme_override_colors/font_outline_color = Color(0, 0, 0, 1)
|
||||
theme_override_constants/outline_size = 8
|
||||
theme_override_font_sizes/font_size = 44
|
||||
text = "Controls"
|
||||
horizontal_alignment = 1
|
||||
|
||||
[node name="Hint" type="Label" parent="OptionsPanel/VBox" unique_id=1809504670]
|
||||
layout_mode = 2
|
||||
theme_override_colors/font_outline_color = Color(0, 0, 0, 1)
|
||||
theme_override_constants/outline_size = 5
|
||||
theme_override_font_sizes/font_size = 20
|
||||
text = "Click a key, then press a new key. Esc cancels."
|
||||
horizontal_alignment = 1
|
||||
|
||||
[node name="Scroll" type="ScrollContainer" parent="OptionsPanel/VBox" unique_id=683738956]
|
||||
custom_minimum_size = Vector2(0, 340)
|
||||
layout_mode = 2
|
||||
size_flags_vertical = 3
|
||||
horizontal_scroll_mode = 0
|
||||
|
||||
[node name="RebindList" type="VBoxContainer" parent="OptionsPanel/VBox/Scroll" unique_id=1261520279]
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
theme_override_constants/separation = 8
|
||||
|
||||
[node name="BackButton" type="Button" parent="OptionsPanel/VBox" unique_id=1587524951]
|
||||
custom_minimum_size = Vector2(0, 48)
|
||||
layout_mode = 2
|
||||
theme_override_font_sizes/font_size = 24
|
||||
text = "Back"
|
||||
|
||||
[node name="CreditsPanel" type="CenterContainer" parent="." unique_id=175743902]
|
||||
visible = false
|
||||
layout_mode = 1
|
||||
anchors_preset = 15
|
||||
anchor_right = 1.0
|
||||
anchor_bottom = 1.0
|
||||
grow_horizontal = 2
|
||||
grow_vertical = 2
|
||||
|
||||
[node name="Backdrop" type="ColorRect" parent="CreditsPanel" unique_id=1950253735]
|
||||
layout_mode = 2
|
||||
mouse_filter = 2
|
||||
color = Color(0, 0, 0, 0.55)
|
||||
|
||||
[node name="VBox" type="VBoxContainer" parent="CreditsPanel" unique_id=901836808]
|
||||
custom_minimum_size = Vector2(520, 360)
|
||||
layout_mode = 2
|
||||
theme_override_constants/separation = 20
|
||||
|
||||
[node name="TitleLabel" type="Label" parent="CreditsPanel/VBox" unique_id=825593160]
|
||||
layout_mode = 2
|
||||
theme_override_colors/font_outline_color = Color(0, 0, 0, 1)
|
||||
theme_override_constants/outline_size = 8
|
||||
theme_override_font_sizes/font_size = 44
|
||||
text = "Credits"
|
||||
horizontal_alignment = 1
|
||||
|
||||
[node name="Names" type="RichTextLabel" parent="CreditsPanel/VBox" unique_id=1355198629]
|
||||
layout_mode = 2
|
||||
size_flags_vertical = 3
|
||||
theme_override_colors/default_color = Color(0.93, 0.88, 0.72, 1)
|
||||
theme_override_colors/font_outline_color = Color(0, 0, 0, 1)
|
||||
theme_override_constants/line_separation = 6
|
||||
theme_override_constants/outline_size = 5
|
||||
theme_override_font_sizes/normal_font_size = 26
|
||||
theme_override_font_sizes/bold_font_size = 30
|
||||
bbcode_enabled = true
|
||||
text = "[center][font_size=32][color=#d9a833]Bullosseum[/color][/font_size]
|
||||
|
||||
[b][color=#d9a833]Siim Raud[/color][/b] [url=https://instagram.com/siimpressionist]@siimpressionist[/url]
|
||||
[font_size=20]Artist / Animator[/font_size]
|
||||
|
||||
[b][color=#d9a833]Richard Aasa[/color][/b] [url=https://instagram.com/aasarichard]@aasarichard[/url]
|
||||
[font_size=20]Programmer[/font_size]
|
||||
|
||||
[b][color=#d9a833]Kadi Rebane[/color][/b] [url=https://x.com/_barkseven]@_barkseven[/url]
|
||||
[font_size=20]Arena asset[/font_size][/center]"
|
||||
fit_content = true
|
||||
scroll_active = false
|
||||
|
||||
[node name="BackButton" type="Button" parent="CreditsPanel/VBox" unique_id=1194791074]
|
||||
custom_minimum_size = Vector2(0, 48)
|
||||
layout_mode = 2
|
||||
theme_override_font_sizes/font_size = 24
|
||||
text = "Back"
|
||||
|
||||
[node name="FadeRect" type="ColorRect" parent="." unique_id=15381366]
|
||||
layout_mode = 1
|
||||
anchors_preset = 15
|
||||
anchor_right = 1.0
|
||||
anchor_bottom = 1.0
|
||||
grow_horizontal = 2
|
||||
grow_vertical = 2
|
||||
mouse_filter = 2
|
||||
color = Color(0, 0, 0, 0)
|
||||
@@ -0,0 +1,25 @@
|
||||
shader_type canvas_item;
|
||||
|
||||
// Blurred, darkened backdrop for the intro video. A COVER-scaled copy of the
|
||||
// clip sits behind the sharp fitted clip so portrait/ultrawide screens fill
|
||||
// edge-to-edge instead of showing dead bars.
|
||||
|
||||
uniform float blur_radius : hint_range(0.0, 8.0) = 3.0;
|
||||
uniform float darken : hint_range(0.0, 1.0) = 0.35;
|
||||
|
||||
void fragment() {
|
||||
vec2 px = TEXTURE_PIXEL_SIZE * blur_radius;
|
||||
vec4 sum = vec4(0.0);
|
||||
// 3x3 gaussian-ish tap, weights sum to 16.
|
||||
sum += texture(TEXTURE, UV + vec2(-px.x, -px.y)) * 1.0;
|
||||
sum += texture(TEXTURE, UV + vec2(0.0, -px.y)) * 2.0;
|
||||
sum += texture(TEXTURE, UV + vec2(px.x, -px.y)) * 1.0;
|
||||
sum += texture(TEXTURE, UV + vec2(-px.x, 0.0)) * 2.0;
|
||||
sum += texture(TEXTURE, UV) * 4.0;
|
||||
sum += texture(TEXTURE, UV + vec2(px.x, 0.0)) * 2.0;
|
||||
sum += texture(TEXTURE, UV + vec2(-px.x, px.y)) * 1.0;
|
||||
sum += texture(TEXTURE, UV + vec2(0.0, px.y)) * 2.0;
|
||||
sum += texture(TEXTURE, UV + vec2(px.x, px.y)) * 1.0;
|
||||
sum /= 16.0;
|
||||
COLOR = vec4(sum.rgb * (1.0 - darken), sum.a);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
uid://bmdtg6qlh7wri
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,49 @@
|
||||
class_name UiFonts
|
||||
extends RefCounted
|
||||
## Shared thematic fonts for menus and HUD. Cinzel is a Roman monumental capital
|
||||
## typeface — a deliberate nod to the "-osseum" (Colosseum) in Bullosseum.
|
||||
##
|
||||
## Fonts load via load_dynamic_font so they resolve identically in the editor,
|
||||
## headless tests and exported builds, whether or not the .ttf has been imported.
|
||||
|
||||
const _DECORATIVE := "res://art/fonts/CinzelDecorative-Bold.ttf"
|
||||
const _ROMAN := "res://art/fonts/Cinzel.ttf"
|
||||
|
||||
static var _title: FontFile = null
|
||||
static var _body: FontFile = null
|
||||
static var _body_bold: FontVariation = null
|
||||
|
||||
|
||||
## Ornate display face — big banners, menu title, win/lose text.
|
||||
static func title() -> FontFile:
|
||||
if _title == null:
|
||||
_title = _load(_DECORATIVE)
|
||||
return _title
|
||||
|
||||
|
||||
## Cleaner monumental face — buttons, labels, body copy.
|
||||
static func body() -> FontFile:
|
||||
if _body == null:
|
||||
_body = _load(_ROMAN)
|
||||
return _body
|
||||
|
||||
|
||||
## Emboldened Roman face for small, high-legibility bits (keycaps, badges) where
|
||||
## Cinzel's thin regular strokes wash out. Faked via FontVariation since only the
|
||||
## regular weight ships, so no extra .ttf is needed.
|
||||
static func body_bold() -> FontVariation:
|
||||
if _body_bold == null:
|
||||
_body_bold = FontVariation.new()
|
||||
_body_bold.base_font = body()
|
||||
_body_bold.variation_embolden = 0.5
|
||||
return _body_bold
|
||||
|
||||
|
||||
static func _load(path: String) -> FontFile:
|
||||
if ResourceLoader.exists(path):
|
||||
var res := load(path)
|
||||
if res is FontFile:
|
||||
return res as FontFile
|
||||
var f := FontFile.new()
|
||||
f.load_dynamic_font(path)
|
||||
return f
|
||||
@@ -0,0 +1 @@
|
||||
uid://dpnkwfi2ogmpx
|
||||
@@ -0,0 +1,84 @@
|
||||
class_name UiTheme
|
||||
extends RefCounted
|
||||
## Shared "engraved bronze plaque" look for every box in the game — aged gold on
|
||||
## dark leather, a chiselled heavier bottom edge and a warm drop shadow, so the HUD
|
||||
## and the menus read as the same arena furniture instead of a stack of generic
|
||||
## rounded panels. Pair with UiFonts (Cinzel) for the full Colosseum feel.
|
||||
|
||||
# ── Arena palette ───────────────────────────────────────────────────────────────
|
||||
const GOLD := Color(1.00, 0.78, 0.22) # aged gold — trim, key text
|
||||
const CREAM := Color(0.93, 0.88, 0.72) # warm highlight — hover / active
|
||||
const MUTED := Color(0.66, 0.60, 0.46) # faded bronze — secondary text
|
||||
const LEATHER := Color(0.12, 0.08, 0.03, 0.90) # panel fill
|
||||
const LEATHER_DARK := Color(0.06, 0.04, 0.02, 0.88) # deeper fill — inset badges
|
||||
const BORDER := Color(0.72, 0.54, 0.18, 0.90) # default gold-bronze trim
|
||||
const SHADOW := Color(0.00, 0.00, 0.00, 0.55)
|
||||
|
||||
|
||||
## The signature plaque: leather fill, gold-bronze trim with a heavier chiselled
|
||||
## bottom edge (weight sinks to the base like an engraved tablet), crisp corners and
|
||||
## a soft drop shadow. `accent` recolours the trim — pass a per-ability colour for
|
||||
## HUD tiles or CREAM for a hot/hover state; a transparent accent draws a frameless
|
||||
## plate (still shadowed).
|
||||
static func plaque(bg: Color, accent: Color = BORDER,
|
||||
corner: int = 1, h_margin: float = 16.0, v_margin: float = 8.0,
|
||||
shadow: bool = true) -> StyleBoxFlat:
|
||||
var s := StyleBoxFlat.new()
|
||||
s.bg_color = bg
|
||||
s.set_corner_radius_all(corner)
|
||||
s.corner_detail = 1
|
||||
if accent.a > 0.0:
|
||||
s.set_border_width_all(1)
|
||||
s.border_width_bottom = 2
|
||||
s.border_color = accent
|
||||
s.content_margin_left = h_margin
|
||||
s.content_margin_right = h_margin
|
||||
s.content_margin_top = v_margin
|
||||
s.content_margin_bottom = v_margin
|
||||
if shadow:
|
||||
s.shadow_color = SHADOW
|
||||
s.shadow_size = 5
|
||||
s.shadow_offset = Vector2(0.0, 3.0)
|
||||
return s
|
||||
|
||||
|
||||
## Small inset key-cap / badge: deep fill with a thin gold underline instead of a
|
||||
## full frame and no shadow, so it reads as pressed-in rather than raised.
|
||||
static func badge(h_margin: float = 8.0, v_margin: float = 2.0) -> StyleBoxFlat:
|
||||
var s := StyleBoxFlat.new()
|
||||
s.bg_color = LEATHER_DARK
|
||||
s.set_corner_radius_all(3)
|
||||
s.border_width_bottom = 2
|
||||
s.border_color = Color(GOLD.r, GOLD.g, GOLD.b, 0.55)
|
||||
s.content_margin_left = h_margin
|
||||
s.content_margin_right = h_margin
|
||||
s.content_margin_top = v_margin
|
||||
s.content_margin_bottom = v_margin
|
||||
return s
|
||||
|
||||
|
||||
## Round token for the run map — a filled disc with an even border and the shared warm
|
||||
## shadow. Unlike plaque()'s chiselled tablet the border is uniform, so it reads as a
|
||||
## marker rather than a plate. A large corner radius clamps to a full circle on any pip
|
||||
## size. Keeps map pips inside the single styling source instead of a hand-rolled box.
|
||||
static func disc(fill: Color, border: Color, border_w: int = 3) -> StyleBoxFlat:
|
||||
var s := StyleBoxFlat.new()
|
||||
s.bg_color = fill
|
||||
s.set_corner_radius_all(999)
|
||||
s.corner_detail = 8
|
||||
s.set_border_width_all(border_w)
|
||||
s.border_color = border
|
||||
s.shadow_color = SHADOW
|
||||
s.shadow_size = 6
|
||||
s.shadow_offset = Vector2(0.0, 3.0)
|
||||
return s
|
||||
|
||||
|
||||
## Thin gold rule for separating rows inside a plaque — brighter at the centre than
|
||||
## the surrounding trim so it reads as an inlaid line.
|
||||
static func rule() -> StyleBoxFlat:
|
||||
var s := StyleBoxFlat.new()
|
||||
s.bg_color = Color(GOLD.r, GOLD.g, GOLD.b, 0.35)
|
||||
s.content_margin_top = 5.0
|
||||
s.content_margin_bottom = 5.0
|
||||
return s
|
||||
@@ -0,0 +1 @@
|
||||
uid://bpi5bsauh1wie
|
||||
@@ -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