Initial scaffold: Godot 4.3 scenes, systems, and placeholders.

Wire all twelve levels with billboard prefabs, autoload systems (inventory, achievements, mock shop, DE/EN/BY), and satire audio locks so the Title→End flow is playable with placeholder assets.
This commit is contained in:
copilot 2026-09-14 18:36:02 +02:00
commit 938cc4bc82
114 changed files with 4819 additions and 0 deletions

View file

@ -0,0 +1,57 @@
extends Node
## Achievement unlock + persistence.
signal unlocked(achievement_id: String, title: String)
var _defs: Array = []
var _unlocked: Dictionary = {} # id -> true
func _ready() -> void:
_load_defs()
func _load_defs() -> void:
var file := FileAccess.open("res://data/achievements.json", FileAccess.READ)
if file == null:
push_error("Missing achievements.json")
return
var parsed: Variant = JSON.parse_string(file.get_as_text())
if typeof(parsed) == TYPE_ARRAY:
_defs = parsed
func unlock(achievement_id: String) -> void:
if _unlocked.get(achievement_id, false):
return
_unlocked[achievement_id] = true
var title := achievement_id
for entry in _defs:
if str(entry.get("id", "")) == achievement_id:
title = Locale.tr_key(str(entry.get("title_key", achievement_id)))
break
unlocked.emit(achievement_id, title)
_check_all()
GameState.save_game()
func is_unlocked(achievement_id: String) -> bool:
return bool(_unlocked.get(achievement_id, false))
func get_defs() -> Array:
return _defs
func _check_all() -> void:
if _unlocked.get("all_burgers", false):
return
for entry in _defs:
var id := str(entry.get("id", ""))
if id == "all_burgers":
continue
if not _unlocked.get(id, false):
return
_unlocked["all_burgers"] = true
var title := Locale.tr_key("ach_all_title")
unlocked.emit("all_burgers", title)
func serialize() -> Dictionary:
return {"unlocked": _unlocked.duplicate()}
func deserialize(data: Dictionary) -> void:
_unlocked = data.get("unlocked", {}).duplicate()

View file

@ -0,0 +1,91 @@
extends Node
## Music / SFX / Voice + Burger-Cham every 120s on title/loading.
signal burger_cham_triggered
const BURGER_INTERVAL := 120.0
# TODO: Replace placeholder streams with real takes (see README asset list).
const MUSIC_TITLE := "res://assets/audio/placeholders/music_title.wav"
const SFX_EXPLOSION := "res://assets/audio/placeholders/sfx_explosion.wav"
const SFX_HIT := "res://assets/audio/placeholders/sfx_hit.wav"
const SFX_BURGER := "res://assets/audio/placeholders/sfx_burger.wav"
const VOICE_DE := "res://assets/audio/placeholders/voice_de.wav"
const VOICE_EN := "res://assets/audio/placeholders/voice_en.wav"
const VOICE_BY := "res://assets/audio/placeholders/voice_by.wav"
var _music: AudioStreamPlayer
var _sfx: AudioStreamPlayer
var _voice: AudioStreamPlayer
var _burger_timer: float = 0.0
var burger_cham_enabled: bool = true
func _ready() -> void:
process_mode = Node.PROCESS_MODE_ALWAYS
_music = AudioStreamPlayer.new()
_music.bus = "Music"
_music.volume_db = 6.0 # intentionally loud
add_child(_music)
_sfx = AudioStreamPlayer.new()
_sfx.bus = "SFX"
add_child(_sfx)
_voice = AudioStreamPlayer.new()
_voice.bus = "Voice"
add_child(_voice)
func _process(delta: float) -> void:
if not burger_cham_enabled:
return
if GameState.current_scene_id != "title_screen":
return
_burger_timer += delta
if _burger_timer >= BURGER_INTERVAL:
_burger_timer = 0.0
trigger_burger_cham()
func play_title_music() -> void:
var stream := _load_stream(MUSIC_TITLE)
if stream == null:
return
_music.stream = stream
_music.volume_db = 6.0
if not _music.playing:
_music.play()
func stop_music() -> void:
_music.stop()
func play_sfx(path: String) -> void:
var stream := _load_stream(path)
if stream == null:
return
_sfx.stream = stream
_sfx.play()
func play_explosion() -> void:
play_sfx(SFX_EXPLOSION)
func play_hit() -> void:
play_sfx(SFX_HIT)
func play_language_voice(lang: String) -> void:
var path := VOICE_DE
match lang:
"en":
path = VOICE_EN
"by":
path = VOICE_BY
var stream := _load_stream(path)
if stream == null:
return
_voice.stream = stream
_voice.play()
func trigger_burger_cham() -> void:
play_sfx(SFX_BURGER)
burger_cham_triggered.emit()
func _load_stream(path: String) -> AudioStream:
if ResourceLoader.exists(path):
return load(path)
return null

View file

@ -0,0 +1,161 @@
extends Node
## Global game flow, HP, death, playtime, save/load.
## TODO: Expand save schema when real progression exists.
signal hp_changed(hp: int, max_hp: int)
signal died(cause_key: String)
signal scene_changed(scene_id: String)
const SAVE_PATH := "user://save.json"
const SCENE_ORDER: Array[String] = [
"title_screen",
"intro_cutscene",
"village",
"shoe_boss",
"meadow",
"massage_studio",
"farm_path",
"farm",
"moon",
"final_boss",
"end_screen",
]
const SCENE_PATHS := {
"title_screen": "res://scenes/title_screen.tscn",
"intro_cutscene": "res://scenes/intro_cutscene.tscn",
"village": "res://scenes/village.tscn",
"shoe_boss": "res://scenes/shoe_boss.tscn",
"meadow": "res://scenes/meadow.tscn",
"massage_studio": "res://scenes/massage_studio.tscn",
"farm_path": "res://scenes/farm_path.tscn",
"farm": "res://scenes/farm.tscn",
"moon": "res://scenes/moon.tscn",
"final_boss": "res://scenes/final_boss.tscn",
"end_screen": "res://scenes/end_screen.tscn",
"death_screen": "res://scenes/death_screen.tscn",
}
var current_scene_id: String = "title_screen"
var max_hp: int = 5
var hp: int = 5
var death_count: int = 0
var last_death_cause_key: String = "death_default"
var playtime_seconds: float = 0.0
var house_built: bool = false
var flags: Dictionary = {}
func _ready() -> void:
process_mode = Node.PROCESS_MODE_ALWAYS
load_game()
func _process(delta: float) -> void:
if get_tree().paused:
return
if current_scene_id in ["title_screen", "death_screen"]:
return
playtime_seconds += delta
if playtime_seconds >= 3600.0:
Achievements.unlock("played_hour")
func goto_scene(scene_id: String) -> void:
if not SCENE_PATHS.has(scene_id):
push_error("Unknown scene: %s" % scene_id)
return
current_scene_id = scene_id
scene_changed.emit(scene_id)
if scene_id == "moon":
Achievements.unlock("moon_cheese")
save_game()
get_tree().change_scene_to_file(SCENE_PATHS[scene_id])
func goto_next() -> void:
var idx := SCENE_ORDER.find(current_scene_id)
if idx < 0:
goto_scene("village")
return
if idx >= SCENE_ORDER.size() - 1:
goto_scene("end_screen")
return
goto_scene(SCENE_ORDER[idx + 1])
func skip_to_final_boss() -> void:
Achievements.unlock("milk_skip")
flags["milk_skip_used"] = true
goto_scene("final_boss")
func start_new_game() -> void:
hp = max_hp
flags.clear()
house_built = false
goto_scene("intro_cutscene")
func heal_full() -> void:
hp = max_hp
hp_changed.emit(hp, max_hp)
func take_damage(amount: int = 1, cause_key: String = "death_default") -> void:
hp = max(0, hp - amount)
hp_changed.emit(hp, max_hp)
if hp <= 0:
die(cause_key)
func die(cause_key: String = "death_default") -> void:
last_death_cause_key = cause_key
death_count += 1
died.emit(cause_key)
if death_count >= 100:
Achievements.unlock("died_100")
save_game()
goto_scene("death_screen")
func set_flag(key: String, value: Variant = true) -> void:
flags[key] = value
save_game()
func get_flag(key: String, default_value: Variant = false) -> Variant:
return flags.get(key, default_value)
func save_game() -> void:
var data := {
"hp": hp,
"max_hp": max_hp,
"death_count": death_count,
"last_death_cause_key": last_death_cause_key,
"playtime_seconds": playtime_seconds,
"current_scene_id": current_scene_id,
"house_built": house_built,
"flags": flags,
"inventory": Inventory.serialize(),
"achievements": Achievements.serialize(),
"shop": Shop.serialize(),
"settings": Settings.serialize(),
}
var file := FileAccess.open(SAVE_PATH, FileAccess.WRITE)
if file == null:
push_warning("Could not write save file")
return
file.store_string(JSON.stringify(data, "\t"))
func load_game() -> void:
if not FileAccess.file_exists(SAVE_PATH):
return
var file := FileAccess.open(SAVE_PATH, FileAccess.READ)
if file == null:
return
var parsed: Variant = JSON.parse_string(file.get_as_text())
if typeof(parsed) != TYPE_DICTIONARY:
return
var data: Dictionary = parsed
hp = int(data.get("hp", max_hp))
max_hp = int(data.get("max_hp", max_hp))
death_count = int(data.get("death_count", 0))
last_death_cause_key = str(data.get("last_death_cause_key", "death_default"))
playtime_seconds = float(data.get("playtime_seconds", 0.0))
current_scene_id = str(data.get("current_scene_id", "title_screen"))
house_built = bool(data.get("house_built", false))
flags = data.get("flags", {})
Inventory.deserialize(data.get("inventory", {}))
Achievements.deserialize(data.get("achievements", {}))
Shop.deserialize(data.get("shop", {}))
Settings.deserialize(data.get("settings", {}))

View file

@ -0,0 +1,84 @@
extends Node
## Array-based inventory with item IDs from data/items.json.
signal inventory_changed
signal item_used(item_id: String)
var _items: Dictionary = {} # id -> count
var _defs: Dictionary = {}
func _ready() -> void:
_load_defs()
func _load_defs() -> void:
var file := FileAccess.open("res://data/items.json", FileAccess.READ)
if file == null:
push_error("Missing items.json")
return
var parsed: Variant = JSON.parse_string(file.get_as_text())
if typeof(parsed) != TYPE_ARRAY:
return
for entry in parsed:
if typeof(entry) == TYPE_DICTIONARY and entry.has("id"):
_defs[str(entry["id"])] = entry
func add_item(item_id: String, amount: int = 1) -> void:
if amount <= 0:
return
_items[item_id] = int(_items.get(item_id, 0)) + amount
inventory_changed.emit()
GameState.save_game()
func remove_item(item_id: String, amount: int = 1) -> bool:
var have := int(_items.get(item_id, 0))
if have < amount:
return false
have -= amount
if have <= 0:
_items.erase(item_id)
else:
_items[item_id] = have
inventory_changed.emit()
GameState.save_game()
return true
func has_item(item_id: String, amount: int = 1) -> bool:
return int(_items.get(item_id, 0)) >= amount
func get_count(item_id: String) -> int:
return int(_items.get(item_id, 0))
func get_all() -> Dictionary:
return _items.duplicate()
func use_item(item_id: String) -> bool:
if not has_item(item_id):
return false
match item_id:
"milk":
remove_item("milk", 1)
item_used.emit(item_id)
GameState.skip_to_final_boss()
return true
"death_ticket":
remove_item("death_ticket", 1)
Achievements.unlock("eaten_to_death")
item_used.emit(item_id)
return true
"tiramisu":
remove_item("tiramisu", 1)
var eaten := int(GameState.get_flag("tiramisu_kg", 0)) + 1
GameState.set_flag("tiramisu_kg", eaten)
if eaten >= 1000:
Achievements.unlock("tiramisu_tonne")
item_used.emit(item_id)
return true
_:
return false
func serialize() -> Dictionary:
return {"items": _items.duplicate()}
func deserialize(data: Dictionary) -> void:
_items = data.get("items", {}).duplicate()
inventory_changed.emit()

View file

@ -0,0 +1,35 @@
extends Node
## Multilingual strings DE/EN/BY with DE fallback.
signal locale_changed
var _strings: Dictionary = {}
func _ready() -> void:
reload()
func reload() -> void:
_strings = _load_lang("de")
var lang := Settings.language if Settings else "de"
if lang != "de":
var overlay := _load_lang(lang)
for key in overlay.keys():
_strings[key] = overlay[key]
locale_changed.emit()
func _load_lang(lang: String) -> Dictionary:
var path := "res://data/locales/%s.json" % lang
var file := FileAccess.open(path, FileAccess.READ)
if file == null:
push_warning("Missing locale: %s" % path)
return {}
var parsed: Variant = JSON.parse_string(file.get_as_text())
if typeof(parsed) == TYPE_DICTIONARY:
return parsed
return {}
func tr_key(key: String, args: Array = []) -> String:
var text := str(_strings.get(key, key))
if args.is_empty():
return text
return text % args

View file

@ -0,0 +1,79 @@
extends Node
## Persistent settings. Music cannot be turned down (satire).
signal settings_changed
const MUSIC_BUS := "Music"
const SFX_BUS := "SFX"
const VOICE_BUS := "Voice"
var language: String = "de"
var sfx_volume: float = 1.0
## Intentionally locked loud — slider is UI-disabled.
var music_volume: float = 1.0
func _ready() -> void:
_ensure_buses()
apply_audio()
# Locale reloads after both Settings + Locale exist (Locale._ready / deserialize).
func _ensure_buses() -> void:
_ensure_bus(MUSIC_BUS)
_ensure_bus(SFX_BUS)
_ensure_bus(VOICE_BUS)
func _ensure_bus(bus_name: String) -> void:
if AudioServer.get_bus_index(bus_name) != -1:
return
AudioServer.add_bus()
var idx := AudioServer.bus_count - 1
AudioServer.set_bus_name(idx, bus_name)
AudioServer.set_bus_send(idx, "Master")
func set_language(lang: String) -> void:
if lang not in ["de", "en", "by"]:
return
language = lang
Locale.reload()
AudioDirector.play_language_voice(lang)
settings_changed.emit()
GameState.save_game()
func set_sfx_volume(value: float) -> void:
sfx_volume = clampf(value, 0.0, 1.0)
apply_audio()
settings_changed.emit()
GameState.save_game()
func set_music_volume(_value: float) -> void:
## Satire lock: music stays maxed.
music_volume = 1.0
apply_audio()
settings_changed.emit()
func apply_audio() -> void:
_set_bus_linear(MUSIC_BUS, music_volume)
_set_bus_linear(SFX_BUS, sfx_volume)
_set_bus_linear(VOICE_BUS, sfx_volume)
func _set_bus_linear(bus_name: String, linear: float) -> void:
var idx := AudioServer.get_bus_index(bus_name)
if idx == -1:
return
var db := -80.0 if linear <= 0.0001 else 20.0 * log(linear) / log(10.0)
AudioServer.set_bus_volume_db(idx, db)
func serialize() -> Dictionary:
return {
"language": language,
"sfx_volume": sfx_volume,
"music_volume": 1.0,
}
func deserialize(data: Dictionary) -> void:
language = str(data.get("language", "de"))
sfx_volume = float(data.get("sfx_volume", 1.0))
music_volume = 1.0
Locale.reload()
apply_audio()
settings_changed.emit()

50
scripts/autoload/shop.gd Normal file
View file

@ -0,0 +1,50 @@
extends Node
## Mock Pay-to-Win shop. 1€ = 1 Dresslerpunkt (no real payments).
signal points_changed(points: int)
signal purchase_made(item_id: String)
var dressler_points: int = 0
const CATALOG := [
{"id": "buy_point", "cost": 0, "mock_euro": true},
{"id": "sunglasses", "cost": 5, "gives": "sunglasses"},
{"id": "death_ticket", "cost": 3, "gives": "death_ticket"},
{"id": "milk", "cost": 10, "gives": "milk"},
{"id": "tiramisu", "cost": 1, "gives": "tiramisu"},
]
func buy_mock_euro_point() -> void:
## Satire: pretend the user paid 1€.
dressler_points += 1
points_changed.emit(dressler_points)
GameState.save_game()
func purchase(entry_id: String) -> bool:
if entry_id == "buy_point":
buy_mock_euro_point()
return true
for entry in CATALOG:
if str(entry["id"]) != entry_id:
continue
var cost := int(entry.get("cost", 0))
if dressler_points < cost:
return false
dressler_points -= cost
var gives := str(entry.get("gives", ""))
if gives != "":
Inventory.add_item(gives, 1)
if gives == "sunglasses":
Achievements.unlock("cool_sunglasses")
points_changed.emit(dressler_points)
purchase_made.emit(entry_id)
GameState.save_game()
return true
return false
func serialize() -> Dictionary:
return {"dressler_points": dressler_points}
func deserialize(data: Dictionary) -> void:
dressler_points = int(data.get("dressler_points", 0))
points_changed.emit(dressler_points)

View file

@ -0,0 +1,37 @@
extends Node3D
class_name BillboardSprite
## 2D sprite on a 3D plane that always faces the camera (Mario-tree style).
## TODO: Swap placeholder textures for real pixel art.
@export var texture: Texture2D
@export var pixel_size: float = 0.05
@export var modulate_color: Color = Color.WHITE
@onready var _mesh_instance: MeshInstance3D = $MeshInstance3D
func _ready() -> void:
_apply()
func set_texture(tex: Texture2D) -> void:
texture = tex
_apply()
func _apply() -> void:
if _mesh_instance == null:
return
var plane := PlaneMesh.new()
plane.size = Vector2(1.0, 1.0)
_mesh_instance.mesh = plane
var mat := StandardMaterial3D.new()
mat.billboard_mode = BaseMaterial3D.BILLBOARD_ENABLED
mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
mat.texture_filter = BaseMaterial3D.TEXTURE_FILTER_NEAREST
mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA_SCISSOR
mat.alpha_scissor_threshold = 0.1
mat.albedo_color = modulate_color
if texture != null:
mat.albedo_texture = texture
var w := float(texture.get_width()) * pixel_size
var h := float(texture.get_height()) * pixel_size
plane.size = Vector2(w, h)
_mesh_instance.material_override = mat

View file

@ -0,0 +1,25 @@
extends Area3D
class_name ExitTrigger
## Walk into this volume to advance or jump to a scene.
@export var next_scene_id: String = ""
@export var use_goto_next: bool = true
@export var require_flag: String = ""
func _ready() -> void:
body_entered.connect(_on_body_entered)
collision_layer = 0
collision_mask = 2 # player layer
func _on_body_entered(body: Node3D) -> void:
if not body.is_in_group("player"):
return
if require_flag != "" and not GameState.get_flag(require_flag, false):
var ui := get_node_or_null("/root/UIRoot")
if ui and ui.has_method("show_dialog"):
ui.show_dialog(Locale.tr_key("exit_hint") + " (?)")
return
if use_goto_next and next_scene_id == "":
GameState.goto_next()
elif next_scene_id != "":
GameState.goto_scene(next_scene_id)

View file

@ -0,0 +1,24 @@
extends Area3D
class_name HazardZone
## Damages the player on enter.
@export var damage: int = 1
@export var death_cause_key: String = "death_default"
@export var cooldown: float = 1.0
var _ready_to_hurt: bool = true
func _ready() -> void:
body_entered.connect(_on_body_entered)
collision_layer = 0
collision_mask = 2
func _on_body_entered(body: Node3D) -> void:
if not _ready_to_hurt:
return
if not body.is_in_group("player"):
return
_ready_to_hurt = false
GameState.take_damage(damage, death_cause_key)
await get_tree().create_timer(cooldown).timeout
_ready_to_hurt = true

View file

@ -0,0 +1,43 @@
extends Node3D
class_name HittableEntity
## Generic enemy / boss stub with hit points.
signal defeated
signal hit(remaining_hp: int)
@export var max_hp: int = 4
@export var texture_path: String = "res://assets/sprites/placeholders/enemy.png"
@export var defeat_flag: String = ""
@export var achievement_on_defeat: String = ""
var hp: int = 4
var _alive: bool = true
@onready var _billboard: BillboardSprite = $BillboardSprite
func _ready() -> void:
hp = max_hp
add_to_group("enemy")
add_to_group("interactable")
if _billboard:
var tex: Texture2D = load(texture_path)
if tex:
_billboard.set_texture(tex)
func take_hit(amount: int = 1) -> void:
if not _alive:
return
hp = max(0, hp - amount)
hit.emit(hp)
AudioDirector.play_hit()
if hp <= 0:
_alive = false
if defeat_flag != "":
GameState.set_flag(defeat_flag, true)
if achievement_on_defeat != "":
Achievements.unlock(achievement_on_defeat)
defeated.emit()
queue_free()
func on_interact(_player: Node) -> void:
take_hit(1)

View file

@ -0,0 +1,43 @@
extends Node3D
class_name InteractableNPC
## Billboard NPC with dialog key and optional quest hook.
signal interacted(npc: InteractableNPC)
@export var dialog_key: String = "dialog_hongkong_npc"
@export var texture_path: String = "res://assets/sprites/placeholders/npc.png"
@export var give_item_id: String = ""
@export var require_item_id: String = ""
@export var consume_required: bool = false
@export var quest_flag: String = ""
@export var achievement_id: String = ""
@export var on_success_goto: String = ""
@onready var _billboard: BillboardSprite = $BillboardSprite
func _ready() -> void:
add_to_group("interactable")
if _billboard:
var tex: Texture2D = load(texture_path)
if tex:
_billboard.set_texture(tex)
func on_interact(_player: Node) -> void:
interacted.emit(self)
var ui := get_node_or_null("/root/UIRoot")
if ui and ui.has_method("show_dialog"):
ui.show_dialog(Locale.tr_key(dialog_key))
if require_item_id != "" and not Inventory.has_item(require_item_id):
return
if require_item_id != "" and consume_required:
Inventory.remove_item(require_item_id, 1)
if give_item_id != "":
Inventory.add_item(give_item_id, 1)
if quest_flag != "":
GameState.set_flag(quest_flag, true)
if quest_flag == "house_built":
GameState.house_built = true
if achievement_id != "":
Achievements.unlock(achievement_id)
if on_success_goto != "":
GameState.goto_scene(on_success_goto)

View file

@ -0,0 +1,36 @@
extends Control
## Blinky death screen with cause text + skull every 10s.
## TODO: Final flashy death art.
@onready var title: Label = $Title
@onready var cause: Label = $Cause
@onready var cont: Button = $ContinueButton
@onready var skull: ColorRect = $Skull
var _blink: float = 0.0
var _skull_timer: float = 0.0
func _ready() -> void:
GameState.current_scene_id = "death_screen"
_refresh()
Locale.locale_changed.connect(_refresh)
skull.visible = false
func _refresh() -> void:
title.text = Locale.tr_key("death_title")
cause.text = Locale.tr_key(GameState.last_death_cause_key)
cont.text = Locale.tr_key("death_continue")
func _process(delta: float) -> void:
_blink += delta
modulate = Color(1, 0.4 + 0.6 * abs(sin(_blink * 6.0)), 0.4 + 0.6 * abs(sin(_blink * 4.0)))
_skull_timer += delta
if _skull_timer >= 10.0:
_skull_timer = 0.0
skull.visible = true
await get_tree().create_timer(1.0).timeout
skull.visible = false
func _on_continue_pressed() -> void:
GameState.heal_full()
GameState.goto_scene("title_screen")

View file

@ -0,0 +1,8 @@
extends Node3D
## End village with house-build stub.
func _ready() -> void:
GameState.current_scene_id = "end_screen"
GameState.heal_full()
if GameState.get_flag("house_built", false) or GameState.house_built:
GameState.house_built = true

7
scripts/levels/farm.gd Normal file
View file

@ -0,0 +1,7 @@
extends Node3D
## Level stub: farm
## TODO: Replace placeholders with final pixel art / audio.
func _ready() -> void:
GameState.current_scene_id = "farm"
GameState.heal_full()

View file

@ -0,0 +1,7 @@
extends Node3D
## Level stub: farm_path
## TODO: Replace placeholders with final pixel art / audio.
func _ready() -> void:
GameState.current_scene_id = "farm_path"
GameState.heal_full()

View file

@ -0,0 +1,9 @@
extends Node3D
## Hongkong / Glitschkönig final boss.
func _ready() -> void:
GameState.current_scene_id = "final_boss"
GameState.heal_full()
var ui := get_node_or_null("/root/UIRoot")
if ui and ui.has_method("show_dialog"):
ui.show_dialog(Locale.tr_key("dialog_hongkong_boss"))

View file

@ -0,0 +1,35 @@
extends Control
## Intro: desk + console + explosion.
## TODO: Real cutscene art and epic narrator VO.
@onready var label: Label = $Label
var _step: int = 0
func _ready() -> void:
GameState.current_scene_id = "intro_cutscene"
AudioDirector.stop_music()
_show_step()
func _unhandled_input(event: InputEvent) -> void:
if event.is_action_pressed("skip_cutscene") or event.is_action_pressed("interact") or event.is_action_pressed("attack"):
_advance()
func _show_step() -> void:
match _step:
0:
label.text = Locale.tr_key("dialog_intro_1")
1:
label.text = Locale.tr_key("dialog_intro_2")
2:
AudioDirector.play_explosion()
label.text = "BOOM"
_:
GameState.goto_scene("village")
func _advance() -> void:
_step += 1
_show_step()
func _on_skip_pressed() -> void:
_advance()

View file

@ -0,0 +1,7 @@
extends Node3D
## Level stub: massage_studio
## TODO: Replace placeholders with final pixel art / audio.
func _ready() -> void:
GameState.current_scene_id = "massage_studio"
GameState.heal_full()

7
scripts/levels/meadow.gd Normal file
View file

@ -0,0 +1,7 @@
extends Node3D
## Level stub: meadow
## TODO: Replace placeholders with final pixel art / audio.
func _ready() -> void:
GameState.current_scene_id = "meadow"
GameState.heal_full()

7
scripts/levels/moon.gd Normal file
View file

@ -0,0 +1,7 @@
extends Node3D
## Moon cheese level.
func _ready() -> void:
GameState.current_scene_id = "moon"
GameState.heal_full()
Achievements.unlock("moon_cheese")

View file

@ -0,0 +1,9 @@
extends Node3D
## Schuhmacher Reif boss under the table.
func _ready() -> void:
GameState.current_scene_id = "shoe_boss"
GameState.heal_full()
var ui := get_node_or_null("/root/UIRoot")
if ui and ui.has_method("show_dialog"):
ui.show_dialog(Locale.tr_key("dialog_reif"))

View file

@ -0,0 +1,49 @@
extends Control
## Title screen with placeholder rain + loud music.
## TODO: Real title art (Mario/Sonic/Burger rain, disturbed human).
@onready var subtitle: Label = $Subtitle
@onready var bubble: Label = $Bubble
@onready var start_btn: Button = $StartButton
@onready var rain: Node2D = $Rain
var _rain_sprites: Array[Sprite2D] = []
func _ready() -> void:
GameState.current_scene_id = "title_screen"
AudioDirector.burger_cham_enabled = true
AudioDirector.play_title_music()
_refresh()
Locale.locale_changed.connect(_refresh)
_spawn_rain()
func _refresh() -> void:
subtitle.text = Locale.tr_key("title_subtitle")
bubble.text = Locale.tr_key("title_bubble")
start_btn.text = Locale.tr_key("menu_start")
func _spawn_rain() -> void:
var paths = [
"res://assets/sprites/placeholders/mario.png",
"res://assets/sprites/placeholders/sonic.png",
"res://assets/sprites/placeholders/burger.png",
]
for i in range(24):
var s := Sprite2D.new()
s.texture = load(paths[i % paths.size()])
s.position = Vector2(randf() * 1280.0, randf() * -720.0)
s.scale = Vector2(3, 3)
s.texture_filter = CanvasItem.TEXTURE_FILTER_NEAREST
rain.add_child(s)
_rain_sprites.append(s)
func _process(delta: float) -> void:
for s in _rain_sprites:
s.position.y += (80.0 + s.get_index() * 3.0) * delta
if s.position.y > 760.0:
s.position.y = -40.0
s.position.x = randf() * 1280.0
func _on_start_pressed() -> void:
AudioDirector.burger_cham_enabled = false
GameState.start_new_game()

View file

@ -0,0 +1,7 @@
extends Node3D
## Level stub: village
## TODO: Replace placeholders with final pixel art / audio.
func _ready() -> void:
GameState.current_scene_id = "village"
GameState.heal_full()

View file

@ -0,0 +1,58 @@
extends CharacterBody3D
class_name PlayerController
## Simple top-down-ish 3D movement for billboard levels.
@export var move_speed: float = 6.0
@export var attack_range: float = 1.8
@export var attack_damage: int = 1
signal interacted
signal attacked
@onready var _billboard: BillboardSprite = $BillboardSprite
var _locked: bool = false
func _ready() -> void:
add_to_group("player")
if _billboard and _billboard.texture == null:
var tex: Texture2D = load("res://assets/sprites/placeholders/player.png")
if tex:
_billboard.set_texture(tex)
func _physics_process(_delta: float) -> void:
if _locked or get_tree().paused:
velocity = Vector3.ZERO
return
var input := Vector2(
Input.get_axis("move_left", "move_right"),
Input.get_axis("move_up", "move_down")
)
var dir := Vector3(input.x, 0.0, input.y)
if dir.length() > 1.0:
dir = dir.normalized()
velocity = dir * move_speed
move_and_slide()
if Input.is_action_just_pressed("interact"):
_try_interact()
if Input.is_action_just_pressed("attack"):
_try_attack()
func lock_input(locked: bool) -> void:
_locked = locked
func _try_interact() -> void:
interacted.emit()
for body in get_tree().get_nodes_in_group("interactable"):
if body is Node3D and global_position.distance_to(body.global_position) <= 2.2:
if body.has_method("on_interact"):
body.on_interact(self)
return
func _try_attack() -> void:
attacked.emit()
AudioDirector.play_hit()
for body in get_tree().get_nodes_in_group("enemy"):
if body is Node3D and global_position.distance_to(body.global_position) <= attack_range:
if body.has_method("take_hit"):
body.take_hit(attack_damage)

File diff suppressed because it is too large Load diff

161
scripts/ui/ui_root.gd Normal file
View file

