Files
step3/scripts/ui/ccs_cases_2/slot_machine.gd
T

771 lines
27 KiB
GDScript

extends Control
signal number_change_finished(value: int)
signal spin_finished(symbols: Array[int], payout_multiplier: float)
signal continue_requested
@export var icon_reels: Array[Control]
@export var number_reels: Array[Control]
@export_range(-40.0, 0.0, 0.5) var click_volume_db := -11.0
@export_range(0.0, 0.2, 0.005) var pitch_variation := 0.055
@export_range(-40.0, -10.0, 0.5) var reel_whir_volume_db := -30.0
@export_range(0.0, 1.5, 0.05) var extra_icon_reel_stop_delay := 0.4
@export_range(0.1, 2.0, 0.05) var number_roll_duration := 0.6
@export_category("Slot Math")
@export_range(0.0, 1.0, 0.001) var target_rtp := 0.96
@export var symbol_names := PackedStringArray([
"Star", "Diamond", "Potion", "Shield", "Chips", "Gift"
])
@export var symbol_payout_min := PackedInt32Array([1, 50, 400, 300, 5, 0])
@export var symbol_payout_max := PackedInt32Array([1, 100, 400, 300, 10, 0])
@export var symbol_rtp_shares := PackedFloat32Array([
0.25686958, 0.15625, 0.16666667, 0.15625, 0.20833333, 0.05563042
])
@export var loss_symbol_weights := PackedFloat32Array([1.0, 2.0, 4.0, 6.0, 10.0, 3.0])
@export_range(0.0, 1.0, 0.01) var near_miss_rate := 0.15
@export_category("Early Session Study")
@export_range(0, 20, 1) var early_boost_spin_count := 5
@export_range(0.0, 1.0, 0.01) var early_blue_stack_chance := 0.20
const CLICK_SOUNDS: Array[AudioStream] = [
preload("res://assets/ccs_cases_2/audio/CLICK-01.wav"),
preload("res://assets/ccs_cases_2/audio/CLICK-02.wav"),
preload("res://assets/ccs_cases_2/audio/CLICK-03.wav"),
preload("res://assets/ccs_cases_2/audio/CLICK-04.wav"),
]
# The recordings contain different amounts of silence before their transients.
# Starting here preserves a tiny lead-in while keeping the impact synchronized.
const CLICK_START_OFFSETS: Array[float] = [0.040, 0.039, 0.052, 0.061]
const STOP_CLICK: AudioStream = preload("res://assets/ccs_cases_2/audio/LOUDCLICK-01.wav")
# This recording has about 167 ms of silence before its impact.
const STOP_CLICK_START_OFFSET := 0.167
const STOP_FLAP: AudioStream = preload("res://assets/ccs_cases_2/audio/DEEPFLAP-01.wav")
# The deep layer has about 159 ms of silence before its transient.
const STOP_FLAP_START_OFFSET := 0.159
const SPIN_START_THUMP: AudioStream = preload("res://assets/ccs_cases_2/audio/THUMP-01.wav")
const LEVER_THWAP: AudioStream = preload("res://assets/ccs_cases_2/audio/MAGNET-THWAP-01.wav")
const LEVER_THWAP_START_OFFSET := 0.110
const REEL_WHIR: AudioStream = preload("res://assets/ccs_cases_2/audio/FAN-WHIR-01.wav")
const REEL_WHIR_START_OFFSET := 1.72
const CHIP_SOUNDS: Array[AudioStream] = [
preload("res://assets/ccs_cases_2/audio/CHIPS-01.wav"),
preload("res://assets/ccs_cases_2/audio/CHIPS-02.wav"),
preload("res://assets/ccs_cases_2/audio/CHIPS-03.wav"),
preload("res://assets/ccs_cases_2/audio/CHIPS-04.wav"),
]
const CHIP_START_OFFSETS: Array[float] = [0.224, 0.154, 0.201, 0.261]
const AUDIO_POOL_SIZE := 16
const SYMBOL_COUNT := 6
const SINGLE_COIN_SYMBOL := 0
const BLUE_COIN_STACK_SYMBOL := 4
const PRESENT_SYMBOL := 5
const LEVER_FRAME_POSITIONS: Array[Vector2] = [
Vector2(19.0, -105.0),
Vector2(23.0, -106.0),
Vector2(12.0, -82.0),
Vector2(12.0, -90.0),
Vector2(5.0, -90.0),
]
var values: Array[int] = []
var reel_tweens: Array[Tween] = []
var number_tweens: Array[Tween] = []
var number_value := 100
var number_animation_active := false
var audio_pool: Array[AudioStreamPlayer] = []
var next_audio_player := 0
var last_click_sound := -1
var last_chip_sound := -1
var chip_count_tween: Tween
var reel_whir_player: AudioStreamPlayer
var reel_whir_tween: Tween
var win_effect_tween: Tween
var spin_in_progress := false
var stopped_icon_reels := 0
var selected_payout := 0.0
var selected_near_miss := false
var selected_early_session_boost := false
var session_spins_started := 0
var early_stack_bonus_consumed := false
var rng := RandomNumberGenerator.new()
var lever_tween: Tween
var lever_foley_tween: Tween
var lever_animating := false
@onready var spin_result_label: Label = $SpinResultLabel
@onready var lever_pivot: Node2D = $LeverPivot
@onready var lever_sprite: AnimatedSprite2D = $LeverPivot/LeverSprite
@onready var lever_button: Button = $LeverButton
func _ready() -> void:
rng.randomize()
reel_whir_player = AudioStreamPlayer.new()
reel_whir_player.name = "ReelWhirPlayer"
reel_whir_player.stream = REEL_WHIR
add_child(reel_whir_player)
# A small voice pool lets nearby reel clicks overlap without cutting each other off.
for i in AUDIO_POOL_SIZE:
var player := AudioStreamPlayer.new()
player.name = "ReelClickVoice%d" % (i + 1)
player.volume_db = click_volume_db
add_child(player)
audio_pool.append(player)
_apply_number_immediately(number_value)
func spin() -> void:
if spin_in_progress:
return
var configuration_error := _get_slot_math_error()
if not configuration_error.is_empty():
push_error(configuration_error)
spin_result_label.text = "CONFIG ERROR"
return
if number_value <= 0:
spin_result_label.text = "NO CREDITS"
return
_reset_win_effect()
for active_tween in reel_tweens:
if active_tween and active_tween.is_valid():
active_tween.kill()
reel_tweens.clear()
var early_session_eligible := (
not early_stack_bonus_consumed
and session_spins_started < early_boost_spin_count
)
var outcome := _select_spin_outcome(early_session_eligible)
session_spins_started += 1
values = outcome.symbols
if (
values[0] == BLUE_COIN_STACK_SYMBOL
and values[0] == values[1]
and values[1] == values[2]
):
early_stack_bonus_consumed = true
selected_payout = outcome.payout
selected_near_miss = outcome.is_near_miss
selected_early_session_boost = outcome.early_session_boost
stopped_icon_reels = 0
spin_in_progress = true
lever_button.disabled = true
spin_result_label.text = "Spinning..."
add_to_number(-1)
_play_spin_start()
_play_short_reel_whir()
for i in icon_reels.size():
var spinner := icon_reels[i]
var n_rows := int(spinner.material.get_shader_parameter("n_rows"))
var result := values[i]
var step := 1.0 / float(n_rows)
var start := float(spinner.material.get_shader_parameter("y_offset"))
# Several full downward rotations plus the predetermined final result.
# Negative texture offset moves this vertical strip downward on screen.
var rotations := randi_range(4, 7)
var target := floorf(start) - rotations + result * step
# Later reels stop later, with slight randomness.
var duration := 1.8 + i * 0.55 + randf_range(0.1, 0.45)
# Open up the spacing between the three prominent icon-reel stops without
# changing reel 1 or the timing of the smaller number reels.
if i < 3:
duration += i * extra_icon_reel_stop_delay
var tick_state := {
"last_row": floori(start * n_rows),
"start": start,
"distance": target - start,
"final_row": floori(target * n_rows),
}
var reel_tween := create_tween()
reel_tweens.append(reel_tween)
reel_tween.tween_method(
func(value: float) -> void:
spinner.material.set_shader_parameter("y_offset", value)
var current_row := floori(value * n_rows)
if current_row != tick_state.last_row:
tick_state.last_row = current_row
# The final detent is scheduled separately to compensate for
# audio-device latency; do not also play it at the endpoint.
if current_row != tick_state.final_row:
var progress := clampf(
(value - tick_state.start) / tick_state.distance,
0.0,
1.0
)
_play_reel_click(progress, i),
start,
target,
duration
).set_trans(Tween.TRANS_CUBIC).set_ease(Tween.EASE_OUT)
reel_tween.tween_callback(func() -> void:
spinner.material.set_shader_parameter("y_offset", fmod(target, 1.0))
stopped_icon_reels += 1
if stopped_icon_reels == icon_reels.size():
_complete_spin()
)
if i < 3:
# Audio reaches the speakers after the engine submits it. Start the stop
# sound early by the latency reported for the active audio device so its
# transient reaches the listener at the visual tween endpoint.
var audio_latency := AudioServer.get_output_latency()
audio_latency += AudioServer.get_time_to_next_mix()
var stop_click_tween := create_tween()
reel_tweens.append(stop_click_tween)
stop_click_tween.tween_interval(maxf(duration - audio_latency, 0.0))
stop_click_tween.tween_callback(_play_stop_click)
func _select_spin_outcome(allow_early_session_boost: bool = false) -> Dictionary:
if allow_early_session_boost and rng.randf() < early_blue_stack_chance:
return {
"symbols": [
BLUE_COIN_STACK_SYMBOL,
BLUE_COIN_STACK_SYMBOL,
BLUE_COIN_STACK_SYMBOL,
] as Array[int],
"payout": _roll_symbol_payout(BLUE_COIN_STACK_SYMBOL),
"is_near_miss": false,
"early_session_boost": true,
}
var roll := rng.randf()
var cumulative_probability := 0.0
var share_total := _sum_positive(symbol_rtp_shares)
for symbol_index in SYMBOL_COUNT:
var normalized_share := maxf(symbol_rtp_shares[symbol_index], 0.0) / share_total
var win_probability := target_rtp * normalized_share / _get_symbol_model_value(symbol_index)
cumulative_probability += win_probability
if roll < cumulative_probability:
return {
"symbols": [symbol_index, symbol_index, symbol_index] as Array[int],
"payout": _roll_symbol_payout(symbol_index),
"is_near_miss": false,
"early_session_boost": false,
}
if rng.randf() < near_miss_rate:
var near_miss_symbol := _pick_near_miss_symbol()
# With the current downward travel, the preceding symbol on the cyclic
# strip stops one position beyond the matching symbol.
var adjacent_symbol := posmod(near_miss_symbol - 1, SYMBOL_COUNT)
return {
"symbols": [near_miss_symbol, near_miss_symbol, adjacent_symbol] as Array[int],
"payout": 0.0,
"is_near_miss": true,
"early_session_boost": false,
}
var losing_symbols: Array[int] = []
while true:
losing_symbols = [
_pick_weighted_symbol(loss_symbol_weights),
_pick_weighted_symbol(loss_symbol_weights),
_pick_weighted_symbol(loss_symbol_weights),
]
# First-two matches are reserved for the explicit near-miss outcome so
# its configured rate remains exact and measurable.
if losing_symbols[0] != losing_symbols[1]:
break
return {
"symbols": losing_symbols,
"payout": 0.0,
"is_near_miss": false,
"early_session_boost": false,
}
func reset_early_session_study() -> void:
session_spins_started = 0
early_stack_bonus_consumed = false
func _pick_weighted_symbol(weights: PackedFloat32Array) -> int:
var total := _sum_positive(weights)
var roll := rng.randf() * total
var cumulative := 0.0
for i in SYMBOL_COUNT:
cumulative += maxf(weights[i], 0.0)
if roll < cumulative:
return i
return SYMBOL_COUNT - 1
func _pick_near_miss_symbol() -> int:
var total := 0.0
for i in range(1, SYMBOL_COUNT):
total += maxf(loss_symbol_weights[i], 0.0)
var roll := rng.randf() * total
var cumulative := 0.0
for i in range(1, SYMBOL_COUNT):
cumulative += maxf(loss_symbol_weights[i], 0.0)
if roll < cumulative:
return i
return SYMBOL_COUNT - 1
func _complete_spin() -> void:
if values[0] == PRESENT_SYMBOL and values[0] == values[1] and values[1] == values[2]:
spin_result_label.text = "PRESENT — no award"
elif selected_payout > 0.0:
var symbol_name := symbol_names[values[0]]
spin_result_label.text = "WIN %sx — %s" % [_format_multiplier(selected_payout), symbol_name.to_upper()]
_play_win_celebration()
return
else:
spin_result_label.text = "No win"
_finish_spin()
func _play_win_celebration() -> void:
_reset_win_effect()
win_effect_tween = create_tween()
win_effect_tween.tween_interval(0.10)
win_effect_tween.tween_method(_set_win_intensity, 0.0, 1.0, 0.10).set_trans(
Tween.TRANS_QUAD
).set_ease(Tween.EASE_OUT)
win_effect_tween.tween_method(_set_win_intensity, 1.0, 0.35, 0.13).set_trans(
Tween.TRANS_QUAD
).set_ease(Tween.EASE_IN)
win_effect_tween.tween_method(_set_win_intensity, 0.35, 0.85, 0.14).set_trans(
Tween.TRANS_SINE
).set_ease(Tween.EASE_OUT)
win_effect_tween.tween_callback(_award_selected_win)
win_effect_tween.tween_method(_set_win_intensity, 0.85, 0.0, 0.30).set_trans(
Tween.TRANS_SINE
).set_ease(Tween.EASE_IN_OUT)
win_effect_tween.tween_callback(_finish_spin)
func _award_selected_win() -> void:
if values[0] == BLUE_COIN_STACK_SYMBOL:
_play_chip_count(int(roundf(selected_payout)))
_award_counter_for_symbol(values[0], selected_payout)
func _set_win_intensity(intensity: float) -> void:
for reel in icon_reels:
reel.material.set_shader_parameter("win_intensity", intensity)
func _reset_win_effect() -> void:
if win_effect_tween and win_effect_tween.is_valid():
win_effect_tween.kill()
win_effect_tween = null
_set_win_intensity(0.0)
func _finish_spin() -> void:
_set_win_intensity(0.0)
spin_in_progress = false
if not lever_animating:
lever_button.disabled = false
spin_finished.emit(values.duplicate(), selected_payout)
func pull_lever() -> void:
if lever_animating or spin_in_progress:
return
lever_animating = true
lever_button.disabled = true
if lever_tween and lever_tween.is_valid():
lever_tween.kill()
if lever_foley_tween and lever_foley_tween.is_valid():
lever_foley_tween.kill()
var audio_latency := AudioServer.get_output_latency()
audio_latency += AudioServer.get_time_to_next_mix()
lever_foley_tween = create_tween()
lever_foley_tween.tween_interval(maxf(0.28 - audio_latency, 0.0))
lever_foley_tween.tween_callback(_play_lever_thwap)
lever_tween = create_tween()
lever_tween.tween_method(_set_lever_pull_progress, 0.0, 1.0, 0.28).set_trans(
Tween.TRANS_CUBIC
).set_ease(Tween.EASE_IN)
lever_tween.tween_callback(spin)
lever_tween.tween_interval(0.08)
lever_tween.tween_method(_set_lever_pull_progress, 1.0, 0.0, 0.46).set_trans(
Tween.TRANS_BACK
).set_ease(Tween.EASE_OUT)
lever_tween.tween_callback(func() -> void:
lever_animating = false
if not spin_in_progress:
lever_button.disabled = false
)
func _play_lever_thwap() -> void:
var player := audio_pool[next_audio_player]
next_audio_player = (next_audio_player + 1) % audio_pool.size()
player.stream = LEVER_THWAP
player.pitch_scale = 1.0 + randf_range(-pitch_variation * 0.3, pitch_variation * 0.3)
player.volume_db = click_volume_db + 1.5
player.play(LEVER_THWAP_START_OFFSET)
func _play_chip_count(point_count: int) -> void:
if chip_count_tween and chip_count_tween.is_valid():
chip_count_tween.kill()
chip_count_tween = create_tween()
for point_index in point_count:
chip_count_tween.tween_callback(
_play_chip_point.bind(point_index, point_count)
)
if point_index < point_count - 1:
var progress := float(point_index) / maxf(float(point_count - 1), 1.0)
chip_count_tween.tween_interval(lerpf(0.105, 0.072, progress))
func _play_chip_point(point_index: int, point_count: int) -> void:
var sound_index := rng.randi_range(0, CHIP_SOUNDS.size() - 2)
if last_chip_sound >= 0 and sound_index >= last_chip_sound:
sound_index += 1
last_chip_sound = sound_index
var player := audio_pool[next_audio_player]
next_audio_player = (next_audio_player + 1) % audio_pool.size()
player.stream = CHIP_SOUNDS[sound_index]
var count_progress := float(point_index) / maxf(float(point_count - 1), 1.0)
player.pitch_scale = lerpf(0.96, 1.08, count_progress) + randf_range(-0.012, 0.012)
# Individual recordings have long tails, so keep each voice restrained as
# the sequence builds into the familiar overlapping chip-count texture.
player.volume_db = click_volume_db - 3.0 + randf_range(-0.6, 0.4)
player.play(CHIP_START_OFFSETS[sound_index])
func _play_short_reel_whir() -> void:
if reel_whir_tween and reel_whir_tween.is_valid():
reel_whir_tween.kill()
reel_whir_player.stop()
reel_whir_player.volume_db = reel_whir_volume_db
reel_whir_player.pitch_scale = 0.92 + randf_range(-0.01, 0.01)
# Use only the recording's naturally decelerating tail, then fade it before
# it can compete with the individual reel clicks.
reel_whir_player.play(REEL_WHIR_START_OFFSET)
reel_whir_tween = create_tween()
reel_whir_tween.tween_interval(0.6)
reel_whir_tween.tween_property(reel_whir_player, "volume_db", -60.0, 0.35)
reel_whir_tween.tween_callback(reel_whir_player.stop)
func _set_lever_pull_progress(progress: float) -> void:
# The generated poses contain real perspective changes. Per-frame offsets keep
# their slightly different mounting sockets anchored to the cabinet pivot.
lever_pivot.rotation = 0.0
var frame_index := clampi(roundi(progress * 4.0), 0, 4)
lever_sprite.frame = frame_index
lever_sprite.position = LEVER_FRAME_POSITIONS[frame_index]
func _award_counter_for_symbol(symbol_index: int, payout: float) -> void:
match symbol_index:
SINGLE_COIN_SYMBOL:
add_to_number(int(roundf(payout)))
BLUE_COIN_STACK_SYMBOL:
add_to_number(int(roundf(payout)))
func _roll_symbol_payout(symbol_index: int) -> float:
if symbol_index == PRESENT_SYMBOL:
return 0.0
return float(rng.randi_range(symbol_payout_min[symbol_index], symbol_payout_max[symbol_index]))
func _get_symbol_model_value(symbol_index: int) -> float:
if symbol_index == PRESENT_SYMBOL:
var other_symbol_total := 0.0
for i in PRESENT_SYMBOL:
other_symbol_total += _get_symbol_expected_payout(i)
return other_symbol_total / float(PRESENT_SYMBOL)
return _get_symbol_expected_payout(symbol_index)
func _get_symbol_expected_payout(symbol_index: int) -> float:
return (float(symbol_payout_min[symbol_index]) + float(symbol_payout_max[symbol_index])) * 0.5
func _format_multiplier(multiplier: float) -> String:
if is_equal_approx(multiplier, roundf(multiplier)):
return str(int(roundf(multiplier)))
return "%.2f" % multiplier
func get_theoretical_rtp() -> float:
if not _get_slot_math_error().is_empty():
return 0.0
var share_total := _sum_positive(symbol_rtp_shares)
var theoretical_rtp := 0.0
for i in SYMBOL_COUNT:
var normalized_share := maxf(symbol_rtp_shares[i], 0.0) / share_total
var model_value := _get_symbol_model_value(i)
var probability := target_rtp * normalized_share / model_value
theoretical_rtp += probability * model_value
return theoretical_rtp
func get_theoretical_credited_rtp() -> float:
if not _get_slot_math_error().is_empty():
return 0.0
var share_total := _sum_positive(symbol_rtp_shares)
var credited_rtp := 0.0
for i in [SINGLE_COIN_SYMBOL, BLUE_COIN_STACK_SYMBOL]:
var normalized_share := maxf(symbol_rtp_shares[i], 0.0) / share_total
var model_value := _get_symbol_model_value(i)
var probability := target_rtp * normalized_share / model_value
credited_rtp += probability * _get_symbol_expected_payout(i)
return credited_rtp
func get_theoretical_hit_rate() -> float:
if not _get_slot_math_error().is_empty():
return 0.0
var share_total := _sum_positive(symbol_rtp_shares)
var hit_rate := 0.0
for i in SYMBOL_COUNT:
var normalized_share := maxf(symbol_rtp_shares[i], 0.0) / share_total
hit_rate += target_rtp * normalized_share / _get_symbol_model_value(i)
return hit_rate
func _get_slot_math_error() -> String:
if icon_reels.size() != 3:
return "Slot math requires exactly three icon reels."
if symbol_names.size() != SYMBOL_COUNT:
return "Symbol names must contain exactly six entries."
if symbol_payout_min.size() != SYMBOL_COUNT or symbol_payout_max.size() != SYMBOL_COUNT:
return "Symbol payout minimums and maximums must contain exactly six entries."
if symbol_rtp_shares.size() != SYMBOL_COUNT or loss_symbol_weights.size() != SYMBOL_COUNT:
return "RTP shares and loss display weights must contain exactly six entries."
for i in SYMBOL_COUNT:
if symbol_payout_min[i] < 0 or symbol_payout_max[i] < symbol_payout_min[i]:
return "Symbol payout ranges must be nonnegative and minimum cannot exceed maximum."
if i != PRESENT_SYMBOL and _get_symbol_expected_payout(i) <= 0.0:
return "Every non-present symbol must have a positive expected payout."
if _sum_positive(symbol_rtp_shares) <= 0.0:
return "At least one RTP share must be positive."
if _sum_positive(loss_symbol_weights) <= 0.0:
return "At least one loss display weight must be positive."
var near_miss_weight_total := 0.0
for i in range(1, SYMBOL_COUNT):
near_miss_weight_total += maxf(loss_symbol_weights[i], 0.0)
if near_miss_rate > 0.0 and near_miss_weight_total <= 0.0:
return "Near misses require a positive display weight for a non-coin symbol."
if near_miss_rate < 0.0 or near_miss_rate > 1.0:
return "Near-miss rate must be between zero and one."
var share_total := _sum_positive(symbol_rtp_shares)
var total_win_probability := 0.0
for i in SYMBOL_COUNT:
var normalized_share := maxf(symbol_rtp_shares[i], 0.0) / share_total
total_win_probability += target_rtp * normalized_share / _get_symbol_model_value(i)
if total_win_probability > 1.0:
return "Configured payouts and RTP produce a win probability above 100%."
return ""
func _sum_positive(numbers: PackedFloat32Array) -> float:
var total := 0.0
for number in numbers:
total += maxf(number, 0.0)
return total
func _play_reel_click(spin_progress: float, _reel_index: int) -> void:
if audio_pool.is_empty():
return
# Avoid the artificial sound of choosing the same sample twice in a row.
var sound_index := randi_range(0, CLICK_SOUNDS.size() - 2)
if last_click_sound >= 0 and sound_index >= last_click_sound:
sound_index += 1
last_click_sound = sound_index
var player := audio_pool[next_audio_player]
next_audio_player = (next_audio_player + 1) % audio_pool.size()
player.stream = CLICK_SOUNDS[sound_index]
# The mechanism loses a little energy as it coasts to a stop. Small random
# variation keeps simultaneous reels from sounding cloned.
player.pitch_scale = lerpf(1.08, 0.91, spin_progress) + randf_range(
-pitch_variation,
pitch_variation
)
player.volume_db = click_volume_db + randf_range(-1.5, 0.8)
player.play(CLICK_START_OFFSETS[sound_index])
func _play_stop_click() -> void:
if audio_pool.is_empty():
return
var stop_pitch := 0.97 + randf_range(-pitch_variation * 0.5, pitch_variation * 0.5)
var player := audio_pool[next_audio_player]
next_audio_player = (next_audio_player + 1) % audio_pool.size()
player.stream = STOP_CLICK
player.pitch_scale = stop_pitch
# The source is substantially louder than the four running clicks, so tame it
# slightly while retaining a distinct, weighty reel-lock sound.
player.volume_db = click_volume_db - 2.0 + randf_range(-0.5, 0.5)
player.play(STOP_CLICK_START_OFFSET)
# Layer the quieter low flap under the detent for mechanical body. Matching
# pitch variation makes both recordings read as one physical impact.
var flap_player := audio_pool[next_audio_player]
next_audio_player = (next_audio_player + 1) % audio_pool.size()
flap_player.stream = STOP_FLAP
flap_player.pitch_scale = stop_pitch
flap_player.volume_db = click_volume_db + 4.0 + randf_range(-0.5, 0.5)
flap_player.play(STOP_FLAP_START_OFFSET)
func _play_spin_start() -> void:
if audio_pool.is_empty():
return
var player := audio_pool[next_audio_player]
next_audio_player = (next_audio_player + 1) % audio_pool.size()
player.stream = SPIN_START_THUMP
player.pitch_scale = 0.94 + randf_range(-pitch_variation * 0.35, pitch_variation * 0.35)
player.volume_db = click_volume_db + 2.0 + randf_range(-0.4, 0.4)
player.play()
func set_number(value: int, animate: bool = true) -> void:
var clamped_value := clampi(value, 0, 9999)
if not animate:
_cancel_number_animation()
number_value = clamped_value
_apply_number_immediately(number_value)
number_change_finished.emit(number_value)
return
if clamped_value == number_value and not number_animation_active:
number_change_finished.emit(number_value)
return
var previous_target := number_value
number_value = clamped_value
_animate_number_to(number_value, previous_target, true)
func add_to_number(amount: int, animate: bool = true) -> void:
var previous_target := number_value
var new_value := clampi(number_value + amount, 0, 9999)
if not animate:
set_number(new_value, false)
return
if new_value == previous_target and not number_animation_active:
number_change_finished.emit(number_value)
return
number_value = new_value
_animate_number_to(number_value, previous_target, true)
func get_number() -> int:
return number_value
func is_number_animating() -> bool:
return number_animation_active
func test_counter_setting() -> void:
set_number(randi_range(0, 9999))
func go_to_doctor_scene() -> void:
continue_requested.emit()
func _animate_number_to(target_value: int, previous_target: int, is_addition: bool) -> void:
_cancel_number_animation()
if number_reels.is_empty():
number_change_finished.emit(number_value)
return
number_animation_active = true
var amount := target_value - previous_target
var direction := signi(amount)
var duration := number_roll_duration
if is_addition:
duration += minf(float(absi(amount)) / 1000.0, 1.0) * 0.35
for i in number_reels.size():
var reel := number_reels[i]
var start := float(reel.material.get_shader_parameter("y_offset"))
var target_digit := _digit_at(target_value, i)
var current_digit_position := start * 10.0
var current_digit_phase := fposmod(current_digit_position, 10.0)
var digit_steps: float
if is_addition and direction != 0:
var place := _place_for_index(i)
var transition_count := floori(float(target_value) / place)
transition_count -= floori(float(previous_target) / place)
var extra_turns := mini(floori(float(absi(transition_count)) / 10.0), 3)
if direction > 0:
digit_steps = fposmod(float(target_digit) - current_digit_phase, 10.0)
digit_steps += extra_turns * 10.0
else:
digit_steps = -fposmod(current_digit_phase - float(target_digit), 10.0)
digit_steps -= extra_turns * 10.0
else:
# This is only reached when retargeting to the same value from a
# fractional position; settle directly without choosing a spin direction.
digit_steps = fposmod(float(target_digit) - current_digit_phase + 5.0, 10.0) - 5.0
var target_offset := start + digit_steps / 10.0
var digit_tween := create_tween()
number_tweens.append(digit_tween)
digit_tween.tween_method(
func(offset: float) -> void:
reel.material.set_shader_parameter("y_offset", offset),
start,
target_offset,
duration
).set_trans(Tween.TRANS_CUBIC).set_ease(Tween.EASE_OUT)
digit_tween.tween_callback(func() -> void:
reel.material.set_shader_parameter("y_offset", float(target_digit) / 10.0)
)
var completion_tween := create_tween()
number_tweens.append(completion_tween)
completion_tween.tween_interval(duration)
completion_tween.tween_callback(func() -> void:
number_animation_active = false
number_tweens.clear()
number_change_finished.emit(number_value)
)
func _cancel_number_animation() -> void:
for active_tween in number_tweens:
if active_tween and active_tween.is_valid():
active_tween.kill()
number_tweens.clear()
number_animation_active = false
func _apply_number_immediately(value: int) -> void:
for i in mini(number_reels.size(), 4):
var digit := _digit_at(value, i)
number_reels[i].material.set_shader_parameter("y_offset", float(digit) / 10.0)
func _digit_at(value: int, index: int) -> int:
return floori(float(value) / _place_for_index(index)) % 10
func _place_for_index(index: int) -> int:
return int(pow(10, 3 - index))