Fix slot machine: real spin animation, working lever, honest reels

Three real bugs from user testing:
- The lever was a plain TextureRect with mouse_filter set to IGNORE -
  it visually looked interactive but could never receive a click.
  Swapped it for a TextureButton wired to the same spin action, plus
  a quick pull-and-spring-back rotation for tactile feedback.
- The "reel animation" was just rapidly swapping each cell's texture
  in place - no actual motion. Reels are now real scrolling strips
  (a clipped viewport + a taller VBoxContainer strip that scrolls and
  recycles its front icon to the back), which is what an actual
  spinning reel look/feel requires.
- Every reel unconditionally settled on the coin icon regardless of
  what was actually won, so a jackpot and a QBank Reset Token looked
  identical - the two facts users need to trust the mechanic. Reels
  now settle with the real reward's icon landing in the middle
  (payline) row on all three columns, with random decorative icons
  above/below, so the visual result always matches the actual payout.

Verified the settle math directly (a reel-only probe confirming the
middle icon matches the requested target after recycling) and the
lever's click wiring (confirms _spinning toggles and the disabled
state round-trips), plus a real rendered screenshot of a settled
spin, before cleaning up the probes.
This commit is contained in:
hz
2026-07-30 04:26:42 -05:00
parent 09502944ec
commit a58aa401c8
+123 -40
View File
@@ -28,7 +28,8 @@ var main: Control
var subtitle_label: Label var subtitle_label: Label
var machine_root: Control var machine_root: Control
var reel_columns: Array = [] var lever_button: TextureButton
var reel_states: Array = []
var win_up_to_card: PanelContainer var win_up_to_card: PanelContainer
var win_up_to_label: Label var win_up_to_label: Label
var cost_card: PanelContainer var cost_card: PanelContainer
@@ -134,32 +135,53 @@ func _build_machine(parent: Control) -> void:
reels_grid.add_theme_constant_override("separation", 16) reels_grid.add_theme_constant_override("separation", 16)
center.add_child(reels_grid) center.add_child(reels_grid)
reel_columns.clear() var cell := 66.0
reel_states.clear()
for _column_index in range(3): for _column_index in range(3):
var reel_col := VBoxContainer.new() var clip := Control.new()
reel_col.add_theme_constant_override("separation", 10) clip.clip_contents = true
reels_grid.add_child(reel_col) clip.custom_minimum_size = Vector2(cell, cell * 3)
var cells: Array = [] reels_grid.add_child(clip)
for _row_index in range(3):
var strip := VBoxContainer.new()
strip.add_theme_constant_override("separation", 0)
clip.add_child(strip)
var icons: Array = []
for _row_index in range(4):
var icon := TextureRect.new() var icon := TextureRect.new()
icon.texture = REEL_SYMBOLS[randi() % REEL_SYMBOLS.size()] icon.texture = REEL_SYMBOLS[randi() % REEL_SYMBOLS.size()]
icon.custom_minimum_size = Vector2(66, 66) icon.custom_minimum_size = Vector2(cell, cell)
icon.expand_mode = TextureRect.EXPAND_IGNORE_SIZE icon.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
icon.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED icon.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
reel_col.add_child(icon) strip.add_child(icon)
cells.append(icon) icons.append(icon)
reel_columns.append(cells)
var lever := TextureRect.new() reel_states.append({
lever.texture = LEVER_TEXTURE "strip": strip,
lever.anchor_left = 0.80 "icons": icons,
lever.anchor_right = 1.06 "step": cell,
lever.anchor_top = 0.24 "speed": 620.0,
lever.anchor_bottom = 0.88 "spinning": false,
lever.expand_mode = TextureRect.EXPAND_IGNORE_SIZE "stop_requested": false,
lever.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT "target_texture": null,
lever.mouse_filter = Control.MOUSE_FILTER_IGNORE "recycles_after_target": -1,
machine_root.add_child(lever) "done": true,
})
lever_button = TextureButton.new()
lever_button.texture_normal = LEVER_TEXTURE
lever_button.ignore_texture_size = true
lever_button.stretch_mode = TextureButton.STRETCH_KEEP_ASPECT_CENTERED
lever_button.anchor_left = 0.80
lever_button.anchor_right = 1.06
lever_button.anchor_top = 0.24
lever_button.anchor_bottom = 0.88
lever_button.mouse_default_cursor_shape = Control.CURSOR_POINTING_HAND
lever_button.tooltip_text = "Pull to spin"
lever_button.resized.connect(func(): lever_button.pivot_offset = Vector2(lever_button.size.x * 0.5, lever_button.size.y * 0.92))
lever_button.pressed.connect(_on_lever_pulled)
machine_root.add_child(lever_button)
func _build_controls(parent: Control) -> void: func _build_controls(parent: Control) -> void:
@@ -431,7 +453,20 @@ func _build_reward_row(dark: bool, reward: Dictionary) -> HBoxContainer:
func _refresh_spin_button() -> void: func _refresh_spin_button() -> void:
spin_button.disabled = _spinning or CreditsService.credits < CreditsService.spin_cost var disabled := _spinning or CreditsService.credits < CreditsService.spin_cost
spin_button.disabled = disabled
lever_button.disabled = disabled
func _on_lever_pulled() -> void:
_play_lever_pull_animation()
_on_spin_pressed()
func _play_lever_pull_animation() -> void:
var tween := create_tween()
tween.tween_property(lever_button, "rotation_degrees", -20.0, 0.1).set_trans(Tween.TRANS_QUAD).set_ease(Tween.EASE_OUT)
tween.tween_property(lever_button, "rotation_degrees", 0.0, 0.3).set_trans(Tween.TRANS_ELASTIC).set_ease(Tween.EASE_OUT)
func _on_spin_pressed() -> void: func _on_spin_pressed() -> void:
@@ -451,13 +486,33 @@ func _on_spin_pressed() -> void:
func _do_one_spin() -> bool: func _do_one_spin() -> bool:
_animate_reels() for state in reel_states:
state["stop_requested"] = false
state["target_texture"] = null
state["recycles_after_target"] = -1
state["done"] = false
_spin_reel(state)
var result := await _await_spin_result() var result := await _await_spin_result()
if not result.get("ok", false): if not result.get("ok", false):
for state in reel_states:
state["target_texture"] = REEL_SYMBOLS[randi() % REEL_SYMBOLS.size()]
state["stop_requested"] = true
await _await_reels_done()
status_label.text = str(result.get("message", "The spin could not be completed.")) status_label.text = str(result.get("message", "The spin could not be completed."))
status_label.add_theme_color_override("font_color", Brand.MAGENTA) status_label.add_theme_color_override("font_color", Brand.MAGENTA)
return false return false
var reward_key := str(result.get("reward_key", ""))
var reward_texture := _icon_for_reward(reward_key)
for index in range(reel_states.size()):
var state: Dictionary = reel_states[index]
get_tree().create_timer(index * 0.25).timeout.connect(func():
state["target_texture"] = reward_texture
state["stop_requested"] = true)
await _await_reels_done()
var reward_label := str(result.get("reward_label", "")) var reward_label := str(result.get("reward_label", ""))
var coins_won := int(result.get("coins_won", 0)) var coins_won := int(result.get("coins_won", 0))
status_label.text = "You won: %s" % reward_label status_label.text = "You won: %s" % reward_label
@@ -471,6 +526,24 @@ func _do_one_spin() -> bool:
return true return true
func _icon_for_reward(key: String) -> Texture2D:
var spec: Dictionary = REWARD_ICONS.get(key, {})
var texture: Texture2D = spec.get("texture", SLOT_COIN)
return texture if texture != null else SLOT_COIN
func _await_reels_done() -> void:
while true:
var all_done := true
for state in reel_states:
if not state.get("done", false):
all_done = false
break
if all_done:
return
await get_tree().process_frame
func _await_spin_result() -> Dictionary: func _await_spin_result() -> Dictionary:
var result := {} var result := {}
var on_resolved := func(reward_key: String, reward_label: String, coins_won: int): var on_resolved := func(reward_key: String, reward_label: String, coins_won: int):
@@ -492,23 +565,33 @@ func _await_spin_result() -> Dictionary:
return result return result
func _animate_reels() -> void: func _spin_reel(state: Dictionary) -> void:
for index in range(reel_columns.size()): state["spinning"] = true
_spin_one_reel(reel_columns[index], 0.9 + index * 0.25) var strip: VBoxContainer = state["strip"]
var icons: Array = state["icons"]
var step: float = state["step"]
func _spin_one_reel(cells: Array, duration: float) -> void: var speed: float = state["speed"]
var elapsed := 0.0 while true:
var interval := 0.06 var current_speed: float = speed if state["recycles_after_target"] == -1 else speed * 0.45
while elapsed < duration: strip.position.y -= current_speed * get_process_delta_time()
var symbol: Texture2D = REEL_SYMBOLS[randi() % REEL_SYMBOLS.size()] if strip.position.y <= -step:
for cell in cells: strip.position.y += step
cell.texture = symbol var front: TextureRect = icons.pop_front()
await get_tree().create_timer(interval).timeout strip.move_child(front, strip.get_child_count() - 1)
elapsed += interval icons.append(front)
interval = minf(interval * 1.18, 0.22) if state["stop_requested"] and state["recycles_after_target"] == -1:
for cell in cells: front.texture = state["target_texture"]
cell.texture = SLOT_COIN state["recycles_after_target"] = 0
else:
front.texture = REEL_SYMBOLS[randi() % REEL_SYMBOLS.size()]
if state["recycles_after_target"] >= 0:
state["recycles_after_target"] += 1
if state["recycles_after_target"] == 2:
strip.position.y = 0.0
break
await get_tree().process_frame
state["spinning"] = false
state["done"] = true
func _play_coin_burst(coins_won: int) -> void: func _play_coin_burst(coins_won: int) -> void: