다마고치 게임에 미니게임 3종을 넣었다. 게임 자체보다 어려웠던 건 게임 모드 전환이다. 미니게임을 시작하면 씬이 줌아웃되고 캐릭터가 축소되고 이동 범위가 바뀌고 배경이 교체된다. 게임이 끝나면 이 모든 게 원래대로 돌아와야 한다.

하나라도 복원을 빠뜨리면 버그가 된다. 캐릭터가 작은 채로 남아있거나, 배경이 확장된 채로 유지되거나, 이동 범위가 좁아진 채로 고정된다. 유저는 “게임이 망가졌다"고 느낀다.

이 글에서는 미니게임 모드 전환 시 상태 관리 패턴과, 실제로 터진 버그들의 원인과 해결을 정리한다.


문제의 본질: 임시 상태 vs 영구 상태

미니게임 모드는 임시 상태다. 잠깐 동안만 다른 설정을 쓰고, 끝나면 원래대로 돌아간다. 이게 간단해 보이는데 실제로는 복잡하다.

왜 복잡한가:

  1. 변경해야 할 것이 많다: scale, position, 이동 범위, 배경, UI 표시, 입력 모드…
  2. 각 값의 복원 시점이 다르다: UI는 즉시, 줌은 Tween으로 부드럽게, 배경은 줌 복원 후에
  3. 다른 시스템이 값을 덮어쓸 수 있다: _process()의 방어적 초기화가 대표적

패턴 1: Snapshot-Restore (스냅샷 저장/복원)

가장 기본적인 패턴. 게임 시작 시 모든 관련 값을 저장하고, 종료 시 복원한다.

# dodge_game_mode.gd

# 저장할 원본 값들
var _original_main_scale: Vector2
var _original_main_pos: Vector2
var _original_pet_scale: Vector2
var _original_wander_left: float
var _original_wander_right: float
var _original_bg_texture: Texture2D
var _original_bg_pos: Vector2

func start(pet_display: Node2D, main_scene: Node2D, vp_size: Vector2) -> void:
    # === SNAPSHOT ===
    _original_main_scale = main_scene.scale
    _original_main_pos = main_scene.position
    _original_pet_scale = pet_display.sprite.scale
    _original_wander_left = pet_display.WANDER_LEFT
    _original_wander_right = pet_display.WANDER_RIGHT

    var bg: Sprite2D = main_scene.get_node("Background")
    _original_bg_texture = bg.texture
    _original_bg_pos = bg.position

    # === MODIFY ===
    _apply_game_mode_settings()

func end_game() -> void:
    # === RESTORE ===
    _restore_all_originals()

단순하지만 강력하다. 규칙은 하나:

수정하는 모든 값은 반드시 수정 전에 저장한다.

이걸 어기면 복원할 수 없다. “나중에 저장하지 뭐” 하다가 수정 후에 저장하면 이미 바뀐 값이 “원본"이 된다.

체크리스트

미니게임 모드에서 저장/복원해야 하는 항목:

카테고리항목수정 내용
트랜스폼메인 씬 scale1.0 → 0.55
트랜스폼메인 씬 position(0,0) → 줌아웃 위치
트랜스폼캐릭터 scale원본 → 축소
이동WANDER_LEFT/RIGHT±150 → ±364
이동meta(“floor_left/right”)±150 → ±364
비주얼배경 텍스처기본 → 확장(하늘)
비주얼배경 위치원본 → 오프셋
UIActionBar 표시visible → hidden
UIHUD (HP, 점수)hidden → visible
행동캐릭터 AI 모드자동 산책 → 플레이어 조작

10개 항목이다. 미니게임 3종이면 각각 이 목록을 관리해야 한다. 공통 부분이 많으니 베이스 클래스로 뽑았다.


패턴 2: BaseGameMode 추상화

3종의 미니게임이 공통으로 쓰는 시작/종료 로직을 베이스 클래스로 분리했다.

# base_game_mode.gd
class_name BaseGameMode
extends CanvasLayer

var _main_scene: Node2D
var _pet_display: Node2D
var _vp_size: Vector2
var _game_active: bool = false

# 스냅샷 저장소
var _snapshots: Dictionary = {}

func _save_snapshot(key: String, value: Variant) -> void:
    _snapshots[key] = value

func _get_snapshot(key: String) -> Variant:
    return _snapshots.get(key)

func _base_start(pet_display: Node2D, main_scene: Node2D, vp_size: Vector2) -> void:
    _pet_display = pet_display
    _main_scene = main_scene
    _vp_size = vp_size

    # 공통 스냅샷
    _save_snapshot("main_scale", main_scene.scale)
    _save_snapshot("main_pos", main_scene.position)
    _save_snapshot("wander_left", pet_display.WANDER_LEFT)
    _save_snapshot("wander_right", pet_display.WANDER_RIGHT)
    _save_snapshot("floor_left_meta", pet_display.get_meta("floor_left", -150.0))
    _save_snapshot("floor_right_meta", pet_display.get_meta("floor_right", 150.0))

    # 공통 줌아웃
    _zoom_out()
    # 공통 이동 범위 보정
    _adjust_wander_range()