@ -0,0 +1,161 @@
extends CanvasLayer
## Global UI: pause, inventory, shop, settings, toast, dialog, burger-cham.
@onready var pause_panel: PanelContainer = $PausePanel
@onready var dialog_panel: PanelContainer = $DialogPanel
@onready var dialog_label: Label = $DialogPanel/Margin/DialogLabel
@onready var toast_label: Label = $ToastLabel
@onready var burger_overlay: ColorRect = $BurgerOverlay
@onready var burger_label: Label = $BurgerOverlay/BurgerLabel
@onready var points_label: Label = $PausePanel/Margin/VBox/PointsLabel
@onready var inventory_list: ItemList = $PausePanel/Margin/VBox/InventoryList
@onready var sfx_slider: HSlider = $PausePanel/Margin/VBox/SfxSlider
@onready var music_slider: HSlider = $PausePanel/Margin/VBox/MusicSlider
@onready var lang_option: OptionButton = $PausePanel/Margin/VBox/LangOption
@onready var hp_label: Label = $HUD/HpLabel
var _dialog_timer: float = 0.0
func _ready() -> void:
process_mode = Node.PROCESS_MODE_ALWAYS
layer = 100
pause_panel.visible = false
dialog_panel.visible = false
burger_overlay.visible = false
toast_label.visible = false
_setup_lang_option()
_refresh_texts()
sfx_slider.value = Settings.sfx_volume
music_slider.value = 1.0
music_slider.editable = false
Achievements.unlocked.connect(_on_achievement)
AudioDirector.burger_cham_triggered.connect(_on_burger_cham)
Settings.settings_changed.connect(_refresh_texts)
Locale.locale_changed.connect(_refresh_texts)
Inventory.inventory_changed.connect(_refresh_inventory)
Shop.points_changed.connect(_refresh_points)
GameState.hp_changed.connect(_on_hp)
_on_hp(GameState.hp, GameState.max_hp)
_refresh_inventory()
_refresh_points(Shop.dressler_points)
func _process(delta: float) -> void:
if _dialog_timer > 0.0:
_dialog_timer -= delta
if _dialog_timer <= 0.0:
dialog_panel.visible = false
func _unhandled_input(event: InputEvent) -> void:
if event.is_action_pressed("pause_menu"):
toggle_pause()
get_viewport().set_input_as_handled()
func toggle_pause() -> void:
if GameState.current_scene_id in ["title_screen", "death_screen", "intro_cutscene"]:
return
pause_panel.visible = not pause_panel.visible
get_tree().paused = pause_panel.visible
if pause_panel.visible:
_refresh_texts()
_refresh_inventory()
func show_dialog(text: String, duration: float = 3.5) -> void:
dialog_label.text = text
dialog_panel.visible = true
_dialog_timer = duration
func _setup_lang_option() -> void:
lang_option.clear()
lang_option.add_item("Deutsch", 0)
lang_option.add_item("English", 1)
lang_option.add_item("Bayerisch", 2)
match Settings.language:
"en":
lang_option.select(1)
"by":
lang_option.select(2)
_:
lang_option.select(0)
func _refresh_texts() -> void:
$PausePanel/Margin/VBox/TitleLabel.text = Locale.tr_key("menu_settings")
$PausePanel/Margin/VBox/ResumeButton.text = Locale.tr_key("menu_resume")
$PausePanel/Margin/VBox/ShopTitle.text = Locale.tr_key("menu_shop")
$PausePanel/Margin/VBox/BuyPointButton.text = Locale.tr_key("menu_buy_point")
$PausePanel/Margin/VBox/BuySunglassesButton.text = Locale.tr_key("shop_sunglasses")
$PausePanel/Margin/VBox/BuyTicketButton.text = Locale.tr_key("shop_ticket")
$PausePanel/Margin/VBox/BuyMilkButton.text = Locale.tr_key("shop_milk")
$PausePanel/Margin/VBox/BuyTiramisuButton.text = Locale.tr_key("shop_tiramisu")
$PausePanel/Margin/VBox/SfxLabel.text = Locale.tr_key("menu_sfx")
$PausePanel/Margin/VBox/MusicLabel.text = Locale.tr_key("menu_music")
$PausePanel/Margin/VBox/LangLabel.text = Locale.tr_key("menu_language")
$PausePanel/Margin/VBox/InventoryLabel.text = Locale.tr_key("menu_inventory")
_refresh_points(Shop.dressler_points)
func _refresh_points(points: int) -> void:
points_label.text = Locale.tr_key("menu_points", [str(points)])
func _refresh_inventory() -> void:
inventory_list.clear()
var items := Inventory.get_all()
for id in items.keys():
var count: int = int(items[id])
var item_name: String = Locale.tr_key("item_" + str(id))
inventory_list.add_item("%s x%d" % [item_name, count])
inventory_list.set_item_metadata(inventory_list.item_count - 1, id)
func _on_hp(hp: int, max_hp: int) -> void:
hp_label.text = "HP %d/%d" % [hp, max_hp]
func _on_achievement(_id: String, title: String) -> void:
toast_label.text = Locale.tr_key("toast_achievement", [title])
toast_label.visible = true
await get_tree().create_timer(3.0).timeout
toast_label.visible = false
func _on_burger_cham() -> void:
burger_label.text = Locale.tr_key("burger_cham")
burger_overlay.visible = true
await get_tree().create_timer(2.5).timeout
burger_overlay.visible = false
func _on_resume_pressed() -> void:
pause_panel.visible = false
get_tree().paused = false
func _on_buy_point() -> void:
Shop.purchase("buy_point")
func _on_buy_sunglasses() -> void:
Shop.purchase("sunglasses")
func _on_buy_ticket() -> void:
Shop.purchase("death_ticket")
func _on_buy_milk() -> void:
Shop.purchase("milk")
func _on_buy_tiramisu() -> void:
Shop.purchase("tiramisu")
func _on_sfx_changed(value: float) -> void:
Settings.set_sfx_volume(value)
func _on_music_changed(_value: float) -> void:
Settings.set_music_volume(1.0)
music_slider.value = 1.0
func _on_lang_selected(index: int) -> void:
match index:
1:
Settings.set_language("en")
2:
Settings.set_language("by")
_:
Settings.set_language("de")
func _on_inventory_activated(index: int) -> void:
var id: Variant = inventory_list.get_item_metadata(index)
if typeof(id) == TYPE_STRING:
Inventory.use_item(str(id))
_refresh_inventory()