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))