func _base_end() -> void:
    _game_active = false

    # 공통 복원
    _restore_wander_range()
    _zoom_restore()

func _zoom_out() -> void:
    var zoom := 0.55
    var tw := create_tween().set_parallel(true)
    tw.tween_property(_main_scene, "scale", Vector2(zoom, zoom), 0.4) \
        .set_ease(Tween.EASE_OUT)

    var target_x: float = _vp_size.x * (1.0 - zoom) * 0.5
    var target_y: float = _vp_size.y * (1.0 - zoom) * 1.1
    tw.tween_property(_main_scene, "position", Vector2(target_x, target_y), 0.4)

func _zoom_restore() -> void:
    var tw := create_tween().set_parallel(true)
    tw.tween_property(_main_scene, "scale", _get_snapshot("main_scale"), 0.4)
    tw.tween_property(_main_scene, "position", _get_snapshot("main_pos"), 0.4)
    tw.chain().tween_interval(1.5)
    tw.chain().tween_callback(queue_free)

func _adjust_wander_range() -> void:
    var zoom: float = 0.55
    var half_range: float = (_vp_size.x / zoom / 2.0) - 20
    _pet_display.WANDER_LEFT = -half_range
    _pet_display.WANDER_RIGHT = half_range
    _pet_display.set_meta("floor_left", -half_range)
    _pet_display.set_meta("floor_right", half_range)

func _restore_wander_range() -> void:
    _pet_display.WANDER_LEFT = _get_snapshot("wander_left")
    _pet_display.WANDER_RIGHT = _get_snapshot("wander_right")
    _pet_display.set_meta("floor_left", _get_snapshot("floor_left_meta"))
    _pet_display.set_meta("floor_right", _get_snapshot("floor_right_meta"))

이제 개별 미니게임은 이렇게 된다:

# dodge_game_mode.gd
class_name DodgeGameMode
extends BaseGameMode

func start(pet_display: Node2D, main_scene: Node2D, vp_size: Vector2) -> void:
    _base_start(pet_display, main_scene, vp_size)

    # Dodge 전용: 확장 배경 교체
    _swap_background()

    # 게임 시작
    _game_active = true
    _start_spawning()

func end_game() -> void:
    _base_end()
    _restore_background()

공통 로직은 베이스에, 게임별 특수 로직은 하위 클래스에. 표준적인 Template Method 패턴이다.


함정: _process()가 값을 덮어쓴다

이게 가장 오래 삽질한 버그다. 자세한 이야기는 이전 글에 있지만, 패턴 관점에서 다시 정리한다.

버그 상황

이동 범위를 ±364로 넓혔는데, 다음 프레임에 ±150으로 돌아간다.

원인

# pet_display.gd
func _process(delta: float) -> void:
    WANDER_LEFT = get_meta("floor_left", -150.0)   # 매 프레임!
    WANDER_RIGHT = get_meta("floor_right", 150.0)   # 매 프레임!

_process()에서 매 프레임 meta 값을 읽어와서 WANDER_LEFT/RIGHT를 초기화한다. 원래는 메인 씬에서 바닥 크기가 바뀔 때 자동으로 반영되라고 만든 코드다. 평상시에는 문제없다.

하지만 미니게임 모드에서 WANDER_LEFT = -364로 바꿔도, meta 값은 여전히 -150이니까 다음 프레임에 덮어써진다.

교훈: 방어적 초기화의 그림자

이 패턴은 “방어적 초기화"라고 불린다. 매 프레임 값을 리셋해서 일관성을 보장한다. 그런데 이게 다른 시스템의 동적 변경을 무효화하는 부작용이 있다.

해결 방법 3가지:

방법 A: 소스(meta)도 함께 업데이트 (내가 쓴 방법)

# 게임 모드에서
_pet_display.WANDER_LEFT = -half_range
_pet_display.set_meta("floor_left", -half_range)  # 소스도 같이!

장점: 기존 _process() 코드 수정 불필요 단점: meta 의존성이 암묵적. “왜 meta를 바꿔야 하지?“가 직관적이지 않음

방법 B: 게임 모드 플래그로 _process() 분기

# pet_display.gd
var in_game_mode: bool = false

func _process(delta: float) -> void:
    if not in_game_mode:  # 게임 모드가 아닐 때만
        WANDER_LEFT = get_meta("floor_left", -150.0)
        WANDER_RIGHT = get_meta("floor_right", 150.0)

장점: 의도가 명확. 게임 모드일 때 외부 값을 쓴다는 게 코드에 드러남 단점: _process() 복잡도 증가. 플래그 관리 필요

방법 C: _process() 제거, 이벤트 기반으로 전환

# pet_display.gd
signal wander_range_changed(left: float, right: float)

func set_wander_range(left: float, right: float) -> void:
    WANDER_LEFT = left
    WANDER_RIGHT = right
    emit_signal("wander_range_changed", left, right)

장점: 가장 깔끔. 값이 바뀔 때만 실행 단점: 기존 코드 대폭 리팩토링 필요. 메인 씬에서 바닥 크기 변경 시 signal 연결 필요

나는 방법 A로 빠르게 해결하고 넘어갔다. 미니게임 3종이 전부라서 리팩토링 투자 대비 효과가 낮다고 판단했다. 미니게임이 10종으로 늘어나면 방법 B로 전환할 것 같다.


복원 순서가 중요하다

모든 값을 복원한다고 끝이 아니다. 순서도 중요하다.

잘못된 순서:

1. 이동 범위 복원 (±150)
2. 줌 복원 (0.55 → 1.0)  ← 이 동안 캐릭터가 ±150 범위에서 움직이는데,
                            줌이 복원되면서 ±150이 화면 밖이 됨

올바른 순서:

1. 게임 중지 (스폰 정지, 판정 정지)
2. 아이템 정리 (떨어지던 것들 제거)
3. 결과 표시 (성공/실패)
4. 줌 복원 (Tween, 0.4초)
5. 줌 복원 완료 후 → 이동 범위 복원
6. 배경 복원
7. UI 복원
8. 게임 모드 노드 제거

줌이 복원된 후에 이동 범위를 복원해야 한다. 줌 복원 중에 이동 범위가 먼저 바뀌면, 캐릭터가 일시적으로 화면 밖으로 나갈 수 있다.

func _end_sequence() -> void:
    _game_active = false
    _clear_items()
    _show_result()

    # 줌 복원 Tween
    var tw := create_tween().set_parallel(true)
    tw.tween_property(_main_scene, "scale", _get_snapshot("main_scale"), 0.4)
    tw.tween_property(_main_scene, "position", _get_snapshot("main_pos"), 0.4)

    # 줌 복원 완료 후에 나머지 복원
    tw.chain().tween_callback(_restore_wander_range)
    tw.chain().tween_callback(_restore_background)
    tw.chain().tween_callback(_restore_ui)
    tw.chain().tween_interval(0.5)
    tw.chain().tween_callback(queue_free)

chain().tween_callback()으로 줌 복원 완료 시점에 콜백을 건다. Tween의 시퀀스 기능이 여기서 빛난다.


동시 실행 방지

미니게임이 실행 중인데 또 시작하면? 버그다. 스냅샷이 덮어써지고 복원이 불가능해진다.

# main_scene.gd
var _current_game_mode: BaseGameMode = null

func start_minigame(mode_scene: PackedScene) -> void:
    # 이미 게임 중이면 무시
    if _current_game_mode != null:
        return

    _current_game_mode = mode_scene.instantiate()
    _current_game_mode.tree_exiting.connect(
        func(): _current_game_mode = null
    )
    add_child(_current_game_mode)
    _current_game_mode.start(_pet_display, self, get_viewport_rect().size)

tree_exiting 시그널로 게임 모드가 정리될 때 자동으로 null로 돌린다. queue_free()가 호출되면 tree_exiting이 먼저 발생하니까 타이밍이 안전하다.


디버깅 팁: 복원 검증

개발 중에 복원이 제대로 됐는지 확인하는 헬퍼:

func _verify_restoration() -> void:
    for key in _snapshots:
        var expected = _snapshots[key]
        var actual: Variant

        match key:
            "main_scale": actual = _main_scene.scale
            "main_pos": actual = _main_scene.position
            "wander_left": actual = _pet_display.WANDER_LEFT
            "wander_right": actual = _pet_display.WANDER_RIGHT

        if actual != null and actual != expected:
            push_warning("Restore mismatch: %s expected=%s actual=%s" \
                % [key, expected, actual])

게임 종료 후에 이걸 돌리면 복원이 안 된 항목이 콘솔에 뜬다. 프로덕션에서는 빼지만 개발 중에는 항상 켜둔다.


정리

패턴용도핵심 규칙
Snapshot-Restore임시 상태 관리수정 전에 반드시 저장
BaseGameMode공통 로직 추상화Template Method
소스 업데이트_process() 방어 우회meta/config도 함께 변경
순서 보장복원 타이밍 관리Tween chain으로 시퀀싱
동시 실행 방지다중 게임 방지null 체크 + 시그널 정리

게임 모드 전환은 “모든 걸 바꾸고 모든 걸 원래대로 돌리는” 작업이다. 바꿀 게 3개면 쉽지만 10개 넘으면 하나는 반드시 빠뜨린다. 체크리스트와 자동 검증이 유일한 방법이다.

미니게임을 더 추가할 계획이라 BaseGameMode를 좀 더 일반화할 예정이다. 각 게임이 “나는 이것만 추가로 바꾼다"를 선언하면 자동으로 저장/복원되는 구조를 목표로 하고 있다.