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 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_label: Label
var cost_card: PanelContainer
@@ -134,32 +135,53 @@ func _build_machine(parent: Control) -> void:
reels_grid.add_theme_constant_override("separation", 16)
center.add_child(reels_grid)
reel_columns.clear()
var cell := 66.0
reel_states.clear()
for _column_index in range(3):
var reel_col := VBoxContainer.new()
reel_col.add_theme_constant_override("separation", 10)
reels_grid.add_child(reel_col)
var cells: Array = []
for _row_index in range(3):
var clip := Control.new()
clip.clip_contents = true
clip.custom_minimum_size = Vector2(cell, cell * 3)
reels_grid.add_child(clip)
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()
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.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
reel_col.add_child(icon)
cells.append(icon)
reel_columns.append(cells)
strip.add_child(icon)
icons.append(icon)
var lever := TextureRect.new()
lever.texture = LEVER_TEXTURE
lever.anchor_left = 0.80
lever.anchor_right = 1.06
lever.anchor_top = 0.24
lever.anchor_bottom = 0.88
lever.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
lever.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT
lever.mouse_filter = Control.MOUSE_FILTER_IGNORE
machine_root.add_child(lever)
reel_states.append({
"strip": strip,
"icons": icons,
"step": cell,
"speed": 620.0,
"spinning": false,
"stop_requested": false,
"target_texture": null,
"recycles_after_target": -1,
"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:
@@ -431,7 +453,20 @@ func _build_reward_row(dark: bool, reward: Dictionary) -> HBoxContainer:
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:
@@ -451,13 +486,33 @@ func _on_spin_pressed() -> void:
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()
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.add_theme_color_override("font_color", Brand.MAGENTA)
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 coins_won := int(result.get("coins_won", 0))
status_label.text = "You won: %s" % reward_label
@@ -471,6 +526,24 @@ func _do_one_spin() -> bool:
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:
var result := {}
var on_resolved := func(reward_key: String, reward_label: String, coins_won: int):
@@ -492,23 +565,33 @@ func _await_spin_result() -> Dictionary:
return result
func _animate_reels() -> void:
for index in range(reel_columns.size()):
_spin_one_reel(reel_columns[index], 0.9 + index * 0.25)
func _spin_one_reel(cells: Array, duration: float) -> void:
var elapsed := 0.0
var interval := 0.06
while elapsed < duration:
var symbol: Texture2D = REEL_SYMBOLS[randi() % REEL_SYMBOLS.size()]
for cell in cells:
cell.texture = symbol
await get_tree().create_timer(interval).timeout
elapsed += interval
interval = minf(interval * 1.18, 0.22)
for cell in cells:
cell.texture = SLOT_COIN
func _spin_reel(state: Dictionary) -> void:
state["spinning"] = true
var strip: VBoxContainer = state["strip"]
var icons: Array = state["icons"]
var step: float = state["step"]
var speed: float = state["speed"]
while true:
var current_speed: float = speed if state["recycles_after_target"] == -1 else speed * 0.45
strip.position.y -= current_speed * get_process_delta_time()
if strip.position.y <= -step:
strip.position.y += step
var front: TextureRect = icons.pop_front()
strip.move_child(front, strip.get_child_count() - 1)
icons.append(front)
if state["stop_requested"] and state["recycles_after_target"] == -1:
front.texture = state["target_texture"]
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: