act1: FPV-комната с подарком, запиской и флешкой
Some checks failed
Godot CI / Import + smoke test (push) Failing after 19s

3D-сцена 4×4м: стены/пол/потолок, окно (свечение), дверь, стол с пека,
кровать с одеялом, подарок-коробка на полу, отец-NPC в дверях.

Архитектура:
- FPVController (CharacterBody3D + Camera3D + RayCast3D) — mouse look,
  WASD, raycast-pickup, lock_input для катсцен.
- Interactable (StaticBody3D base) — toggle через collision_layer.
- DialogManager (autoload CanvasLayer) — субтитры snake-case, fullscreen
  записка, HUD-подсказки, prompt над крестом, await через signal.
- act1.gd — state machine: INTRO → EXPLORE_GIFT → INSIDE_GIFT →
  NOTE_READ → FATHER_FAREWELL (tween x-2.5) → SEEK_DRIVE → ENDING.

Канон-реплики из Пролог_Первый_день.md без правок:
- Отец intro + про флешку с Шиндой
- Записка «Не сожги. Пароль от Wi-Fi на холодильнике. — Бать»
- Мысли героя: «новый пека?», «бать ну ты красавчик», «Андрюхино так Андрюхино»

GL Compatibility renderer, unshaded материалы плоскими цветами под
pixel-art 640×360. Тестировано на RTX 5070 — стартует без ошибок.

main.tscn placeholder удалён, main_scene = act1_room.tscn.

Не покрыто в MVP (TBD):
- Опц. выход на кухню → холодильник → Wi-Fi пароль
- Mastermind hack-mini-game для подбора Wi-Fi
- Sfxr звуки (открытие коробки, шаги)
- Переход в Акт 2 (пока quit на финале)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Dmitrii Bykov 2026-05-24 17:12:04 +03:00
parent d1c28d088b
commit d1b3afd006
15 changed files with 733 additions and 50 deletions

View file

@ -0,0 +1,32 @@
class_name Interactable extends StaticBody3D
@export var prompt: String = "Взаимодействовать"
@export var enabled: bool = false
signal interacted(player: Node)
func _ready() -> void:
_apply_collision()
func set_interactable(v: bool) -> void:
enabled = v
_apply_collision()
func _apply_collision() -> void:
if enabled:
collision_layer = 1
else:
collision_layer = 0
func get_prompt() -> String:
return prompt
func interact(player: Node) -> void:
if not enabled:
return
interacted.emit(player)

View file

@ -0,0 +1 @@
uid://c04k1gckreqao

View file

@ -0,0 +1,100 @@
class_name FPVController extends CharacterBody3D
@export var move_speed: float = 3.0
@export var mouse_sensitivity: float = 0.0025
@export var gravity: float = 9.8
@onready var camera: Camera3D = $Camera3D
@onready var interact_ray: RayCast3D = $Camera3D/InteractRay
signal interactable_focused(obj: Node)
signal interactable_unfocused
signal interact_pressed(obj: Node)
var _captured: bool = false
var _locked: bool = false
var _focused_obj: Node = null
func _ready() -> void:
_capture_mouse()
func _capture_mouse() -> void:
Input.mouse_mode = Input.MOUSE_MODE_CAPTURED
_captured = true
func _release_mouse() -> void:
Input.mouse_mode = Input.MOUSE_MODE_VISIBLE
_captured = false
func _input(event: InputEvent) -> void:
if event is InputEventMouseMotion and _captured and not _locked:
rotate_y(-event.relative.x * mouse_sensitivity)
camera.rotate_x(-event.relative.y * mouse_sensitivity)
camera.rotation.x = clamp(camera.rotation.x, -PI * 0.45, PI * 0.45)
elif event is InputEventMouseButton and event.pressed and event.button_index == MOUSE_BUTTON_LEFT:
if not _captured:
_capture_mouse()
return
if _locked:
return
if _focused_obj != null:
interact_pressed.emit(_focused_obj)
if _focused_obj.has_method("interact"):
_focused_obj.interact(self)
elif event is InputEventKey and event.pressed:
if event.keycode == KEY_ESCAPE:
if _captured:
_release_mouse()
else:
_capture_mouse()
elif event.keycode == KEY_Q:
get_tree().quit()
func _physics_process(delta: float) -> void:
if _locked:
velocity = Vector3.ZERO
move_and_slide()
_update_focus()
return
var input_dir: Vector2 = Input.get_vector("ui_left", "ui_right", "ui_up", "ui_down")
var direction: Vector3 = (transform.basis * Vector3(input_dir.x, 0.0, input_dir.y)).normalized()
velocity.x = direction.x * move_speed
velocity.z = direction.z * move_speed
if not is_on_floor():
velocity.y -= gravity * delta
else:
velocity.y = 0.0
move_and_slide()
_update_focus()
func _update_focus() -> void:
interact_ray.force_raycast_update()
var new_focus: Node = null
if interact_ray.is_colliding():
var col: Object = interact_ray.get_collider()
if col != null and col is Node and col.has_method("get_prompt"):
new_focus = col
if new_focus != _focused_obj:
if new_focus != null:
interactable_focused.emit(new_focus)
else:
interactable_unfocused.emit()
_focused_obj = new_focus
func lock_input() -> void:
_locked = true
func unlock_input() -> void:
_locked = false
func get_focused() -> Node:
return _focused_obj

View file

@ -0,0 +1 @@
uid://r5r7c1wxtyot

90
scripts/prologue/act1.gd Normal file
View file

@ -0,0 +1,90 @@
extends Node3D
@onready var player: FPVController = $PlayerFPV
@onready var gift_box: Interactable = $GiftBox
@onready var system_unit: Node3D = $SystemUnit
@onready var note: Interactable = $SystemUnit/Note
@onready var flash_drive: Interactable = $Desk/FlashDrive
@onready var father: Node3D = $FatherNPC
enum State { INTRO, EXPLORE_GIFT, INSIDE_GIFT, NOTE_READ, FATHER_FAREWELL, SEEK_DRIVE, ENDING }
var state: State = State.INTRO
func _ready() -> void:
system_unit.visible = false
note.visible = false
gift_box.set_interactable(false)
flash_drive.set_interactable(false)
player.lock_input()
player.interactable_focused.connect(_on_player_focused)
player.interactable_unfocused.connect(_on_player_unfocused)
DialogManager.set_hud("Акт 1 — Подарок · ЛКМ/ПРОБЕЛ — дальше · ESC — мышь · Q — выход")
await get_tree().create_timer(0.8).timeout
await DialogManager.say("ОТЕЦ", "Ну? Чё стоишь. Распаковывай давай. Сам собирал, между прочим — полночи не спал.")
await DialogManager.think("Так… новый пека? Серьёзно? После моего динозавра?")
state = State.EXPLORE_GIFT
gift_box.set_interactable(true)
player.unlock_input()
func _on_player_focused(obj: Node) -> void:
if obj.has_method("get_prompt"):
DialogManager.set_prompt(obj.get_prompt())
func _on_player_unfocused() -> void:
DialogManager.clear_prompt()
func _on_gift_interacted(_p: Node) -> void:
if state != State.EXPLORE_GIFT:
return
state = State.INSIDE_GIFT
gift_box.set_interactable(false)
gift_box.visible = false
system_unit.visible = true
note.visible = true
note.set_interactable(true)
DialogManager.clear_prompt()
func _on_note_interacted(_p: Node) -> void:
if state != State.INSIDE_GIFT:
return
state = State.NOTE_READ
note.set_interactable(false)
player.lock_input()
DialogManager.clear_prompt()
await DialogManager.show_note("«Не сожги. Пароль от Wi-Fi на холодильнике. — Бать»")
await DialogManager.think("Бать ну ты красавчик конечно.")
state = State.FATHER_FAREWELL
await DialogManager.say("ОТЕЦ", "Флешка с Шиндой на столе. Ставь сам, не маленький. Если запорешь — сам и переставляй.")
var tween: Tween = create_tween()
tween.tween_property(father, "position:x", father.position.x - 2.5, 0.8)
await tween.finished
father.visible = false
state = State.SEEK_DRIVE
flash_drive.set_interactable(true)
player.unlock_input()
func _on_drive_interacted(_p: Node) -> void:
if state != State.SEEK_DRIVE:
return
state = State.ENDING
flash_drive.set_interactable(false)
player.lock_input()
DialogManager.clear_prompt()
await DialogManager.think("Ну ладно. Андрюхино так Андрюхино. Поехали.")
await DialogManager.say("СИСТЕМА", "Конец Акта 1. Акт 2 — Установка Шиндовс (TBD).")
get_tree().quit()

View file

@ -0,0 +1 @@
uid://dcn3oei0t5ley

View file

@ -0,0 +1,78 @@
extends CanvasLayer
signal advanced
@onready var subtitle_panel: PanelContainer = $SubtitlePanel
@onready var who_label: Label = $SubtitlePanel/V/Who
@onready var text_label: Label = $SubtitlePanel/V/Text
@onready var hint_label: Label = $SubtitlePanel/V/Hint
@onready var note_overlay: Control = $NoteOverlay
@onready var note_text: Label = $NoteOverlay/Center/P/V2/Text
@onready var prompt_label: Label = $PromptLabel
@onready var hud_label: Label = $HudLabel
var _waiting: bool = false
func _ready() -> void:
subtitle_panel.visible = false
note_overlay.visible = false
prompt_label.visible = false
hud_label.visible = false
func say(who: String, line: String) -> Signal:
who_label.text = "[%s]" % who
text_label.text = line
hint_label.text = "ЛКМ или ПРОБЕЛ — дальше"
subtitle_panel.visible = true
note_overlay.visible = false
_waiting = true
return advanced
func think(line: String) -> Signal:
return say("Я", line)
func show_note(text: String) -> Signal:
note_text.text = text
note_overlay.visible = true
subtitle_panel.visible = false
_waiting = true
return advanced
func set_prompt(text: String) -> void:
if text == "":
prompt_label.visible = false
return
prompt_label.text = "[ЛКМ] %s" % text
prompt_label.visible = true
func clear_prompt() -> void:
prompt_label.visible = false
func set_hud(text: String) -> void:
hud_label.text = text
hud_label.visible = text != ""
func _input(event: InputEvent) -> void:
if not _waiting:
return
var advance: bool = false
if event is InputEventMouseButton and event.pressed and event.button_index == MOUSE_BUTTON_LEFT:
advance = true
elif event is InputEventKey and event.pressed and (event.keycode == KEY_SPACE or event.keycode == KEY_ENTER):
advance = true
if advance:
get_viewport().set_input_as_handled()
subtitle_panel.visible = false
note_overlay.visible = false
_waiting = false
advanced.emit()

View file

@ -0,0 +1 @@
uid://eb6vorv2mguj