docs: add Sprint 19 AudioManager implementation plan
This commit is contained in:
@@ -0,0 +1,574 @@
|
||||
# Sprint 19 — AudioManager Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Add an AudioManager autoload that plays floor-based background music with cross-fade and fires SFX for every player interaction — chest taps, item spawning, drag, drop, outfit apply, and chest return.
|
||||
|
||||
**Architecture:** AudioManager extends Node (autoload, already registered in project.godot). Two AudioStreamPlayer children ping-pong for cross-fade (0.8 s). One SfxPlayer child handles all SFX. Floor is derived from `GameState.current_room` via a pure lookup function. All 7 SFX events are wired in via direct `AudioManager.play_sfx()` calls (autoload = globally accessible). RoomChest gains a tap handler (`_unhandled_input`) to trigger item spawning.
|
||||
|
||||
**Tech Stack:** GDScript 4 (static types), GUT v9.6.0, freesound.org CC0/CC-BY assets, ffmpeg for .ogg conversion if needed.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Audio Assets
|
||||
|
||||
**Files:**
|
||||
- Create: `assets/audio/music/floor_0.ogg` through `floor_3.ogg`
|
||||
- Create: `assets/audio/sfx/chest_tap.ogg`, `item_spawn.ogg`, `item_drag_start.ogg`, `item_drop_hand.ogg`, `item_drop_outfit.ogg`, `item_return_chest.ogg`, `item_drop_floor.ogg`
|
||||
|
||||
- [ ] **Step 1: Create directories**
|
||||
|
||||
```bash
|
||||
mkdir -p "F:/Development/_gameDev/Cozypaw-Hospital/assets/audio/music"
|
||||
mkdir -p "F:/Development/_gameDev/Cozypaw-Hospital/assets/audio/sfx"
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Download music tracks from freesound.org**
|
||||
|
||||
Search freesound.org for each track. Requirements: CC0 or CC-BY licence, loopable (end ≈ start), 30–60 s, child-appropriate, no sudden loud sounds. Save as `floor_N.ogg` directly or download and rename.
|
||||
|
||||
| File | Search query on freesound.org | Character |
|
||||
|---|---|---|
|
||||
| `floor_0.ogg` | `children hospital cheerful loop` | Heiter, belebte Lobby |
|
||||
| `floor_1.ogg` | `calm ambient medical loop` | Ruhig, klinisch |
|
||||
| `floor_2.ogg` | `gentle nursery lullaby loop` | Sanft, Wiegenlied |
|
||||
| `floor_3.ogg` | `garden birds outdoor ambient loop` | Draußen, Vogelgezwitscher |
|
||||
|
||||
If a result is `.mp3` or `.wav`, convert with ffmpeg:
|
||||
```bash
|
||||
ffmpeg -i input.mp3 -c:a libvorbis -q:a 4 floor_0.ogg
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Download SFX from freesound.org**
|
||||
|
||||
Requirements: CC0 or CC-BY, < 0.5 s each, no startling sounds.
|
||||
|
||||
| File | Search query | Target duration |
|
||||
|---|---|---|
|
||||
| `chest_tap.ogg` | `wood tap short` | < 0.3 s |
|
||||
| `item_spawn.ogg` | `pop whoosh soft` | < 0.5 s |
|
||||
| `item_drag_start.ogg` | `pickup soft short` | < 0.3 s |
|
||||
| `item_drop_hand.ogg` | `light click short` | < 0.2 s |
|
||||
| `item_drop_outfit.ogg` | `fabric swoosh short` | < 0.5 s |
|
||||
| `item_return_chest.ogg` | `soft click snap` | < 0.2 s |
|
||||
| `item_drop_floor.ogg` | `soft thud light` | < 0.3 s |
|
||||
|
||||
Convert to `.ogg` with ffmpeg if needed (same command as above, -q:a 6 for SFX).
|
||||
|
||||
- [ ] **Step 4: Verify all 11 files are present**
|
||||
|
||||
```bash
|
||||
ls "F:/Development/_gameDev/Cozypaw-Hospital/assets/audio/music/"
|
||||
ls "F:/Development/_gameDev/Cozypaw-Hospital/assets/audio/sfx/"
|
||||
```
|
||||
|
||||
Expected: 4 music files, 7 SFX files.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add assets/audio/music/ assets/audio/sfx/
|
||||
git commit -m "assets: add floor music and SFX for Sprint 19"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: AudioManager Script + Tests
|
||||
|
||||
**Files:**
|
||||
- Create: `scripts/autoload/AudioManager.gd`
|
||||
- Create: `test/unit/test_audio_manager.gd`
|
||||
|
||||
Note: `AudioManager` is already registered in `project.godot` as `"*res://scripts/autoload/AudioManager.gd"`. Do NOT add `class_name AudioManager` to the script (Godot 4 autoload + class_name conflict — see CLAUDE.md memory).
|
||||
|
||||
- [ ] **Step 1: Write the failing tests**
|
||||
|
||||
Create `test/unit/test_audio_manager.gd`:
|
||||
|
||||
```gdscript
|
||||
## Tests for AudioManager — floor derivation, no-op guard, SFX key validation.
|
||||
extends GutTest
|
||||
|
||||
|
||||
func before_each() -> void:
|
||||
AudioManager._current_floor = -1
|
||||
AudioManager._is_crossfading = false
|
||||
|
||||
|
||||
func test_derive_floor_floor0_reception() -> void:
|
||||
assert_eq(AudioManager._derive_floor_from_room("reception"), 0)
|
||||
|
||||
|
||||
func test_derive_floor_floor0_all_rooms() -> void:
|
||||
assert_eq(AudioManager._derive_floor_from_room("giftshop"), 0)
|
||||
assert_eq(AudioManager._derive_floor_from_room("restaurant"), 0)
|
||||
assert_eq(AudioManager._derive_floor_from_room("emergency"), 0)
|
||||
|
||||
|
||||
func test_derive_floor_floor1_all_rooms() -> void:
|
||||
assert_eq(AudioManager._derive_floor_from_room("xray"), 1)
|
||||
assert_eq(AudioManager._derive_floor_from_room("pharmacy"), 1)
|
||||
assert_eq(AudioManager._derive_floor_from_room("lab"), 1)
|
||||
assert_eq(AudioManager._derive_floor_from_room("patient_rooms"), 1)
|
||||
|
||||
|
||||
func test_derive_floor_floor2_all_rooms() -> void:
|
||||
assert_eq(AudioManager._derive_floor_from_room("ultrasound"), 2)
|
||||
assert_eq(AudioManager._derive_floor_from_room("delivery_room"), 2)
|
||||
assert_eq(AudioManager._derive_floor_from_room("nursery"), 2)
|
||||
|
||||
|
||||
func test_derive_floor_garden() -> void:
|
||||
assert_eq(AudioManager._derive_floor_from_room("garden_party"), 3)
|
||||
|
||||
|
||||
func test_derive_floor_unknown_returns_minus_one() -> void:
|
||||
assert_eq(AudioManager._derive_floor_from_room("unknown_room"), -1)
|
||||
assert_eq(AudioManager._derive_floor_from_room(""), -1)
|
||||
|
||||
|
||||
func test_get_current_floor_starts_at_minus_one() -> void:
|
||||
assert_eq(AudioManager.get_current_floor(), -1)
|
||||
|
||||
|
||||
func test_play_floor_music_same_floor_is_noop() -> void:
|
||||
AudioManager._current_floor = 0
|
||||
AudioManager.play_floor_music(0)
|
||||
assert_eq(AudioManager.get_current_floor(), 0)
|
||||
|
||||
|
||||
func test_play_sfx_unknown_key_does_not_crash() -> void:
|
||||
AudioManager.play_sfx("nonexistent_event_xyz")
|
||||
pass
|
||||
|
||||
|
||||
func test_sfx_map_has_all_seven_keys() -> void:
|
||||
assert_true(AudioManager._SFX_MAP.has("chest_tap"))
|
||||
assert_true(AudioManager._SFX_MAP.has("item_spawn"))
|
||||
assert_true(AudioManager._SFX_MAP.has("item_drag_start"))
|
||||
assert_true(AudioManager._SFX_MAP.has("item_drop_hand"))
|
||||
assert_true(AudioManager._SFX_MAP.has("item_drop_outfit"))
|
||||
assert_true(AudioManager._SFX_MAP.has("item_return_chest"))
|
||||
assert_true(AudioManager._SFX_MAP.has("item_drop_floor"))
|
||||
|
||||
|
||||
func test_music_map_has_all_four_floors() -> void:
|
||||
assert_true(AudioManager._MUSIC_MAP.has(0))
|
||||
assert_true(AudioManager._MUSIC_MAP.has(1))
|
||||
assert_true(AudioManager._MUSIC_MAP.has(2))
|
||||
assert_true(AudioManager._MUSIC_MAP.has(3))
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run to verify FAIL**
|
||||
|
||||
```bash
|
||||
"F:/Development/_tools/Godot_v4.6.2-stable_win64/Godot_v4.6.2-stable_win64.exe" --headless --import
|
||||
"F:/Development/_tools/Godot_v4.6.2-stable_win64/Godot_v4.6.2-stable_win64.exe" --headless -s res://addons/gut/gut_cmdln.gd -gdir=res://test/ -gexit
|
||||
```
|
||||
|
||||
Expected: FAIL — `AudioManager` script missing.
|
||||
|
||||
- [ ] **Step 3: Create AudioManager.gd**
|
||||
|
||||
Create `scripts/autoload/AudioManager.gd`:
|
||||
|
||||
```gdscript
|
||||
## AudioManager — floor music with cross-fade and SFX for player interactions.
|
||||
## Autoload. Do NOT add class_name (Godot 4 autoload conflict).
|
||||
extends Node
|
||||
|
||||
const CROSSFADE_DURATION: float = 0.8
|
||||
|
||||
const _MUSIC_MAP: Dictionary = {
|
||||
0: "res://assets/audio/music/floor_0.ogg",
|
||||
1: "res://assets/audio/music/floor_1.ogg",
|
||||
2: "res://assets/audio/music/floor_2.ogg",
|
||||
3: "res://assets/audio/music/floor_3.ogg",
|
||||
}
|
||||
|
||||
const _SFX_MAP: Dictionary = {
|
||||
"chest_tap": "res://assets/audio/sfx/chest_tap.ogg",
|
||||
"item_spawn": "res://assets/audio/sfx/item_spawn.ogg",
|
||||
"item_drag_start": "res://assets/audio/sfx/item_drag_start.ogg",
|
||||
"item_drop_hand": "res://assets/audio/sfx/item_drop_hand.ogg",
|
||||
"item_drop_outfit": "res://assets/audio/sfx/item_drop_outfit.ogg",
|
||||
"item_return_chest": "res://assets/audio/sfx/item_return_chest.ogg",
|
||||
"item_drop_floor": "res://assets/audio/sfx/item_drop_floor.ogg",
|
||||
}
|
||||
|
||||
var _current_floor: int = -1
|
||||
var _is_crossfading: bool = false
|
||||
var _active_player: AudioStreamPlayer
|
||||
|
||||
var _music_a: AudioStreamPlayer
|
||||
var _music_b: AudioStreamPlayer
|
||||
var _sfx_player: AudioStreamPlayer
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
_music_a = AudioStreamPlayer.new()
|
||||
_music_b = AudioStreamPlayer.new()
|
||||
_sfx_player = AudioStreamPlayer.new()
|
||||
add_child(_music_a)
|
||||
add_child(_music_b)
|
||||
add_child(_sfx_player)
|
||||
_active_player = _music_a
|
||||
_music_a.volume_db = linear_to_db(GameState.music_volume)
|
||||
_music_b.volume_db = linear_to_db(0.0)
|
||||
_sfx_player.volume_db = linear_to_db(GameState.sfx_volume)
|
||||
GameState.state_changed.connect(_on_game_state_changed)
|
||||
var initial_floor: int = _derive_floor_from_room(GameState.current_room)
|
||||
if initial_floor != -1:
|
||||
play_floor_music(initial_floor)
|
||||
|
||||
|
||||
func play_floor_music(floor: int) -> void:
|
||||
if floor == _current_floor:
|
||||
return
|
||||
if not _MUSIC_MAP.has(floor):
|
||||
return
|
||||
if _is_crossfading:
|
||||
return
|
||||
_is_crossfading = true
|
||||
_current_floor = floor
|
||||
var inactive: AudioStreamPlayer = _music_b if _active_player == _music_a else _music_a
|
||||
var stream: AudioStream = load(_MUSIC_MAP[floor]) as AudioStream
|
||||
if stream == null:
|
||||
_is_crossfading = false
|
||||
return
|
||||
inactive.stream = stream
|
||||
inactive.volume_db = linear_to_db(0.0)
|
||||
inactive.play()
|
||||
var tween: Tween = create_tween().set_parallel(true)
|
||||
tween.tween_property(inactive, "volume_db", linear_to_db(GameState.music_volume), CROSSFADE_DURATION)
|
||||
tween.tween_property(_active_player, "volume_db", linear_to_db(0.0), CROSSFADE_DURATION)
|
||||
await tween.finished
|
||||
_active_player.stop()
|
||||
_active_player = inactive
|
||||
_is_crossfading = false
|
||||
|
||||
|
||||
func play_sfx(event: String) -> void:
|
||||
if not _SFX_MAP.has(event):
|
||||
return
|
||||
var stream: AudioStream = load(_SFX_MAP[event]) as AudioStream
|
||||
if stream == null:
|
||||
return
|
||||
_sfx_player.stream = stream
|
||||
_sfx_player.play()
|
||||
|
||||
|
||||
func set_music_volume(vol: float) -> void:
|
||||
GameState.music_volume = vol
|
||||
_active_player.volume_db = linear_to_db(vol)
|
||||
|
||||
|
||||
func set_sfx_volume(vol: float) -> void:
|
||||
GameState.sfx_volume = vol
|
||||
_sfx_player.volume_db = linear_to_db(vol)
|
||||
|
||||
|
||||
func get_current_floor() -> int:
|
||||
return _current_floor
|
||||
|
||||
|
||||
func _on_game_state_changed() -> void:
|
||||
var floor: int = _derive_floor_from_room(GameState.current_room)
|
||||
if floor != -1:
|
||||
play_floor_music(floor)
|
||||
|
||||
|
||||
func _derive_floor_from_room(room: String) -> int:
|
||||
match room:
|
||||
"reception", "giftshop", "restaurant", "emergency":
|
||||
return 0
|
||||
"xray", "pharmacy", "lab", "patient_rooms":
|
||||
return 1
|
||||
"ultrasound", "delivery_room", "nursery":
|
||||
return 2
|
||||
"garden_party":
|
||||
return 3
|
||||
return -1
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run to verify PASS**
|
||||
|
||||
```bash
|
||||
"F:/Development/_tools/Godot_v4.6.2-stable_win64/Godot_v4.6.2-stable_win64.exe" --headless --import
|
||||
"F:/Development/_tools/Godot_v4.6.2-stable_win64/Godot_v4.6.2-stable_win64.exe" --headless -s res://addons/gut/gut_cmdln.gd -gdir=res://test/ -gexit
|
||||
```
|
||||
|
||||
Expected: all previous tests pass + 12 new AudioManager tests pass. Total ≥ 208.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add scripts/autoload/AudioManager.gd test/unit/test_audio_manager.gd
|
||||
git commit -m "feat(audio): add AudioManager with floor music cross-fade and SFX"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: RoomChest Tap Handler + SFX
|
||||
|
||||
**Files:**
|
||||
- Modify: `scripts/objects/room_chest.gd`
|
||||
- Modify: `test/unit/test_room_chest.gd` (append 2 tests)
|
||||
|
||||
Currently `RoomChest` has no input handler — tapping the chest does nothing. This task adds `_unhandled_input` with a helper `_get_press_position()` and wires the two chest SFX events.
|
||||
|
||||
- [ ] **Step 1: Append failing tests to test_room_chest.gd**
|
||||
|
||||
Add at the end of `test/unit/test_room_chest.gd`:
|
||||
|
||||
```gdscript
|
||||
func test_get_press_position_returns_position_for_screen_touch_pressed() -> void:
|
||||
var chest: RoomChest = RoomChest.new()
|
||||
add_child_autofree(chest)
|
||||
var event: InputEventScreenTouch = InputEventScreenTouch.new()
|
||||
event.pressed = true
|
||||
event.position = Vector2(100.0, 200.0)
|
||||
assert_eq(chest._get_press_position(event), Vector2(100.0, 200.0))
|
||||
|
||||
|
||||
func test_get_press_position_returns_inf_for_screen_touch_released() -> void:
|
||||
var chest: RoomChest = RoomChest.new()
|
||||
add_child_autofree(chest)
|
||||
var event: InputEventScreenTouch = InputEventScreenTouch.new()
|
||||
event.pressed = false
|
||||
event.position = Vector2(100.0, 200.0)
|
||||
assert_eq(chest._get_press_position(event), Vector2.INF)
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run to verify FAIL**
|
||||
|
||||
```bash
|
||||
"F:/Development/_tools/Godot_v4.6.2-stable_win64/Godot_v4.6.2-stable_win64.exe" --headless -s res://addons/gut/gut_cmdln.gd -gdir=res://test/ -gexit
|
||||
```
|
||||
|
||||
Expected: FAIL — `_get_press_position` not defined on RoomChest.
|
||||
|
||||
- [ ] **Step 3: Update room_chest.gd**
|
||||
|
||||
The full updated file (replace existing content):
|
||||
|
||||
```gdscript
|
||||
## RoomChest — tappable storage node. Spawns HoldableItem/OutfitItem instances on demand.
|
||||
## Items fly out with a tween. Receives items back via receive_item().
|
||||
class_name RoomChest extends Node2D
|
||||
|
||||
signal items_spawned(chest: RoomChest)
|
||||
signal item_received(chest: RoomChest, item_id: String)
|
||||
|
||||
const SPAWN_TWEEN_DURATION: float = 0.3
|
||||
|
||||
@export var chest_id: String = ""
|
||||
@export var tap_radius: float = 50.0
|
||||
|
||||
var _spawned_items: Array[HoldableItem] = []
|
||||
var _item_configs: Array[ChestItemData] = []
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
add_to_group("room_chests")
|
||||
_item_configs = RoomChestConfig.get_items(chest_id)
|
||||
if not chest_id.is_empty() and GameState.has_method("get_chest_state"):
|
||||
if not GameState.get_chest_state(chest_id).is_empty():
|
||||
call_deferred("spawn_items")
|
||||
|
||||
|
||||
func _unhandled_input(event: InputEvent) -> void:
|
||||
var press_pos: Vector2 = _get_press_position(event)
|
||||
if press_pos == Vector2.INF:
|
||||
return
|
||||
var canvas_pos: Vector2 = get_viewport().get_canvas_transform().affine_inverse() * press_pos
|
||||
if canvas_pos.distance_to(global_position) > tap_radius:
|
||||
return
|
||||
get_viewport().set_input_as_handled()
|
||||
AudioManager.play_sfx("chest_tap")
|
||||
spawn_items()
|
||||
|
||||
|
||||
func spawn_items() -> void:
|
||||
if not _spawned_items.is_empty():
|
||||
return
|
||||
AudioManager.play_sfx("item_spawn")
|
||||
var parent: Node = get_parent()
|
||||
for config: ChestItemData in _item_configs:
|
||||
var item: HoldableItem = _create_item(config)
|
||||
item.home_chest = self
|
||||
if parent != null:
|
||||
parent.add_child(item)
|
||||
else:
|
||||
add_child(item)
|
||||
item.global_position = global_position
|
||||
_spawned_items.append(item)
|
||||
_tween_item_out(item, config.spawn_offset)
|
||||
if GameState.has_method("set_chest_state"):
|
||||
GameState.set_chest_state(chest_id, _get_spawned_ids())
|
||||
items_spawned.emit(self)
|
||||
|
||||
|
||||
func receive_item(item: HoldableItem) -> void:
|
||||
if not _spawned_items.has(item):
|
||||
return
|
||||
_spawned_items.erase(item)
|
||||
if GameState.has_method("set_chest_state"):
|
||||
if _spawned_items.is_empty():
|
||||
GameState.clear_chest_state(chest_id)
|
||||
else:
|
||||
GameState.set_chest_state(chest_id, _get_spawned_ids())
|
||||
item_received.emit(self, item.item_id)
|
||||
_tween_item_in(item)
|
||||
|
||||
|
||||
func are_items_spawned() -> bool:
|
||||
return not _spawned_items.is_empty()
|
||||
|
||||
|
||||
func get_spawned_count() -> int:
|
||||
return _spawned_items.size()
|
||||
|
||||
|
||||
func get_item_config_count() -> int:
|
||||
return _item_configs.size()
|
||||
|
||||
|
||||
func get_spawned_item(index: int) -> HoldableItem:
|
||||
if index < 0 or index >= _spawned_items.size():
|
||||
return null
|
||||
return _spawned_items[index]
|
||||
|
||||
|
||||
func _get_press_position(event: InputEvent) -> Vector2:
|
||||
if event is InputEventScreenTouch and event.pressed:
|
||||
return event.position
|
||||
if event is InputEventMouseButton and event.pressed and event.button_index == MOUSE_BUTTON_LEFT:
|
||||
return event.position
|
||||
return Vector2.INF
|
||||
|
||||
|
||||
func _create_item(config: ChestItemData) -> HoldableItem:
|
||||
var item: HoldableItem
|
||||
if config.item_type == ChestItemData.ItemType.OUTFIT:
|
||||
var outfit: OutfitItem = OutfitItem.new()
|
||||
outfit.outfit_layer = config.outfit_layer
|
||||
item = outfit
|
||||
else:
|
||||
item = HoldableItem.new()
|
||||
item.item_id = config.item_id
|
||||
return item
|
||||
|
||||
|
||||
func _get_spawned_ids() -> Array[String]:
|
||||
var ids: Array[String] = []
|
||||
for item: HoldableItem in _spawned_items:
|
||||
ids.append(item.item_id)
|
||||
return ids
|
||||
|
||||
|
||||
func _tween_item_out(item: HoldableItem, offset: Vector2) -> void:
|
||||
var tween: Tween = create_tween()
|
||||
tween.tween_property(item, "global_position", global_position + offset, SPAWN_TWEEN_DURATION)
|
||||
|
||||
|
||||
func _tween_item_in(item: HoldableItem) -> void:
|
||||
var tween: Tween = create_tween()
|
||||
tween.tween_property(item, "global_position", global_position, SPAWN_TWEEN_DURATION)
|
||||
tween.tween_callback(item.queue_free)
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run to verify PASS**
|
||||
|
||||
```bash
|
||||
"F:/Development/_tools/Godot_v4.6.2-stable_win64/Godot_v4.6.2-stable_win64.exe" --headless -s res://addons/gut/gut_cmdln.gd -gdir=res://test/ -gexit
|
||||
```
|
||||
|
||||
Expected: all tests pass including 2 new `_get_press_position` tests. Total ≥ 210.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add scripts/objects/room_chest.gd test/unit/test_room_chest.gd
|
||||
git commit -m "feat(audio): add tap handler and SFX to RoomChest"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: HoldableItem + OutfitItem SFX
|
||||
|
||||
**Files:**
|
||||
- Modify: `scripts/objects/holdable_item.gd`
|
||||
- Modify: `scripts/objects/outfit_item.gd`
|
||||
|
||||
Pure one-liner additions. No new tests — existing 196+ tests verify no regressions.
|
||||
|
||||
- [ ] **Step 1: Update holdable_item.gd**
|
||||
|
||||
Replace the three methods `_on_drag_picked_up`, `_on_drag_released`, and `_try_return_to_chest` with the versions below. Everything else in the file stays identical.
|
||||
|
||||
```gdscript
|
||||
func _on_drag_picked_up(_pos: Vector2) -> void:
|
||||
if is_in_hand_slot():
|
||||
_detach_from_hand_slot()
|
||||
AudioManager.play_sfx("item_drag_start")
|
||||
item_picked_up.emit(self)
|
||||
|
||||
|
||||
func _on_drag_released(_pos: Vector2) -> void:
|
||||
if _try_return_to_chest():
|
||||
return
|
||||
var result: Array = _find_nearest_free_hand_slot()
|
||||
if not result.is_empty():
|
||||
var character: Character = result[0] as Character
|
||||
var hand: String = result[1] as String
|
||||
character.attach_item(hand, self)
|
||||
AudioManager.play_sfx("item_drop_hand")
|
||||
else:
|
||||
AudioManager.play_sfx("item_drop_floor")
|
||||
item_placed.emit(self)
|
||||
|
||||
|
||||
func _try_return_to_chest() -> bool:
|
||||
if home_chest == null:
|
||||
return false
|
||||
if global_position.distance_to(home_chest.global_position) >= CHEST_RETURN_RADIUS:
|
||||
return false
|
||||
var chest: RoomChest = home_chest as RoomChest
|
||||
if chest == null:
|
||||
return false
|
||||
AudioManager.play_sfx("item_return_chest")
|
||||
chest.receive_item(self)
|
||||
return true
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Update outfit_item.gd**
|
||||
|
||||
Replace `_on_drag_released` with:
|
||||
|
||||
```gdscript
|
||||
func _on_drag_released(_pos: Vector2) -> void:
|
||||
if _try_return_to_chest():
|
||||
return
|
||||
var character: Character = _find_nearest_character()
|
||||
if character != null:
|
||||
character.apply_outfit_item(outfit_layer, item_id, outfit_sprite, self)
|
||||
AudioManager.play_sfx("item_drop_outfit")
|
||||
return
|
||||
super._on_drag_released(_pos)
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Run full test suite**
|
||||
|
||||
```bash
|
||||
"F:/Development/_tools/Godot_v4.6.2-stable_win64/Godot_v4.6.2-stable_win64.exe" --headless -s res://addons/gut/gut_cmdln.gd -gdir=res://test/ -gexit
|
||||
```
|
||||
|
||||
Expected: all tests pass. Total ≥ 210 (no new tests added in this task).
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add scripts/objects/holdable_item.gd scripts/objects/outfit_item.gd
|
||||
git commit -m "feat(audio): wire SFX into HoldableItem and OutfitItem"
|
||||
```
|
||||
Reference in New Issue
Block a user