This commit is contained in:
Marek Le
2026-05-09 23:37:26 +02:00
parent 6d28b04c12
commit 2d4002bd3f
263 changed files with 5250 additions and 4597 deletions

152
CLAUDE.md
View File

@@ -1,92 +1,98 @@
# MMO Projekt
## Überblick
Community-MMO in Godot 4.6 (Forward+, Jolt Physics). Ziel: Einsamkeit bekämpfen durch geographische Nähe der Spieler. Stilles Designprinzip — nicht als "Anti-Einsamkeits-Spiel" vermarktet.
Community-MMO in Godot 4.6 (Forward+, Jolt Physics). Vision in `doc/_core.md`, Features in `doc/features.md`.
## Sprache
Der User kommuniziert auf Deutsch. Code und Variablen auf Englisch. Kommentare nur wo nötig.
## Code-Style
- GDScript mit 2 Spaces Einrückung (Godot Editor auf Spaces/2 eingestellt)
- Explizite Typen bei Variablen die von Variant-Funktionen kommen (z.B. `var dist: float = ...` statt `var dist := ...` bei `distance_to()`, `min()`, `get_node_or_null()`)
- Keine Debug-Prints im finalen Code (nur temporär zum Testen)
- GDScript mit 2 Spaces Einrückung
- Explizite Typen wo der Inferenzer scheitert (Ergebnisse von Variant-Funktionen, Helper-Returns die Node sein könnten)
- Keine Debug-Prints im finalen Code
- Keine emoji
## Architektur
- **Stats (Model)**: Autoload, zentrale Datenhaltung aller Entity-Attribute. Basiswerte aus Resources.
- **Systeme (Controller)**: Scene-Nodes in world.tscn/dungeon.tscn, lesen/schreiben über Stats.
- **Szenen (Views)**: Rendern, Input senden, Events empfangen. Kein Gameplay-State.
- **EventBus (Signals)**: Autoload, Kommunikation zwischen Szenen und Systemen.
- **Event-Flow**: Szene → Input → EventBus → System → Stats → EventBus → Szene
- **Zwischen Szenen**: Kommunikation über EventBus. Szenen kennen sich nicht.
- **Innerhalb einer Szene**: Zugriff auf Geschwister-Nodes erlaubt.
- **Autoloads**: EventBus (Signals), Stats (Entity-Daten), GameState (Szene + Position)
- **Gruppen**: "player", "enemies", "portals", "boss", "cooldown_system"
- **Resources** für Basiswerte (Stats, Abilities), **Stats Autoload** für Laufzeitwerte
## Projektstruktur
Drei Grundordner nach Verantwortung. Resources liegen bei ihren Skripten.
- `scenes/` — Darstellung + Input
- `player/` — Spieler + player_stats + role/ (Rollen + Abilities)
- `enemy/` — Gegner + enemy_stats + boss/ (Boss + boss_stats)
- `portal/` — Portal + Gate + portal_stats
- `dungeon/` — Dungeon + dungeon_manager
- `hud/` — HUD
- `world/` — Hauptszene + portal_spawner
- `effect_icon_factory.gd` — Shared Utility (Effekt-Icons)
- `healthbar*.gd` — Healthbar-Komponenten (health, shield, status, effects)
- `systems/` — Spiellogik
- 12 Systeme (health, shield, ability, auto_attack, cooldown, enemy_ai, respawn, spawn, effect, element, aura, buff_calc)
- `effect.gd` — Effect Resource (Buff/Debuff/Aura Daten)
- `aggro/` — AggroSystem (system, tracker, decay, events) + aggro_config
- `autoloads/` — Globaler Zustand
- event_bus, game_state
- `stats/` — stats + base_stats
### Autoloads (`autoloads/`)
- `EventBus` — alle Signals (Combat, Wave, Inventory, Dialog, Chat, …)
- `Net` — ENet P2P Multiplayer-Manager (Singleplayer = OfflineMultiplayerPeer)
- `Stats` — zentrale Registry: `register(entity, base_resource)` / `get_stat` / `set_stat`
- `GameState` — Wave#, Scene-Pfade, Run-Seed, Pause, Dungeon-Seed
- `SaveLoad` — JSON-basierte Persistenz (Stub für später)
## Planungsdokument
`plan.md` enthält die vollständige Projektstruktur: Szenenbaum, Szenen mit Nodes, Skripte, Components, Stats, Aggro-Regeln, Abilities und Events. Dieses Dokument ist die Wahrheit für den Soll-Zustand.
### Resources (`resources/`)
- `stats/` — BaseStats + Player/Enemy/Boss/Portal/Gate/Village/Building Stats
- `abilities/` — Ability + AbilitySet (3 Klassen × 5 Abilities, im RoleSystem in Code aufgebaut)
- `items/` — Item, Recipe (Crafting in CraftingSystem in Code)
- `buildings/` — Building Resource (Blueprints in BuildingSystem in Code)
- `npcs/` — NpcProfile (Lore + Personality für Ollama)
- `effects/` — Effect Resource + Element (NONE, FIRE)
## Core Loop
1. Portale spawnen dynamisch auf der Karte (PortalSpawner, max 3, 20-40m vom Zentrum)
2. Spieler greift Portal an → Gegner spawnen bei Lebensschwellen (85%/70%/55%/40%/25%/10%)
3. Spieler bekämpft Gegner mit Abilities (Single, AOE, Utility, Ult) + Auto-Attack
4. Portal bei 0 HP → Gate spawnt, Gegner werden entfernt
5. Spieler betritt Gate → Dungeon (separate Szene, 4 Gegnergruppen + Boss)
6. Spieler kann zwischen Welt und Dungeon hin und her (beide Gates aktiv solange Boss lebt)
7. Boss stirbt → 2s Delay → Spieler wird zur Taverne teleportiert, Gates verschwinden
8. Tod → 3s Respawn bei Taverne
### Szenen (`scenes/`)
- `menu/` — main_menu, lobby, options_menu
- `world/` — world.tscn (Dorf-Welt mit Village + alle Systeme als Children)
- `dungeon/` — dungeon.tscn (procedural generation via dungeon_generator.gd)
- `entities/` — player, enemy, gate, portal, building, loot, npc, village
- `hud/` — hud.tscn (alles-in-einem: vitals, abilities, chat, minimap, inventory, crafting, build, dialog, map, pause, game over)
## Kampfsystem
- Auto-Attack: Rollenspezifisch (D: 10 Schaden/10m, T: 5 Schaden/3m, H: 1 Heilung/20m), 0.5s CD
- 5 Abilities pro Rolle: Single, AOE, Utility, Ult, Passive — jede Rolle hat eigene Werte
- GCD 0.5s (gilt für 1, 2, 4), Utility ignoriert GCD
- Heilung: Heiler heilt sich selbst (Singleplayer), is_heal Flag auf Ability
- Passive: Schadens-Boost (D), Schild-Boost (T), Heal-Boost (H) — je 50%, als Aura (50m Radius)
- Targeting: Klick, TAB (cyclet "enemies" + "portals"), Auto-Target (Gegner > Portal)
- Aggro-System: 1:1 Schaden, Tank 2x, Heilung 0.5x, verfällt -1/s, exponentiell außerhalb Portal-Radius
### Systeme (`systems/`)
Alle als Children unter `World/Systems` und `Dungeon/Systems` instanziert:
- Combat: HealthSystem, ShieldSystem, RespawnSystem, CooldownSystem, AbilitySystem, AutoAttackSystem
- Effects: EffectSystem, ElementSystem, AggroSystem, RoleSystem
- World: SpawnSystem, WaveSystem, InvasionSystem, XpSystem, LootSystem
- Player: InventorySystem, CraftingSystem, BuildingSystem
- Social: NpcSystem, DialogSystem (Ollama HTTP), ChatSystem, MapSystem, AudioSystem
## Rollen
- Tank (T), Schaden (D), Heiler (H) — wechselbar mit ALT+1/2/3
- Jede Rolle hat eigenes AbilitySet mit unterschiedlichen Werten und Mechaniken
- Tank: Weniger Schaden, Nahkampf, Schild-Ult (300%), Schild-Passive
- Heiler: Heilt statt schadet (Single, AOE, Ult), Heal-Passive, AOE macht Schaden
### Multiplayer-Pattern
- ENet P2P, Host-Authoritative
- Singleplayer = `Net.host_singleplayer()` (OfflineMultiplayerPeer)
- Player-Entities tragen Authority des jeweiligen Peers (`_enter_tree() → set_multiplayer_authority(name.to_int())`)
- Stats/Damage/Spawn/Wave/XP/Inventory laufen nur am Host (`if not multiplayer.is_server() and multiplayer.multiplayer_peer != null: return`)
- Client-Aktionen via RPC zum Host: `_request_*.rpc_id(1, ...)` → Host validiert + sync via `MultiplayerSpawner` und `MultiplayerSynchronizer`
- Sync-Felder pro Entity über `MultiplayerSynchronizer` mit `SceneReplicationConfig`
## Effekte & Elemente
- **EffectSystem**: Verwaltet Buffs, Debuffs, Auras auf allen Entities. Kein Stacking (gleicher Name → Refresh)
- **Effect Resource**: effect_name, type (BUFF/DEBUFF/AURA), stat, value, duration (-1=permanent), is_multiplier, aura_radius, tick_interval, element
- **Auras**: Passive-Abilities werden als AURA-Effekte erstellt. Propagieren Buffs auf Spieler im aura_radius. Verlässt man Radius → Buff sofort weg
- **ElementSystem**: Verwaltet Elementar-Zustände auf Enemies + Portalen. Aktuell: Feuer (DoT 3/Tick, 2s Interval, 6s)
- **Abilities**: `element` Feld (0=NONE, 1=FIRE). Schadens-Abilities der Damage-Rolle haben element=1 (Feuer)
- **Effekt-Icons**: Auf Healthbars (Enemies, Boss, Portal) und Spieler-HUD. Aura=blau, Buff=grün, Debuff=rot
### Input-Map
- WASD + Space: Move + Jump
- 14: Abilities (im Build-Mode: Bauteil-Auswahl)
- ALT+1/2/3: Tank/Damage/Healer
- Tab: Cycle target | LMB: Click target | RMB: Camera drag (capture mouse)
- I: Inventory | C: Crafting | B: Build mode | M: Map | Y: Chat | E: Interact (NPC)
- R: Rotate building preview
- Escape: Pause / Cancel UI
## Szenenwechsel
- Stats Autoload cached Spieler-Werte automatisch bei Szenenwechsel
- GameState speichert Rolle + Position
- Gate (Eingang): save_player → Dungeon laden
- Gate (Exit): returning_from_dungeon → Welt laden, Spieler bei Gate-Position
- Boss-Tod: dungeon_cleared → Welt laden, Cache geleert, Spieler bei Taverne mit vollen HP
### Dialog (Ollama)
- HTTP `localhost:11434/api/generate`, Modell `llama3.2`
- Pro NPC: System-Prompt aus `lore` + `personality`
- Fallback: `npc.fallback_text` wenn Ollama nicht erreichbar — Spiel bleibt voll spielbar
### Bauen
- Grid-Snap 1m, 4 Bauteile (Floor, Wall, Door, Roof)
- LMB im Build-Mode platziert, MMB entfernt
- Material-Verbrauch aus Crafting-Items (alle aus `wood`, das aus `essence` gecrafted wird)
- Persistenz via Host (Buildings sind echte Nodes unter `EntityRoot/Buildings`, MultiplayerSpawner repliziert)
### Wave-Loop
1. WaveSystem startet 10-min Timer, spawnt 3 normale + 1 rotes Gate
2. Gate stirbt → spawnt Portal an gleicher Position
3. Spieler betritt Portal → Dungeon (procedural, 58 Räume + Boss)
4. Boss-Tod → Welt-Rückkehr; rotes Portal → Wave++ + alle Stats skalieren
5. Timer 0 ohne rotes Portal → Invasion (8+wave×2 Mobs zur Village)
6. Village 0 HP → Game Over → Hauptmenü
## Workflow mit dem User
- **plan.md ist zentral** — User will Änderungen zuerst in plan.md dokumentiert haben, dann implementieren
- **Modularer Aufbau**Jede Szene/Skript hat eine Aufgabe
- **Schrittweise** — Erst planen, dann Code zeigen, dann implementieren
- **Godot-Eigenheiten**: Nach Änderungen an Autoloads/Scripts Godot neu starten. Godot überschreibt .tscn beim Speichern — Szene im Editor schließen vor externen Edits.
- Erst planen (`doc/` lesen), dann implementieren
- Schrittweise, modulares Aufbauennicht alles auf einmal umbauen
- Nach Änderungen: `godot-4 --headless --quit-after 60 res://scenes/world/world.tscn` um Parse-/Runtime-Fehler zu sehen
- `doc/` nicht anfassen — das ist die User-Spec
## Smoke-Test
- `godot-4 --headless --import` → Kein Error
- `godot-4 --headless --quit-after 600 res://scenes/world/world.tscn` → Kein Error
- `godot-4 --headless --quit-after 600 res://scenes/dungeon/dungeon.tscn` → Kein Error
## Bekannte Lücken / TODOs
- Ollama muss installiert werden (Modell `llama3.2` erwartet) — Stub-Fallback funktioniert sonst
- Save/Load Slots im Hauptmenü (Autoload existiert, UI fehlt)
- Multiplayer Late-Join: Building-/Wave-State wird beim spätem Beitritt nicht synchronisiert
- Damage Numbers / Hit FX / Particle Effects fehlen (rein Polish)

View File

@@ -1,82 +0,0 @@
extends Node
var entities: Dictionary = {}
func register(entity: Node, base: EnemyStats, scale: float = 1.0) -> void:
var max_hp: float = base.max_health * scale
var max_sh: float = base.max_shield * scale
entities[entity] = {
"base": base,
"scale": scale,
"health": max_hp,
"max_health": max_hp,
"health_regen": base.health_regen * scale,
"shield": max_sh,
"max_shield": max_sh,
"shield_regen_delay": base.shield_regen_delay,
"shield_regen_time": base.shield_regen_time,
"shield_regen_timer": 0.0,
"alive": true,
"buff_damage": 1.0,
"buff_heal": 1.0,
"buff_shield": 1.0,
"state": 0,
"target": null,
"spawn_position": Vector3.ZERO,
"portal": null,
"attack_timer": 0.0,
}
func apply_scale(entity: Node, scale: float) -> void:
if entity not in entities:
return
var data: Dictionary = entities[entity]
var base: EnemyStats = data["base"]
data["scale"] = scale
data["max_health"] = base.max_health * scale
data["health"] = data["max_health"]
data["health_regen"] = base.health_regen * scale
data["max_shield"] = base.max_shield * scale
data["shield"] = data["max_shield"]
EventBus.health_changed.emit(entity, data["health"], data["max_health"])
if base.max_shield > 0:
EventBus.shield_changed.emit(entity, data["shield"], data["max_shield"])
func deregister(entity: Node) -> void:
entities.erase(entity)
func get_stat(entity: Node, key: String) -> Variant:
if entity in entities:
return entities[entity].get(key)
return null
func set_stat(entity: Node, key: String, value: Variant) -> void:
if entity in entities:
entities[entity][key] = value
func get_base(entity: Node) -> EnemyStats:
if entity in entities:
return entities[entity]["base"]
return null
func is_alive(entity: Node) -> bool:
if entity in entities:
return entities[entity]["alive"]
return false
func set_health(entity: Node, value: float) -> void:
if entity not in entities:
return
entities[entity]["health"] = value
var max_health: float = entities[entity]["max_health"]
EventBus.health_changed.emit(entity, value, max_health)
if value <= 0 and entities[entity]["alive"]:
entities[entity]["alive"] = false
EventBus.entity_died.emit(entity)
func set_shield(entity: Node, value: float) -> void:
if entity not in entities:
return
entities[entity]["shield"] = value
var max_shield: float = entities[entity]["max_shield"]
EventBus.shield_changed.emit(entity, value, max_shield)

View File

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

View File

@@ -1,82 +0,0 @@
extends Node
var entities: Dictionary = {}
func register(entity: Node, base: EnemyStats, scale: float = 1.0) -> void:
var max_hp: float = base.max_health * scale
var max_sh: float = base.max_shield * scale
entities[entity] = {
"base": base,
"scale": scale,
"health": max_hp,
"max_health": max_hp,
"health_regen": base.health_regen * scale,
"shield": max_sh,
"max_shield": max_sh,
"shield_regen_delay": base.shield_regen_delay,
"shield_regen_time": base.shield_regen_time,
"shield_regen_timer": 0.0,
"alive": true,
"buff_damage": 1.0,
"buff_heal": 1.0,
"buff_shield": 1.0,
"state": 0,
"target": null,
"spawn_position": Vector3.ZERO,
"portal": null,
"attack_timer": 0.0,
}
func apply_scale(entity: Node, scale: float) -> void:
if entity not in entities:
return
var data: Dictionary = entities[entity]
var base: EnemyStats = data["base"]
data["scale"] = scale
data["max_health"] = base.max_health * scale
data["health"] = data["max_health"]
data["health_regen"] = base.health_regen * scale
data["max_shield"] = base.max_shield * scale
data["shield"] = data["max_shield"]
EventBus.health_changed.emit(entity, data["health"], data["max_health"])
if base.max_shield > 0:
EventBus.shield_changed.emit(entity, data["shield"], data["max_shield"])
func deregister(entity: Node) -> void:
entities.erase(entity)
func get_stat(entity: Node, key: String) -> Variant:
if entity in entities:
return entities[entity].get(key)
return null
func set_stat(entity: Node, key: String, value: Variant) -> void:
if entity in entities:
entities[entity][key] = value
func get_base(entity: Node) -> EnemyStats:
if entity in entities:
return entities[entity]["base"]
return null
func is_alive(entity: Node) -> bool:
if entity in entities:
return entities[entity]["alive"]
return false
func set_health(entity: Node, value: float) -> void:
if entity not in entities:
return
entities[entity]["health"] = value
var max_health: float = entities[entity]["max_health"]
EventBus.health_changed.emit(entity, value, max_health)
if value <= 0 and entities[entity]["alive"]:
entities[entity]["alive"] = false
EventBus.entity_died.emit(entity)
func set_shield(entity: Node, value: float) -> void:
if entity not in entities:
return
entities[entity]["shield"] = value
var max_shield: float = entities[entity]["max_shield"]
EventBus.shield_changed.emit(entity, value, max_shield)

View File

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

View File

@@ -1,73 +1,60 @@
extends Node
# Intentionen (Input → System)
signal ability_use(player, ability_index)
signal role_change_requested(player, role)
signal target_requested(player, target)
signal enemy_detected(enemy, player)
signal enemy_lost(enemy, player)
signal portal_entered(portal, player)
signal entity_registered(entity: Node)
signal entity_deregistered(entity: Node)
# Kampf
signal attack_executed(attacker, position, direction, damage)
signal damage_dealt(attacker, target, damage)
signal damage_requested(attacker, target, amount)
signal heal_requested(healer, target, amount)
signal damage_requested(attacker: Node, target: Node, amount: float, element: int)
signal heal_requested(healer: Node, target: Node, amount: float)
signal damage_dealt(attacker: Node, target: Node, amount: float)
signal entity_died(entity: Node)
signal entity_respawned(entity: Node)
signal health_changed(entity: Node, current: float, max: float)
signal shield_changed(entity: Node, current: float, max: float)
signal shield_broken(entity: Node)
# Entity
signal entity_died(entity)
signal health_changed(entity, current, max_val)
signal shield_changed(entity, current, max_val)
signal shield_broken(entity)
signal shield_regenerated(entity)
signal ability_use_requested(player: Node, ability_index: int)
signal ability_used(player: Node, ability_index: int, ability: Resource)
signal cooldown_tick(entity: Node, cds: PackedFloat32Array, max_cds: PackedFloat32Array, gcd: float)
signal role_changed(player: Node, role: int)
signal target_changed(player: Node, target: Node)
# Spieler
signal target_changed(player, target)
signal player_respawned(player)
signal role_changed(player, role_type)
signal respawn_tick(timer)
signal cooldown_tick(cooldowns, max_cooldowns, gcd_timer)
signal effect_applied(target: Node, effect: Resource, source: Node)
signal effect_expired(target: Node, effect: Resource)
signal element_applied(target: Node, element: int)
signal buff_changed(entity: Node, stat: StringName, value: float)
# Buff
signal buff_changed(entity, stat, value)
signal enemy_detected(enemy: Node, player: Node)
signal enemy_lost(enemy: Node, player: Node)
signal enemy_engaged(enemy: Node, target: Node)
# Gegner
signal enemy_engaged(enemy, target)
signal gate_destroyed(gate: Node)
signal portal_spawned(portal: Node)
signal portal_entered(portal: Node, player: Node)
signal dungeon_cleared(seed: int)
signal boss_defeated(boss: Node)
# Portal
signal portal_spawn(portal, enemies)
signal portal_defeated(portal)
signal loot_dropped(items: Array, position: Vector3)
signal item_picked_up(player: Node, item: Resource)
signal inventory_changed(player: Node)
signal item_crafted(player: Node, item: Resource)
signal building_placed(building: Node)
signal building_removed(building: Node)
# Dungeon
signal dungeon_cleared()
# Effects
signal effect_requested(target, effect, source)
signal effect_applied(target, effect)
signal effect_expired(target, effect)
# Elements
signal element_damage_dealt(attacker, target, amount, element)
signal element_applied(target, element)
signal element_reaction(target, element_a, element_b, reaction_name)
# Wave
signal run_started(wave_number)
signal wave_started(wave_number)
signal wave_timer_tick(seconds_remaining)
signal wave_ended(wave_number, success)
# XP / Level
signal xp_gained(player, amount)
signal level_up(player, new_level)
# Taverne
signal tavern_damaged(current, max_val)
signal tavern_destroyed()
# Invasion
signal invasion_started(enemies)
signal invasion_ended(success)
# Game-Over
signal wave_started(wave_number: int)
signal wave_timer_tick(seconds_remaining: float)
signal wave_ended(wave_number: int, success: bool)
signal invasion_started()
signal invasion_ended(success: bool)
signal village_damaged(current: float, max: float)
signal village_destroyed()
signal game_over()
signal run_started(wave_number: int)
signal xp_gained(player: Node, amount: float)
signal level_up(player: Node, new_level: int)
signal dialog_opened(player: Node, npc: Node)
signal dialog_closed(player: Node)
signal chat_message(peer_id: int, sender_name: String, text: String)
signal scene_change_requested(scene_path: String)

View File

@@ -1 +1 @@
uid://g7a7xkg1pgb4
uid://361x7bdk2j6v

View File

@@ -1,19 +1,36 @@
extends Node
# Run-Zustand
const ROLE_TANK: int = 0
const ROLE_DAMAGE: int = 1
const ROLE_HEALER: int = 2
const SCENE_MAIN_MENU: String = "res://scenes/menu/main_menu.tscn"
const SCENE_LOBBY: String = "res://scenes/menu/lobby.tscn"
const SCENE_WORLD: String = "res://scenes/world/world.tscn"
const SCENE_DUNGEON: String = "res://scenes/dungeon/dungeon.tscn"
const SCENE_OPTIONS: String = "res://scenes/menu/options_menu.tscn"
var current_scene: String = SCENE_MAIN_MENU
var paused: bool = false
var run_seed: int = 0
var dungeon_seed: int = 0
var dungeon_red: bool = false
var current_wave: int = 1
var wave_timer_remaining: float = 0.0
var run_initialized: bool = false
var portal_return_position: Vector3 = Vector3.ZERO
# Dungeon-Kontext (für XP-Zuordnung nach Clear)
var last_dungeon_variant: int = 0
# Flag für Forced Return (Timer läuft ab während Spieler im Dungeon)
var force_return_to_world: bool = false
func reset() -> void:
func reset_run() -> void:
run_seed = randi()
current_wave = 1
wave_timer_remaining = 0.0
run_initialized = false
last_dungeon_variant = 0
force_return_to_world = false
dungeon_seed = 0
dungeon_red = false
paused = false
Stats.clear_all()
func change_scene(path: String) -> void:
current_scene = path
EventBus.scene_change_requested.emit(path)
get_tree().change_scene_to_file(path)
func set_paused(value: bool) -> void:
paused = value
get_tree().paused = value

View File

@@ -1 +1 @@
uid://c3jq4raqs0onf
uid://dettmu50fjtvc

148
autoloads/net.gd Normal file
View File

@@ -0,0 +1,148 @@
extends Node
const DEFAULT_PORT: int = 7777
const MAX_PLAYERS: int = 4
signal peer_connected(id: int)
signal peer_disconnected(id: int)
signal connected_to_server()
signal connection_failed()
signal server_disconnected()
signal world_ready()
signal peer_world_loaded(peer_id: int)
var player_names: Dictionary = {}
var local_name: String = "Player"
var _expected_peers: Array = []
var _ready_peers: Array = []
func _ready() -> void:
multiplayer.peer_connected.connect(_on_peer_connected)
multiplayer.peer_disconnected.connect(_on_peer_disconnected)
multiplayer.connected_to_server.connect(_on_connected_to_server)
multiplayer.connection_failed.connect(_on_connection_failed)
multiplayer.server_disconnected.connect(_on_server_disconnected)
func host(port: int = DEFAULT_PORT, max_clients: int = MAX_PLAYERS - 1) -> bool:
var peer := ENetMultiplayerPeer.new()
var err := peer.create_server(port, max_clients)
if err != OK:
push_error("Net: failed to host on port %d (err=%d)" % [port, err])
return false
multiplayer.multiplayer_peer = peer
player_names[1] = local_name
return true
func join(address: String, port: int = DEFAULT_PORT) -> bool:
var peer := ENetMultiplayerPeer.new()
var err := peer.create_client(address, port)
if err != OK:
push_error("Net: failed to join %s:%d (err=%d)" % [address, port, err])
return false
multiplayer.multiplayer_peer = peer
return true
func host_singleplayer() -> bool:
if multiplayer.multiplayer_peer != null:
multiplayer.multiplayer_peer.close()
var peer := OfflineMultiplayerPeer.new()
multiplayer.multiplayer_peer = peer
player_names.clear()
player_names[1] = local_name
return true
func disconnect_net() -> void:
if multiplayer.multiplayer_peer != null:
multiplayer.multiplayer_peer.close()
multiplayer.multiplayer_peer = null
player_names.clear()
func is_host() -> bool:
return multiplayer.multiplayer_peer == null or multiplayer.is_server()
func is_authority_for(node: Node) -> bool:
return node.is_multiplayer_authority()
func local_id() -> int:
if multiplayer.multiplayer_peer == null:
return 1
return multiplayer.get_unique_id()
func _on_peer_connected(id: int) -> void:
peer_connected.emit(id)
if is_host():
_register_name.rpc_id(id, 1, local_name)
func _on_peer_disconnected(id: int) -> void:
player_names.erase(id)
peer_disconnected.emit(id)
func _on_connected_to_server() -> void:
player_names[1] = "Host"
player_names[multiplayer.get_unique_id()] = local_name
_register_name.rpc(multiplayer.get_unique_id(), local_name)
connected_to_server.emit()
func _on_connection_failed() -> void:
multiplayer.multiplayer_peer = null
connection_failed.emit()
func _on_server_disconnected() -> void:
multiplayer.multiplayer_peer = null
player_names.clear()
server_disconnected.emit()
@rpc("any_peer", "reliable", "call_local")
func _register_name(peer_id: int, name: String) -> void:
player_names[peer_id] = name
func mark_world_loaded() -> void:
if multiplayer.multiplayer_peer == null:
_expected_peers = [1]
_ready_peers = [1]
world_ready.emit()
return
if is_host():
_expected_peers = [1]
for p in multiplayer.get_peers():
_expected_peers.append(p)
_ready_peers = [1]
if _all_ready():
_broadcast_ready.rpc()
else:
_client_world_loaded.rpc_id(1, multiplayer.get_unique_id())
@rpc("any_peer", "reliable")
func _client_world_loaded(peer_id: int) -> void:
if not is_host():
return
if not peer_id in _expected_peers:
_expected_peers.append(peer_id)
if not peer_id in _ready_peers:
_ready_peers.append(peer_id)
peer_world_loaded.emit(peer_id)
if _all_ready():
_broadcast_ready.rpc()
@rpc("authority", "reliable")
func _load_scene_for_peer(path: String) -> void:
GameState.change_scene(path)
func tell_peer_to_load_scene(peer_id: int, path: String) -> void:
if not is_host():
return
_load_scene_for_peer.rpc_id(peer_id, path)
@rpc("authority", "reliable", "call_local")
func _broadcast_ready() -> void:
world_ready.emit()
func _all_ready() -> bool:
for p in _expected_peers:
if not p in _ready_peers:
return false
return true
func reset_world_ready() -> void:
_expected_peers.clear()
_ready_peers.clear()

1
autoloads/net.gd.uid Normal file
View File

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

View File

@@ -1,195 +0,0 @@
extends Node
enum Role { TANK, DAMAGE, HEALER }
# Basis (aus Resource geladen)
var base: PlayerStats
var speed := 5.0
var jump_velocity := 4.5
var target_range := 20.0
var combat_timeout := 3.0
var respawn_time := 3.0
var gcd_time := 0.5
var aa_cooldown := 0.5
# Laufzeit
var health := 100.0
var max_health := 100.0
var health_regen := 0.0
var shield := 0.0
var max_shield := 0.0
var shield_regen_delay := 3.0
var shield_regen_time := 5.0
var shield_regen_timer := 0.0
var alive := true
# Buffs
var buff_damage := 1.0
var buff_heal := 1.0
var buff_shield := 1.0
# Level / XP
const XP_PER_LEVEL: int = 50
var level: int = 1
var xp: int = 0
var xp_to_next: int = XP_PER_LEVEL
var level_scale: float = 1.0
# Rolle
var current_role: int = Role.DAMAGE
var ability_set: AbilitySet = null
# Kampf
var target: Node3D = null
var in_combat := false
var combat_timer := 0.0
# Cooldowns
var cooldowns: Array[float] = []
var max_cooldowns: Array[float] = []
var gcd := 0.0
var aa_timer := 0.0
# Szenenwechsel
var portal_position := Vector3.ZERO
var returning_from_dungeon := false
var dungeon_cleared := false
# Cache für Szenenwechsel
var _cache: Dictionary = {}
func init_from_resource(res: PlayerStats) -> void:
base = res
speed = res.speed
jump_velocity = res.jump_velocity
target_range = res.target_range
combat_timeout = res.combat_timeout
respawn_time = res.respawn_time
gcd_time = res.gcd_time
aa_cooldown = res.aa_cooldown
if _cache.is_empty():
max_health = res.max_health * level_scale
health = max_health
health_regen = res.health_regen * level_scale
max_shield = res.max_shield * level_scale
shield = max_shield
shield_regen_delay = res.shield_regen_delay
shield_regen_time = res.shield_regen_time
shield_regen_timer = 0.0
alive = true
buff_damage = 1.0
buff_heal = 1.0
buff_shield = 1.0
else:
_restore_cache()
cooldowns.resize(5)
cooldowns.fill(0.0)
max_cooldowns.resize(5)
max_cooldowns.fill(0.0)
gcd = 0.0
aa_timer = 0.0
func set_health(value: float) -> void:
health = value
EventBus.health_changed.emit(self, health, max_health)
if health <= 0 and alive:
alive = false
EventBus.entity_died.emit(self)
func set_shield(value: float) -> void:
shield = value
EventBus.shield_changed.emit(self, shield, max_shield)
func set_role(role: int) -> void:
current_role = role
EventBus.role_changed.emit(self, current_role)
func set_target(new_target: Node3D) -> void:
target = new_target
EventBus.target_changed.emit(self, target)
func respawn() -> void:
health = max_health
shield = max_shield
alive = true
EventBus.health_changed.emit(self, health, max_health)
EventBus.shield_changed.emit(self, shield, max_shield)
EventBus.player_respawned.emit(self)
func save_cache() -> void:
_cache = {
"health": health,
"max_health": max_health,
"health_regen": health_regen,
"shield": shield,
"max_shield": max_shield,
"shield_regen_delay": shield_regen_delay,
"shield_regen_time": shield_regen_time,
"alive": alive,
"buff_damage": buff_damage,
"buff_heal": buff_heal,
"buff_shield": buff_shield,
}
func clear_cache() -> void:
_cache.clear()
portal_position = Vector3.ZERO
returning_from_dungeon = false
dungeon_cleared = false
func reset_run() -> void:
clear_cache()
level = 1
xp = 0
xp_to_next = XP_PER_LEVEL
level_scale = 1.0
func add_xp(amount: int) -> void:
xp += amount
EventBus.xp_gained.emit(self, amount)
while xp >= xp_to_next:
xp -= xp_to_next
level_up()
func level_up() -> void:
level += 1
level_scale = float(_fibonacci(level))
xp_to_next = XP_PER_LEVEL * _fibonacci(level)
if base:
max_health = base.max_health * level_scale
max_shield = base.max_shield * level_scale
else:
max_health = 100.0 * level_scale
max_shield = 50.0 * level_scale
health = max_health
shield = max_shield
EventBus.health_changed.emit(self, health, max_health)
EventBus.shield_changed.emit(self, shield, max_shield)
EventBus.level_up.emit(self, level)
func _fibonacci(n: int) -> int:
if n <= 1:
return 1
if n == 2:
return 2
var a := 1
var b := 2
for i in range(3, n + 1):
var c := a + b
a = b
b = c
return b
func _restore_cache() -> void:
health = _cache.get("health", max_health)
max_health = _cache.get("max_health", max_health)
health_regen = _cache.get("health_regen", 0.0)
shield = _cache.get("shield", 0.0)
max_shield = _cache.get("max_shield", 0.0)
shield_regen_delay = _cache.get("shield_regen_delay", 3.0)
shield_regen_time = _cache.get("shield_regen_time", 5.0)
alive = _cache.get("alive", true)
buff_damage = _cache.get("buff_damage", 1.0)
buff_heal = _cache.get("buff_heal", 1.0)
buff_shield = _cache.get("buff_shield", 1.0)
_cache.clear()

View File

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

View File

@@ -1,45 +0,0 @@
extends Node
var entities: Dictionary = {}
func register(entity: Node, base: PortalStats) -> void:
var thresholds: Array[float] = base.thresholds.duplicate()
var triggered: Array[bool] = []
triggered.resize(thresholds.size())
triggered.fill(false)
entities[entity] = {
"base": base,
"health": base.max_health,
"max_health": base.max_health,
"alive": true,
"spawn_count": base.spawn_count,
"thresholds": thresholds,
"triggered": triggered,
}
func deregister(entity: Node) -> void:
entities.erase(entity)
func get_stat(entity: Node, key: String) -> Variant:
if entity in entities:
return entities[entity].get(key)
return null
func set_stat(entity: Node, key: String, value: Variant) -> void:
if entity in entities:
entities[entity][key] = value
func is_alive(entity: Node) -> bool:
if entity in entities:
return entities[entity]["alive"]
return false
func set_health(entity: Node, value: float) -> void:
if entity not in entities:
return
entities[entity]["health"] = value
var max_health: float = entities[entity]["max_health"]
EventBus.health_changed.emit(entity, value, max_health)
if value <= 0 and entities[entity]["alive"]:
entities[entity]["alive"] = false
EventBus.entity_died.emit(entity)

View File

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

46
autoloads/save_load.gd Normal file
View File

@@ -0,0 +1,46 @@
extends Node
const SAVE_DIR: String = "user://saves/"
func ensure_dir() -> void:
DirAccess.make_dir_recursive_absolute(SAVE_DIR)
func save_path(slot: String) -> String:
return SAVE_DIR + slot + ".save"
func save_run(slot: String, payload: Dictionary) -> bool:
ensure_dir()
var f := FileAccess.open(save_path(slot), FileAccess.WRITE)
if f == null:
push_error("SaveLoad: cannot open %s for write" % slot)
return false
f.store_string(JSON.stringify(payload, " "))
f.close()
return true
func load_run(slot: String) -> Dictionary:
var path := save_path(slot)
if not FileAccess.file_exists(path):
return {}
var f := FileAccess.open(path, FileAccess.READ)
var text := f.get_as_text()
f.close()
var data: Variant = JSON.parse_string(text)
if typeof(data) != TYPE_DICTIONARY:
return {}
return data
func list_slots() -> Array[String]:
ensure_dir()
var out: Array[String] = []
var d := DirAccess.open(SAVE_DIR)
if d == null:
return out
d.list_dir_begin()
var fname := d.get_next()
while fname != "":
if not d.current_is_dir() and fname.ends_with(".save"):
out.append(fname.trim_suffix(".save"))
fname = d.get_next()
d.list_dir_end()
return out

View File

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

124
autoloads/stats.gd Normal file
View File

@@ -0,0 +1,124 @@
extends Node
const SYNCED_STATS: Array = [
"health", "shield", "max_health", "max_shield",
"role", "level", "xp", "xp_to_next",
"buff_damage", "buff_heal", "buff_shield",
]
const SYNC_INTERVAL: float = 0.10
var _entities: Dictionary = {}
var _player_cache: Dictionary = {}
var _dirty: Dictionary = {}
var _accum: float = 0.0
func _ready() -> void:
set_process(true)
func _process(delta: float) -> void:
_accum += delta
if _accum < SYNC_INTERVAL:
return
_accum = 0.0
if multiplayer.multiplayer_peer == null or multiplayer.multiplayer_peer is OfflineMultiplayerPeer or not multiplayer.is_server():
_dirty.clear()
return
if _dirty.is_empty():
return
for entity in _dirty.keys():
if not is_instance_valid(entity) or not entity.is_inside_tree():
continue
_sync_stats_batch.rpc(String(entity.get_path()), _dirty[entity])
_dirty.clear()
@rpc("authority", "unreliable_ordered")
func _sync_stats_batch(path_str: String, changes: Dictionary) -> void:
var entity := get_node_or_null(NodePath(path_str))
if entity == null or not entity in _entities:
return
for stat in changes.keys():
_entities[entity][stat] = changes[stat]
match stat:
"health":
EventBus.health_changed.emit(entity, changes[stat], _entities[entity].get("max_health", 100.0))
"shield":
EventBus.shield_changed.emit(entity, changes[stat], _entities[entity].get("max_shield", 0.0))
"role":
EventBus.role_changed.emit(entity, changes[stat])
"level":
EventBus.level_up.emit(entity, changes[stat])
"buff_damage", "buff_heal", "buff_shield":
EventBus.buff_changed.emit(entity, StringName(stat), changes[stat])
func register(entity: Node, base_resource: Resource) -> void:
if not is_instance_valid(entity):
return
var data: Dictionary = {}
for prop in base_resource.get_property_list():
var name: String = prop.name
if not (prop.usage & PROPERTY_USAGE_STORAGE):
continue
if name in ["resource_local_to_scene", "resource_path", "resource_name", "resource_scene_unique_id", "script"]:
continue
data[name] = base_resource.get(name)
data["health"] = data.get("max_health", 100.0)
data["shield"] = data.get("max_shield", 0.0)
data["shield_regen_timer"] = 0.0
data["base_resource"] = base_resource
_entities[entity] = data
EventBus.entity_registered.emit(entity)
func deregister(entity: Node) -> void:
if entity in _entities:
_entities.erase(entity)
EventBus.entity_deregistered.emit(entity)
func has(entity: Node) -> bool:
return entity in _entities
func get_stat(entity: Node, stat: String, default: Variant = 0.0) -> Variant:
if entity in _entities:
return _entities[entity].get(stat, default)
return default
func set_stat(entity: Node, stat: String, value: Variant) -> void:
if not entity in _entities:
return
var prev: Variant = _entities[entity].get(stat)
_entities[entity][stat] = value
if stat in SYNCED_STATS and prev != value and multiplayer.multiplayer_peer != null and not (multiplayer.multiplayer_peer is OfflineMultiplayerPeer) and multiplayer.is_server() and is_instance_valid(entity) and entity.is_inside_tree():
if not entity in _dirty:
_dirty[entity] = {}
_dirty[entity][stat] = value
func get_all(entity: Node) -> Dictionary:
return _entities.get(entity, {})
func entities() -> Array:
return _entities.keys()
func entities_in_group(group: StringName) -> Array:
var out: Array = []
for e in _entities.keys():
if is_instance_valid(e) and e.is_in_group(group):
out.append(e)
return out
func cache_player(peer_id: int, entity: Node) -> void:
if entity in _entities:
_player_cache[peer_id] = _entities[entity].duplicate(true)
func restore_player(peer_id: int, entity: Node) -> void:
if peer_id in _player_cache:
_entities[entity] = _player_cache[peer_id].duplicate(true)
func clear_player_cache(peer_id: int = -1) -> void:
if peer_id == -1:
_player_cache.clear()
else:
_player_cache.erase(peer_id)
func clear_all() -> void:
_entities.clear()
_player_cache.clear()

1
autoloads/stats.gd.uid Normal file
View File

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

View File

@@ -1,8 +0,0 @@
extends Resource
class_name BaseStats
@export var max_health := 100.0
@export var health_regen := 0.0
@export var max_shield := 0.0
@export var shield_regen_delay := 3.0
@export var shield_regen_time := 5.0

View File

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

View File

@@ -1,38 +0,0 @@
extends Node
var entities: Dictionary = {}
func register(entity: Node, base: Resource) -> void:
entities[entity] = {
"base": base,
"health": base.max_health,
"max_health": base.max_health,
"alive": true,
}
func deregister(entity: Node) -> void:
entities.erase(entity)
func get_stat(entity: Node, key: String) -> Variant:
if entity in entities:
return entities[entity].get(key)
return null
func set_stat(entity: Node, key: String, value: Variant) -> void:
if entity in entities:
entities[entity][key] = value
func is_alive(entity: Node) -> bool:
if entity in entities:
return entities[entity]["alive"]
return false
func set_health(entity: Node, value: float) -> void:
if entity not in entities:
return
entities[entity]["health"] = value
var max_health: float = entities[entity]["max_health"]
EventBus.tavern_damaged.emit(value, max_health)
if value <= 0 and entities[entity]["alive"]:
entities[entity]["alive"] = false
EventBus.tavern_destroyed.emit()

View File

@@ -1 +0,0 @@
uid://822h8c1pur1a

View File

@@ -1,4 +0,0 @@
- player/
- player szene
- enemy/
- enemy szene

View File

View File

@@ -1,16 +0,0 @@
- core/
- persistenz der daten
- enthält die wahrheit zum zustand der welt
- model
- scenes/
- verwaltet die darstellung der szene
- dürfen berechnen, aber nicht den zustand ändern
- logik wird an systeme gesendet und von diesen empfangen
- view
- systems/
- verarbeiten logik von szenen
- schnittstelle von szenen und core
- dürfen berechnungen durchführen
- controller
- assets/
- objekte für szenen

View File

@@ -1,25 +0,0 @@
ziel
- dorf verteidigen
- hohe welle erreichen
sieg möglichkeiten
- gegner durch angriff besiegen
- gegner durch verteidigung besiegen
core loop
- gegner besiegen
- stärker werden
- welle überleben
meta loop
- viele wellen überleben
- presige sammeln
- pernamente verbesserungen
kern
- dungeons besiegen um erfahrung und ressourcen zu bekommen
- dorf kann durch das bausystem mit ressourcen aus den portalen verbessert werden
typischer ablauf (angriff)
- dorf verteidigen
- portale spawnen
- protale besiegen -> stärker werden
- rotes protal besigen -> welle geschafft
- rotes portal bricht nach timer aus -> greift tarverne an
- tarverne tod -> game over

View File

@@ -1,12 +0,0 @@
- WASD bewegung
- Leertaste springen mit gravitation
- gruppe "player"
- thirs-person camera
- RMB+Hold kamera bewegung, player ausrichtung
- leertaste springen
- listener
- player stirbt
- player wird gespawnt
- events
- fähigkeit verwenden

View File

@@ -1,11 +0,0 @@
- CharacterBody3D
- CollisionShape3D - capsule
-
- init.gd - daten initialisieren
- listener.gd - listener registrieren
- movement.gd - player bewegung
- camera.gd - kamera bewegung
- ability.gd -
- targeting.gd
- role.gd

46
export_presets.cfg Normal file
View File

@@ -0,0 +1,46 @@
[preset.0]
name="Linux"
platform="Linux"
runnable=true
dedicated_server=false
custom_features=""
export_filter="all_resources"
include_filter=""
exclude_filter=""
export_path=""
patches=PackedStringArray()
patch_delta_encoding=false
patch_delta_compression_level_zstd=19
patch_delta_min_reduction=0.1
patch_delta_include_filters="*"
patch_delta_exclude_filters=""
encryption_include_filters=""
encryption_exclude_filters=""
seed=0
encrypt_pck=false
encrypt_directory=false
script_export_mode=2
[preset.0.options]
custom_template/debug=""
custom_template/release=""
debug/export_console_wrapper=1
binary_format/embed_pck=false
texture_format/s3tc_bptc=true
texture_format/etc2_astc=false
shader_baker/enabled=false
binary_format/architecture="x86_64"
ssh_remote_deploy/enabled=false
ssh_remote_deploy/host="user@host_ip"
ssh_remote_deploy/port="22"
ssh_remote_deploy/extra_args_ssh=""
ssh_remote_deploy/extra_args_scp=""
ssh_remote_deploy/run_script="#!/usr/bin/env bash
export DISPLAY=:0
unzip -o -q \"{temp_dir}/{archive_name}\" -d \"{temp_dir}\"
\"{temp_dir}/{exe_name}\" {cmd_args}"
ssh_remote_deploy/cleanup_script="#!/usr/bin/env bash
pkill -x -f \"{temp_dir}/{exe_name} {cmd_args}\"
rm -rf \"{temp_dir}\""

58
plan.md
View File

@@ -22,12 +22,12 @@ scenes/ — Darstellung + Input
healthbar_status.gd — Target-Border + Aggro-Farbwechsel
healthbar_effects.gd — Effekt-Icons auf Healthbar
player/ — Spieler + player_stats
role/ — Rollenwechsel + Ability/AbilitySet-Klassen
damage/ — set.tres + abilities/
tank/ — set.tres + abilities/
healer/ — set.tres + abilities/
role/ — Rollenwechsel + Ability/AbilitySet-Klassen
damage/ — set.tres + abilities/
tank/ — set.tres + abilities/
healer/ — set.tres + abilities/
enemy/ — Gegner + enemy_stats
boss/ — Boss + boss_stats
boss/ — Boss + boss_stats
portal/ — Portal + Gate + portal_stats
dungeon/ — Dungeon + dungeon_manager
hud/ — HUD (4 Skripte: vitals, respawn, abilities, effects)
@@ -49,7 +49,7 @@ autoloads/ — Globaler Zustand
- Taverne
- Player
- Portale (dynamisch)
- Gegner
- Gegner
- HUD
## Architektur
@@ -254,14 +254,14 @@ autoloads/ — Globaler Zustand
- world.tscn — Hauptszene (100x100m)
- Systems (alle Systeme als Child-Nodes, siehe Systeme-Sektion)
- NavigationRegion3D
- Boden (MeshInstance3D, 100x100m PlaneMesh)
- Boden (MeshInstance3D, 100x100m PlaneMesh)
- Kollision (StaticBody3D, WorldBoundaryShape3D)
- Licht (DirectionalLight3D, 45°, Schatten)
- Taverne (StaticBody3D, BoxMesh, Mitte der Karte)
- Gruppe (tavern)
- HitArea (Area3D) — nimmt Invasions-Schaden entgegen
- Healthbar (Sprite3D + SubViewport, groß, nur Health — kein Schild)
- Registriert bei Stats mit TavernStats Resource
- Gruppe (tavern)
- HitArea (Area3D) — nimmt Invasions-Schaden entgegen
- Healthbar (Sprite3D + SubViewport, groß, nur Health — kein Schild)
- Registriert bei Stats mit TavernStats Resource
- Spieler (Instanz von player.tscn)
- HUD (Instanz von hud.tscn)
- PortalSpawner (Node, portal_spawner.gd)
@@ -272,7 +272,7 @@ autoloads/ — Globaler Zustand
- Kollision (CapsuleShape3D, 1.8m x 0.3m)
- Mesh (CapsuleMesh)
- CameraPivot (Node3D, camera.gd)
- Camera3D
- Camera3D
- Movement (Node, movement.gd) — WASD + Springen, liest Werte von Stats
- Ability (Node, ability.gd) — Input-Handler 1/2/3/4, emittiert ability_use_requested
- Role (Node, role/role.gd) — Rollenwechsel ALT+1/2/3, emittiert role_changed (auch bei _ready)
@@ -291,25 +291,25 @@ autoloads/ — Globaler Zustand
- NavigationAgent3D
- Bewegung wird vom AI-System (ai_system.gd) gesteuert — Enemy hat keinen eigenen Movement-Node
- Healthbar (Sprite3D + SubViewport, healthbar.gd) — Health-Anzeige
- HealthbarShield (Node, healthbar_shield.gd) — Shield-Anzeige
- HealthbarStatus (Node, healthbar_status.gd) — Target-Border + Aggro-Farbe
- HealthbarEffects (Node, healthbar_effects.gd) — Effekt-Icons
- HealthbarShield (Node, healthbar_shield.gd) — Shield-Anzeige
- HealthbarStatus (Node, healthbar_status.gd) — Target-Border + Aggro-Farbe
- HealthbarEffects (Node, healthbar_effects.gd) — Effekt-Icons
- init.gd — Registriert bei Stats mit EnemyStats Resource, Detection-Area Signal
- Aggro-Regeln (Werte in AggroConfig Resource):
- Aufbau:
- Schaden = Aggro (1:1), Tank 2x Multiplikator
- Heilung = 0.5x Aggro auf alle Gegner die Heiler kennen
- Aggro-Spread: 50% des Aggro an Gegner im alert_radius (10m)
- Detection-Area (10m): +1 Aggro, Alert an Nachbarn im alert_radius
- Schaden = Aggro (1:1), Tank 2x Multiplikator
- Heilung = 0.5x Aggro auf alle Gegner die Heiler kennen
- Aggro-Spread: 50% des Aggro an Gegner im alert_radius (10m)
- Detection-Area (10m): +1 Aggro, Alert an Nachbarn im alert_radius
- Kampfstatus:
- Spieler in DetectionArea → immer im Kampf (kein Decay)
- Spieler verlässt DetectionArea → 5s Combat-Timeout, dann Decay
- Schaden verursachen setzt Combat-Timer zurück
- Spieler in DetectionArea → immer im Kampf (kein Decay)
- Spieler verlässt DetectionArea → 5s Combat-Timeout, dann Decay
- Schaden verursachen setzt Combat-Timer zurück
- Abbau (nach Combat-Timeout):
- Basis: -aggro_decay/s (default 1.0)
- Exponentieller Decay basierend auf Zeit seit Kampfende (1%·2^sekunden)
- Ohne Aggro: Gegner kehrt zum Portal zurück, regeneriert
- Bei Spieler-Tod → Aggro auf 0
- Basis: -aggro_decay/s (default 1.0)
- Exponentieller Decay basierend auf Zeit seit Kampfende (1%·2^sekunden)
- Ohne Aggro: Gegner kehrt zum Portal zurück, regeneriert
- Bei Spieler-Tod → Aggro auf 0
## Boss (enemy/)
- boss.tscn — wie enemy.tscn aber größer (Mesh lila, 1.5x)
@@ -324,9 +324,9 @@ autoloads/ — Globaler Zustand
- HitArea (Area3D)
- DetectionArea (Area3D, Auto-Targeting bei Betreten)
- Healthbar (Sprite3D + SubViewport, healthbar.gd)
- HealthbarShield (Node, healthbar_shield.gd)
- HealthbarStatus (Node, healthbar_status.gd)
- HealthbarEffects (Node, healthbar_effects.gd)
- HealthbarShield (Node, healthbar_shield.gd)
- HealthbarStatus (Node, healthbar_status.gd)
- HealthbarEffects (Node, healthbar_effects.gd)
- init.gd — Registriert bei Stats mit PortalStats Resource
- Spawnt Gegner bei HP-Schwellen (→ SpawnSystem)

View File

@@ -18,12 +18,10 @@ config/icon="res://icon.svg"
[autoload]
EventBus="*res://autoloads/event_bus.gd"
Net="*res://autoloads/net.gd"
Stats="*res://autoloads/stats.gd"
GameState="*res://autoloads/game_state.gd"
PlayerData="*res://autoloads/player_stats.gd"
EnemyData="*res://autoloads/enemy_stats.gd"
BossData="*res://autoloads/boss_stats.gd"
PortalData="*res://autoloads/portal_stats.gd"
TavernData="*res://autoloads/tavern_stats.gd"
SaveLoad="*res://autoloads/save_load.gd"
[dotnet]
@@ -51,51 +49,109 @@ move_right={
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":68,"key_label":0,"unicode":100,"location":0,"echo":false,"script":null)
]
}
jump={
"deadzone": 0.5,
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":32,"key_label":0,"unicode":32,"location":0,"echo":false,"script":null)
]
}
ability_1={
"deadzone": 0.2,
"deadzone": 0.5,
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":49,"key_label":0,"unicode":49,"location":0,"echo":false,"script":null)
]
}
ability_2={
"deadzone": 0.2,
"deadzone": 0.5,
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":50,"key_label":0,"unicode":50,"location":0,"echo":false,"script":null)
]
}
ability_3={
"deadzone": 0.2,
"deadzone": 0.5,
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":51,"key_label":0,"unicode":51,"location":0,"echo":false,"script":null)
]
}
ability_4={
"deadzone": 0.2,
"deadzone": 0.5,
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":52,"key_label":0,"unicode":52,"location":0,"echo":false,"script":null)
]
}
ability_5={
"deadzone": 0.2,
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":53,"key_label":0,"unicode":53,"location":0,"echo":false,"script":null)
]
}
target_next={
"deadzone": 0.2,
"deadzone": 0.5,
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":4194306,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
]
}
class_tank={
"deadzone": 0.2,
"deadzone": 0.5,
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":true,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":49,"key_label":0,"unicode":49,"location":0,"echo":false,"script":null)
]
}
class_damage={
"deadzone": 0.2,
"deadzone": 0.5,
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":true,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":50,"key_label":0,"unicode":50,"location":0,"echo":false,"script":null)
]
}
class_healer={
"deadzone": 0.2,
"deadzone": 0.5,
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":true,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":51,"key_label":0,"unicode":51,"location":0,"echo":false,"script":null)
]
}
interact={
"deadzone": 0.5,
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":69,"key_label":0,"unicode":101,"location":0,"echo":false,"script":null)
]
}
inventory={
"deadzone": 0.5,
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":73,"key_label":0,"unicode":105,"location":0,"echo":false,"script":null)
]
}
crafting={
"deadzone": 0.5,
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":67,"key_label":0,"unicode":99,"location":0,"echo":false,"script":null)
]
}
build_mode={
"deadzone": 0.5,
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":66,"key_label":0,"unicode":98,"location":0,"echo":false,"script":null)
]
}
chat={
"deadzone": 0.5,
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":89,"key_label":0,"unicode":121,"location":0,"echo":false,"script":null)
]
}
map={
"deadzone": 0.5,
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":77,"key_label":0,"unicode":109,"location":0,"echo":false,"script":null)
]
}
pause={
"deadzone": 0.5,
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":4194305,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
]
}
rotate_build={
"deadzone": 0.5,
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":82,"key_label":0,"unicode":114,"location":0,"echo":false,"script":null)
]
}
click_select={
"deadzone": 0.5,
"events": [Object(InputEventMouseButton,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"button_mask":0,"position":Vector2(0, 0),"global_position":Vector2(0, 0),"factor":1.0,"button_index":1,"canceled":false,"pressed":false,"double_click":false,"script":null)
]
}
camera_drag={
"deadzone": 0.5,
"events": [Object(InputEventMouseButton,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"button_mask":0,"position":Vector2(0, 0),"global_position":Vector2(0, 0),"factor":1.0,"button_index":2,"canceled":false,"pressed":false,"double_click":false,"script":null)
]
}
[layer_names]
3d_physics/layer_1="World"
3d_physics/layer_2="Player"
3d_physics/layer_3="Enemy"
3d_physics/layer_4="Hitbox"
3d_physics/layer_5="Building"
[physics]

View File

@@ -0,0 +1,20 @@
class_name Ability
extends Resource
enum Type { SINGLE, AOE, UTILITY, ULT, PASSIVE }
@export var ability_name: StringName = &""
@export var type: Type = Type.SINGLE
@export var damage: float = 0.0
@export var ability_range: float = 5.0
@export var cooldown: float = 2.0
@export var uses_gcd: bool = true
@export var aoe_radius: float = 0.0
@export var is_heal: bool = false
@export var shield_value: float = 0.0
@export var shield_multiplier: float = 0.0
@export var passive_stat: StringName = &""
@export var passive_value: float = 0.5
@export var passive_radius: float = 50.0
@export var element: int = Element.NONE
@export var icon: Texture2D

View File

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

View File

@@ -1,7 +1,9 @@
extends Resource
class_name AbilitySet
extends Resource
@export var abilities: Array[Ability] = []
@export var aa_damage: float = 10.0
@export var role_name: StringName = &""
@export var aa_damage: float = 5.0
@export var aa_range: float = 10.0
@export var aa_is_heal: bool = false
@export var abilities: Array[Ability] = []
@export var color: Color = Color.WHITE

View File

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

View File

@@ -0,0 +1,13 @@
class_name Building
extends Resource
enum Type { FLOOR, WALL, DOOR, ROOF }
@export var building_id: StringName = &""
@export var display_name: String = ""
@export var type: Type = Type.WALL
@export var size: Vector3 = Vector3(1, 1, 1)
@export var color: Color = Color.GRAY
@export var icon: Texture2D
@export var material_item: Item
@export var material_cost: int = 1

View File

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

View File

@@ -0,0 +1,15 @@
class_name Effect
extends Resource
enum Type { BUFF, DEBUFF, AURA, DOT, HOT }
@export var effect_name: StringName = &""
@export var type: Type = Type.BUFF
@export var stat: StringName = &""
@export var value: float = 0.0
@export var is_multiplier: bool = false
@export var duration: float = -1.0
@export var aura_radius: float = 0.0
@export var tick_interval: float = 0.0
@export var element: int = Element.NONE
@export var icon: Texture2D

View File

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

View File

@@ -0,0 +1,19 @@
class_name Element
extends RefCounted
const NONE: int = 0
const FIRE: int = 1
static func name_of(e: int) -> String:
match e:
FIRE:
return "Fire"
_:
return "None"
static func color_of(e: int) -> Color:
match e:
FIRE:
return Color(1.0, 0.4, 0.1)
_:
return Color.WHITE

View File

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

12
resources/items/item.gd Normal file
View File

@@ -0,0 +1,12 @@
class_name Item
extends Resource
enum Category { ESSENCE, MATERIAL, EQUIPMENT, CONSUMABLE }
@export var item_id: StringName = &""
@export var display_name: String = ""
@export var category: Category = Category.MATERIAL
@export var max_stack: int = 99
@export var icon: Texture2D
@export var equip_slot: StringName = &""
@export var stat_bonus: Dictionary = {}

View File

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

View File

@@ -0,0 +1,9 @@
class_name Recipe
extends Resource
@export var recipe_id: StringName = &""
@export var display_name: String = ""
@export var output_item: Item
@export var output_count: int = 1
@export var inputs: Array[Item] = []
@export var input_counts: Array[int] = []

View File

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

View File

@@ -0,0 +1,10 @@
class_name NpcProfile
extends Resource
@export var npc_id: StringName = &""
@export var display_name: String = ""
@export_multiline var lore: String = ""
@export_multiline var personality: String = ""
@export_multiline var fallback_text: String = "..."
@export var color: Color = Color(0.8, 0.7, 0.5)
@export var greeting: String = "Hallo Reisender."

View File

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

View File

@@ -0,0 +1,8 @@
class_name BaseStats
extends Resource
@export var max_health: float = 100.0
@export var health_regen: float = 0.0
@export var max_shield: float = 0.0
@export var shield_regen: float = 0.0
@export var shield_regen_delay: float = 5.0

View File

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

View File

@@ -1,2 +1,4 @@
extends EnemyStats
class_name BossStats
extends EnemyStats
@export var enrage_time: float = 60.0

View File

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

View File

@@ -0,0 +1,2 @@
class_name BuildingStats
extends BaseStats

View File

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

View File

@@ -0,0 +1,11 @@
class_name EnemyStats
extends BaseStats
@export var speed: float = 3.0
@export var attack_range: float = 2.0
@export var attack_damage: float = 5.0
@export var attack_cooldown: float = 1.5
@export var aggro_radius: float = 12.0
@export var leash_radius: float = 25.0
@export var xp_value: float = 5.0
@export var loot_chance: float = 1.0

View File

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

View File

@@ -0,0 +1,7 @@
class_name GateStats
extends BaseStats
@export var spawn_count: int = 5
@export var spawn_interval: float = 4.0
@export var aggro_radius: float = 15.0
@export var is_red: bool = false

View File

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

View File

@@ -0,0 +1,18 @@
class_name PlayerStats
extends BaseStats
@export var speed: float = 5.0
@export var jump_velocity: float = 4.5
@export var target_range: float = 20.0
@export var combat_timeout: float = 5.0
@export var respawn_time: float = 3.0
@export var gcd_time: float = 0.5
@export var aa_cooldown: float = 0.5
@export var level: int = 1
@export var xp: float = 0.0
@export var xp_to_next: float = 50.0
@export var buff_damage: float = 1.0
@export var buff_heal: float = 1.0
@export var buff_shield: float = 1.0

View File

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

View File

@@ -0,0 +1,5 @@
class_name PortalStats
extends BaseStats
@export var lifetime: float = 0.0
@export var is_red: bool = false

View File

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

View File

@@ -0,0 +1,2 @@
class_name VillageStats
extends BaseStats

View File

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

View File

@@ -1,266 +1,133 @@
[gd_scene format=3]
[gd_scene load_steps=23 format=3 uid="uid://b0dungeon0001"]
[ext_resource type="PackedScene" path="res://scenes/player/player.tscn" id="player"]
[ext_resource type="PackedScene" path="res://scenes/hud/hud.tscn" id="hud"]
[ext_resource type="PackedScene" path="res://scenes/enemy/enemy.tscn" id="enemy"]
[ext_resource type="Resource" path="res://scenes/enemy/boss_stats.tres" id="boss_stats"]
[ext_resource type="Script" path="res://systems/dungeon_system.gd" id="dungeon_system"]
[ext_resource type="Script" path="res://scenes/dungeon/dungeon_manager.gd" id="dungeon_manager"]
[ext_resource type="Script" path="res://systems/audio_system.gd" id="audio_system"]
[ext_resource type="Script" path="res://systems/xp_system.gd" id="xp_system"]
[ext_resource type="PackedScene" path="res://scenes/portal/gate.tscn" id="gate"]
[ext_resource type="Script" path="res://systems/damage_system.gd" id="damage_system"]
[ext_resource type="Script" path="res://systems/health_system.gd" id="health_system"]
[ext_resource type="Script" path="res://systems/heal_system.gd" id="heal_system"]
[ext_resource type="Script" path="res://systems/shield_system.gd" id="shield_system"]
[ext_resource type="Script" path="res://systems/role_system.gd" id="role_system"]
[ext_resource type="Script" path="res://systems/ability_system.gd" id="ability_system"]
[ext_resource type="Script" path="res://systems/attack_system.gd" id="attack_system"]
[ext_resource type="Script" path="res://systems/cooldown_system.gd" id="cooldown_system"]
[ext_resource type="Script" path="res://systems/targeting_system.gd" id="targeting_system"]
[ext_resource type="Script" path="res://systems/aggro/aggro_system.gd" id="aggro_system"]
[ext_resource type="Script" path="res://systems/aggro/aggro_tracker.gd" id="aggro_tracker"]
[ext_resource type="Script" path="res://systems/aggro/aggro_decay.gd" id="aggro_decay"]
[ext_resource type="Script" path="res://systems/aggro/aggro_events.gd" id="aggro_events"]
[ext_resource type="Script" path="res://systems/ai_system.gd" id="ai_system"]
[ext_resource type="Script" path="res://systems/respawn_system.gd" id="respawn_system"]
[ext_resource type="Script" path="res://systems/spawn_system.gd" id="spawn_system"]
[ext_resource type="Script" path="res://systems/aura_system.gd" id="aura_system"]
[ext_resource type="Script" path="res://systems/buff_system.gd" id="buff_system"]
[ext_resource type="Script" path="res://systems/debuff_system.gd" id="debuff_system"]
[ext_resource type="Script" path="res://systems/element_system.gd" id="element_system"]
[ext_resource type="Script" path="res://systems/hud_system.gd" id="hud_system"]
[ext_resource type="Script" path="res://systems/nameplate_system.gd" id="nameplate_system"]
[ext_resource type="Resource" uid="uid://cgxtn7dfs40bh" path="res://scenes/player/role/tank/set.tres" id="tank_set"]
[ext_resource type="Resource" uid="uid://beodknb6i1pm4" path="res://scenes/player/role/damage/set.tres" id="damage_set"]
[ext_resource type="Resource" uid="uid://kcwuhnqy34mj" path="res://scenes/player/role/healer/set.tres" id="healer_set"]
[ext_resource type="Script" path="res://scenes/dungeon/dungeon_manager.gd" id="1"]
[ext_resource type="PackedScene" uid="uid://b0player00001" path="res://scenes/entities/player/player.tscn" id="2"]
[ext_resource type="Script" path="res://systems/health_system.gd" id="4"]
[ext_resource type="Script" path="res://systems/shield_system.gd" id="5"]
[ext_resource type="Script" path="res://systems/respawn_system.gd" id="6"]
[ext_resource type="Script" path="res://systems/cooldown_system.gd" id="7"]
[ext_resource type="Script" path="res://systems/role_system.gd" id="8"]
[ext_resource type="Script" path="res://systems/effect_system.gd" id="9"]
[ext_resource type="Script" path="res://systems/element_system.gd" id="10"]
[ext_resource type="Script" path="res://systems/aggro_system.gd" id="11"]
[ext_resource type="Script" path="res://systems/combat/ability_system.gd" id="12"]
[ext_resource type="Script" path="res://systems/combat/auto_attack_system.gd" id="13"]
[ext_resource type="Script" path="res://systems/spawn_system.gd" id="14"]
[ext_resource type="Script" path="res://systems/xp_system.gd" id="17"]
[ext_resource type="Script" path="res://systems/loot_system.gd" id="18"]
[ext_resource type="Script" path="res://systems/inventory_system.gd" id="19"]
[ext_resource type="Script" path="res://systems/chat_system.gd" id="24"]
[ext_resource type="Script" path="res://systems/map_system.gd" id="25"]
[ext_resource type="PackedScene" uid="uid://b0hud00001" path="res://scenes/hud/hud.tscn" id="26"]
[ext_resource type="Script" path="res://systems/audio_system.gd" id="27"]
[sub_resource type="NavigationMesh" id="NavigationMesh_1"]
vertices = PackedVector3Array(-7.0, 0.5, -7.0, -7.0, 0.5, 87.0, 7.0, 0.5, 87.0, 7.0, 0.5, -7.0)
polygons = [PackedInt32Array(3, 2, 0), PackedInt32Array(0, 2, 1)]
[sub_resource type="ProceduralSkyMaterial" id="ProceduralSkyMaterial_1"]
sky_top_color = Color(0.1, 0.05, 0.1, 1)
sky_horizon_color = Color(0.15, 0.05, 0.05, 1)
ground_horizon_color = Color(0.1, 0.05, 0.05, 1)
ground_bottom_color = Color(0.0, 0.0, 0.0, 1)
[sub_resource type="StandardMaterial3D" id="StandardMaterial3D_floor"]
albedo_color = Color(0.2, 0.18, 0.15, 1)
[sub_resource type="Sky" id="Sky_1"]
sky_material = SubResource("ProceduralSkyMaterial_1")
[sub_resource type="PlaneMesh" id="PlaneMesh_1"]
material = SubResource("StandardMaterial3D_floor")
size = Vector2(15, 90)
[sub_resource type="WorldBoundaryShape3D" id="WorldBoundaryShape3D_1"]
[sub_resource type="StandardMaterial3D" id="StandardMaterial3D_wall"]
albedo_color = Color(0.25, 0.22, 0.2, 1)
[sub_resource type="BoxMesh" id="BoxMesh_north_south"]
material = SubResource("StandardMaterial3D_wall")
size = Vector3(15, 3, 0.5)
[sub_resource type="BoxShape3D" id="BoxShape3D_north_south"]
size = Vector3(15, 3, 0.5)
[sub_resource type="BoxMesh" id="BoxMesh_east_west"]
material = SubResource("StandardMaterial3D_wall")
size = Vector3(0.5, 3, 90)
[sub_resource type="BoxShape3D" id="BoxShape3D_east_west"]
size = Vector3(0.5, 3, 90)
[sub_resource type="Environment" id="Environment_1"]
background_mode = 2
sky = SubResource("Sky_1")
ambient_light_source = 3
ambient_light_color = Color(0.2, 0.18, 0.2, 1)
ambient_light_energy = 0.4
[node name="Dungeon" type="Node3D"]
script = ExtResource("1")
[node name="WorldEnvironment" type="WorldEnvironment" parent="."]
environment = SubResource("Environment_1")
[node name="DirectionalLight3D" type="DirectionalLight3D" parent="."]
transform = Transform3D(0.866, -0.354, 0.354, 0, 0.707, 0.707, -0.5, -0.612, 0.612, 0, 30, 0)
light_energy = 0.7
[node name="DungeonGeometry" type="Node3D" parent="."]
[node name="Systems" type="Node" parent="."]
[node name="HealthSystem" type="Node" parent="Systems"]
script = ExtResource("health_system")
[node name="DamageSystem" type="Node" parent="Systems"]
script = ExtResource("damage_system")
[node name="HealSystem" type="Node" parent="Systems"]
script = ExtResource("heal_system")
script = ExtResource("4")
[node name="ShieldSystem" type="Node" parent="Systems"]
script = ExtResource("shield_system")
[node name="RoleSystem" type="Node" parent="Systems"]
script = ExtResource("role_system")
tank_set = ExtResource("tank_set")
damage_set = ExtResource("damage_set")
healer_set = ExtResource("healer_set")
[node name="AbilitySystem" type="Node" parent="Systems"]
script = ExtResource("ability_system")
[node name="AttackSystem" type="Node" parent="Systems"]
script = ExtResource("attack_system")
[node name="CooldownSystem" type="Node" parent="Systems"]
script = ExtResource("cooldown_system")
[node name="TargetingSystem" type="Node" parent="Systems"]
script = ExtResource("targeting_system")
[node name="AggroSystem" type="Node" parent="Systems"]
script = ExtResource("aggro_system")
[node name="AggroTracker" type="Node" parent="Systems/AggroSystem"]
script = ExtResource("aggro_tracker")
[node name="AggroDecay" type="Node" parent="Systems/AggroSystem"]
script = ExtResource("aggro_decay")
[node name="AggroEvents" type="Node" parent="Systems/AggroSystem"]
script = ExtResource("aggro_events")
[node name="AISystem" type="Node" parent="Systems"]
script = ExtResource("ai_system")
script = ExtResource("5")
[node name="RespawnSystem" type="Node" parent="Systems"]
script = ExtResource("respawn_system")
script = ExtResource("6")
[node name="SpawnSystem" type="Node" parent="Systems"]
script = ExtResource("spawn_system")
[node name="CooldownSystem" type="Node" parent="Systems"]
script = ExtResource("7")
[node name="AuraSystem" type="Node" parent="Systems"]
script = ExtResource("aura_system")
[node name="RoleSystem" type="Node" parent="Systems"]
script = ExtResource("8")
[node name="BuffSystem" type="Node" parent="Systems"]
script = ExtResource("buff_system")
[node name="DebuffSystem" type="Node" parent="Systems"]
script = ExtResource("debuff_system")
[node name="EffectSystem" type="Node" parent="Systems"]
script = ExtResource("9")
[node name="ElementSystem" type="Node" parent="Systems"]
script = ExtResource("element_system")
script = ExtResource("10")
[node name="HudSystem" type="Node" parent="Systems"]
script = ExtResource("hud_system")
[node name="AggroSystem" type="Node" parent="Systems"]
script = ExtResource("11")
[node name="NameplateSystem" type="Node" parent="Systems"]
script = ExtResource("nameplate_system")
[node name="AbilitySystem" type="Node" parent="Systems"]
script = ExtResource("12")
[node name="NavigationRegion3D" type="NavigationRegion3D" parent="."]
navigation_mesh = SubResource("NavigationMesh_1")
[node name="AutoAttackSystem" type="Node" parent="Systems"]
script = ExtResource("13")
[node name="Boden" type="MeshInstance3D" parent="NavigationRegion3D"]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 40)
mesh = SubResource("PlaneMesh_1")
[node name="SpawnSystem" type="Node" parent="Systems"]
script = ExtResource("14")
[node name="BodenCollision" type="StaticBody3D" parent="."]
[node name="CollisionShape3D" type="CollisionShape3D" parent="BodenCollision"]
shape = SubResource("WorldBoundaryShape3D_1")
[node name="WallSouth" type="StaticBody3D" parent="."]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 1.5, -5.25)
[node name="Mesh" type="MeshInstance3D" parent="WallSouth"]
mesh = SubResource("BoxMesh_north_south")
[node name="CollisionShape3D" type="CollisionShape3D" parent="WallSouth"]
shape = SubResource("BoxShape3D_north_south")
[node name="WallNorth" type="StaticBody3D" parent="."]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 1.5, 85.25)
[node name="Mesh" type="MeshInstance3D" parent="WallNorth"]
mesh = SubResource("BoxMesh_north_south")
[node name="CollisionShape3D" type="CollisionShape3D" parent="WallNorth"]
shape = SubResource("BoxShape3D_north_south")
[node name="WallEast" type="StaticBody3D" parent="."]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 7.75, 1.5, 40)
[node name="Mesh" type="MeshInstance3D" parent="WallEast"]
mesh = SubResource("BoxMesh_east_west")
[node name="CollisionShape3D" type="CollisionShape3D" parent="WallEast"]
shape = SubResource("BoxShape3D_east_west")
[node name="WallWest" type="StaticBody3D" parent="."]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -7.75, 1.5, 40)
[node name="Mesh" type="MeshInstance3D" parent="WallWest"]
mesh = SubResource("BoxMesh_east_west")
[node name="CollisionShape3D" type="CollisionShape3D" parent="WallWest"]
shape = SubResource("BoxShape3D_east_west")
[node name="DirectionalLight3D" type="DirectionalLight3D" parent="."]
transform = Transform3D(1, 0, 0, 0, 0.707, 0.707, 0, -0.707, 0.707, 0, 10, 40)
light_energy = 0.6
shadow_enabled = true
[node name="Player" parent="." instance=ExtResource("player")]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 1, -3)
[node name="HUD" parent="." instance=ExtResource("hud")]
[node name="Enemy1a" parent="." instance=ExtResource("enemy")]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -3, 0, 15)
[node name="Enemy1b" parent="." instance=ExtResource("enemy")]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -1, 0, 15)
[node name="Enemy1c" parent="." instance=ExtResource("enemy")]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 1, 0, 15)
[node name="Enemy1d" parent="." instance=ExtResource("enemy")]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 3, 0, 15)
[node name="Enemy2a" parent="." instance=ExtResource("enemy")]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -3, 0, 30)
[node name="Enemy2b" parent="." instance=ExtResource("enemy")]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -1, 0, 30)
[node name="Enemy2c" parent="." instance=ExtResource("enemy")]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 1, 0, 30)
[node name="Enemy2d" parent="." instance=ExtResource("enemy")]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 3, 0, 30)
[node name="Enemy3a" parent="." instance=ExtResource("enemy")]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -3, 0, 45)
[node name="Enemy3b" parent="." instance=ExtResource("enemy")]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -1, 0, 45)
[node name="Enemy3c" parent="." instance=ExtResource("enemy")]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 1, 0, 45)
[node name="Enemy3d" parent="." instance=ExtResource("enemy")]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 3, 0, 45)
[node name="Enemy4a" parent="." instance=ExtResource("enemy")]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -3, 0, 60)
[node name="Enemy4b" parent="." instance=ExtResource("enemy")]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -1, 0, 60)
[node name="Enemy4c" parent="." instance=ExtResource("enemy")]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 1, 0, 60)
[node name="Enemy4d" parent="." instance=ExtResource("enemy")]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 3, 0, 60)
[node name="Boss" parent="." groups=["boss"] instance=ExtResource("enemy")]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 75)
stats = ExtResource("boss_stats")
[node name="ExitGate" parent="." instance=ExtResource("gate")]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -6, 0, -4)
target_scene = "res://scenes/world/world.tscn"
is_exit = true
[node name="DungeonSystem" type="Node" parent="Systems"]
script = ExtResource("dungeon_system")
[node name="AudioSystem" type="Node" parent="Systems"]
script = ExtResource("audio_system")
[node name="LootSystem" type="Node" parent="Systems"]
script = ExtResource("18")
[node name="XpSystem" type="Node" parent="Systems"]
script = ExtResource("xp_system")
script = ExtResource("17")
[node name="DungeonManager" type="Node" parent="."]
script = ExtResource("dungeon_manager")
[node name="InventorySystem" type="Node" parent="Systems"]
script = ExtResource("19")
[node name="ChatSystem" type="Node" parent="Systems"]
script = ExtResource("24")
[node name="MapSystem" type="Node" parent="Systems"]
script = ExtResource("25")
[node name="AudioSystem" type="Node" parent="Systems"]
script = ExtResource("27")
[node name="EntityRoot" type="Node3D" parent="."]
[node name="Players" type="Node3D" parent="EntityRoot"]
[node name="Enemies" type="Node3D" parent="EntityRoot"]
[node name="Loot" type="Node3D" parent="EntityRoot"]
[node name="Portals" type="Node3D" parent="EntityRoot"]
[node name="Buildings" type="Node3D" parent="EntityRoot"]
[node name="Gates" type="Node3D" parent="EntityRoot"]
[node name="Npcs" type="Node3D" parent="EntityRoot"]
[node name="PlayerSpawner" type="MultiplayerSpawner" parent="."]
_spawnable_scenes = PackedStringArray("res://scenes/entities/player/player.tscn")
spawn_path = NodePath("../EntityRoot/Players")
[node name="EnemySpawner" type="MultiplayerSpawner" parent="."]
_spawnable_scenes = PackedStringArray("res://scenes/entities/enemy/enemy.tscn")
spawn_path = NodePath("../EntityRoot/Enemies")
[node name="LootSpawner" type="MultiplayerSpawner" parent="."]
_spawnable_scenes = PackedStringArray("res://scenes/entities/loot/loot_drop.tscn")
spawn_path = NodePath("../EntityRoot/Loot")
[node name="HUD" parent="." instance=ExtResource("26")]

View File

@@ -0,0 +1,93 @@
extends Node
const ROOM_HEIGHT: float = 4.0
const WALL_THICKNESS: float = 0.4
const CORRIDOR_WIDTH: float = 4.0
var rng: RandomNumberGenerator
var rooms: Array = []
func generate(parent: Node3D, seed: int, scale_difficulty: float = 1.0) -> Dictionary:
rng = RandomNumberGenerator.new()
rng.seed = seed
rooms.clear()
var room_count: int = rng.randi_range(5, 8)
var pos := Vector3(0, 0, 0)
var dir := Vector3(0, 0, -1)
for i in range(room_count):
var w: float = rng.randf_range(8.0, 14.0)
var d: float = rng.randf_range(8.0, 14.0)
rooms.append({"pos": pos, "size": Vector3(w, ROOM_HEIGHT, d)})
if i == room_count - 1:
break
var corridor_len: float = rng.randf_range(4.0, 8.0)
var step: Vector3 = dir * (max(w, d) * 0.5 + corridor_len + 4.0)
pos += step
if rng.randf() < 0.5:
var rotate_left: bool = rng.randf() < 0.5
dir = dir.rotated(Vector3.UP, PI * 0.5 * (1 if rotate_left else -1))
_build_geometry(parent)
return {"rooms": rooms, "spawn": rooms[0].pos + Vector3(0, 1, 0), "boss": rooms[-1].pos}
func _build_geometry(parent: Node3D) -> void:
for r in rooms:
_build_room(parent, r.pos, r.size)
for i in range(rooms.size() - 1):
_build_corridor(parent, rooms[i].pos, rooms[i + 1].pos)
func _build_room(parent: Node3D, center: Vector3, size: Vector3) -> void:
_add_floor(parent, center, Vector2(size.x, size.z))
var hw: float = size.x * 0.5
var hd: float = size.z * 0.5
_add_wall(parent, center + Vector3(0, ROOM_HEIGHT * 0.5, -hd), Vector3(size.x, ROOM_HEIGHT, WALL_THICKNESS))
_add_wall(parent, center + Vector3(0, ROOM_HEIGHT * 0.5, hd), Vector3(size.x, ROOM_HEIGHT, WALL_THICKNESS))
_add_wall(parent, center + Vector3(-hw, ROOM_HEIGHT * 0.5, 0), Vector3(WALL_THICKNESS, ROOM_HEIGHT, size.z))
_add_wall(parent, center + Vector3(hw, ROOM_HEIGHT * 0.5, 0), Vector3(WALL_THICKNESS, ROOM_HEIGHT, size.z))
func _build_corridor(parent: Node3D, from: Vector3, to: Vector3) -> void:
var mid: Vector3 = (from + to) * 0.5
var d: float = from.distance_to(to)
var dir: Vector3 = (to - from).normalized()
_add_floor(parent, Vector3(mid.x, 0, mid.z), Vector2(d, CORRIDOR_WIDTH) if abs(dir.x) > abs(dir.z) else Vector2(CORRIDOR_WIDTH, d))
func _add_floor(parent: Node3D, center: Vector3, size: Vector2) -> void:
var body := StaticBody3D.new()
body.collision_layer = 1
body.collision_mask = 0
var mesh := MeshInstance3D.new()
var box := BoxMesh.new()
box.size = Vector3(size.x, 0.4, size.y)
mesh.mesh = box
var mat := StandardMaterial3D.new()
mat.albedo_color = Color(0.25, 0.22, 0.2)
mesh.material_override = mat
mesh.position = Vector3(0, -0.2, 0)
var col := CollisionShape3D.new()
var shape := BoxShape3D.new()
shape.size = Vector3(size.x, 0.4, size.y)
col.shape = shape
col.position = Vector3(0, -0.2, 0)
body.add_child(mesh)
body.add_child(col)
body.position = center
parent.add_child(body)
func _add_wall(parent: Node3D, center: Vector3, size: Vector3) -> void:
var body := StaticBody3D.new()
body.collision_layer = 1
body.collision_mask = 0
var mesh := MeshInstance3D.new()
var box := BoxMesh.new()
box.size = size
mesh.mesh = box
var mat := StandardMaterial3D.new()
mat.albedo_color = Color(0.35, 0.32, 0.28)
mesh.material_override = mat
var col := CollisionShape3D.new()
var shape := BoxShape3D.new()
shape.size = size
col.shape = shape
body.add_child(mesh)
body.add_child(col)
body.position = center
parent.add_child(body)

View File

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

View File

@@ -1,16 +1,82 @@
extends Node
extends Node3D
const PLAYER_SCENE: PackedScene = preload("res://scenes/entities/player/player.tscn")
const ENEMY_SCENE: PackedScene = preload("res://scenes/entities/enemy/enemy.tscn")
const GENERATOR: GDScript = preload("res://scenes/dungeon/dungeon_generator.gd")
@onready var players_root: Node3D = $EntityRoot/Players
@onready var dungeon_root: Node3D = $DungeonGeometry
@onready var spawn_system: Node = $Systems/SpawnSystem
var generator: Node
var data: Dictionary
func _ready() -> void:
call_deferred("_scale_dungeon")
add_to_group("dungeon")
generator = GENERATOR.new()
add_child(generator)
data = generator.generate(dungeon_root, GameState.dungeon_seed, GameState.current_wave * (5.0 if GameState.dungeon_red else 1.0))
EventBus.boss_defeated.connect(_on_boss_defeated)
Net.world_ready.connect(_on_world_ready)
Net.peer_world_loaded.connect(_on_peer_world_loaded)
if Net.is_host():
multiplayer.peer_connected.connect(_on_peer_connected)
multiplayer.peer_disconnected.connect(_on_peer_disconnected)
Net.reset_world_ready()
Net.mark_world_loaded()
func _scale_dungeon() -> void:
var variant_multiplier: float = 10.0 if GameState.last_dungeon_variant == 1 else 1.0
var total_scale: float = PlayerData.level_scale * variant_multiplier
var parent: Node = get_parent()
for child in parent.get_children():
if not child.is_in_group("enemies"):
continue
if child.is_in_group("boss"):
BossData.apply_scale(child, total_scale)
else:
EnemyData.apply_scale(child, total_scale)
func _exit_tree() -> void:
if Net.world_ready.is_connected(_on_world_ready):
Net.world_ready.disconnect(_on_world_ready)
if Net.peer_world_loaded.is_connected(_on_peer_world_loaded):
Net.peer_world_loaded.disconnect(_on_peer_world_loaded)
func _on_world_ready() -> void:
if not Net.is_host():
return
_spawn_player(1)
for id in multiplayer.get_peers():
_spawn_player(id)
_populate_dungeon()
func _populate_dungeon() -> void:
var difficulty: float = GameState.current_wave * (3.0 if GameState.dungeon_red else 1.0)
for i in range(data.rooms.size() - 1):
var room: Dictionary = data.rooms[i]
var n: int = 2 + (1 if GameState.dungeon_red else 0)
for j in range(n):
var off := Vector3(randf_range(-room.size.x * 0.3, room.size.x * 0.3), 0.5, randf_range(-room.size.z * 0.3, room.size.z * 0.3))
spawn_system.spawn_enemy_at(room.pos + off, GameState.dungeon_red, difficulty * 0.5)
spawn_system.spawn_boss_at(data.boss + Vector3(0, 0.5, 0), difficulty)
func _spawn_player(peer_id: int) -> void:
if players_root.get_node_or_null(str(peer_id)) != null:
return
var p: CharacterBody3D = PLAYER_SCENE.instantiate()
p.name = str(peer_id)
players_root.add_child(p, true)
p.global_position = data.spawn + Vector3(randf_range(-1, 1), 0, randf_range(-1, 1))
func _on_peer_connected(id: int) -> void:
Net.tell_peer_to_load_scene(id, GameState.SCENE_DUNGEON)
func _on_peer_world_loaded(peer_id: int) -> void:
if not Net.is_host():
return
_spawn_player(peer_id)
func _on_peer_disconnected(id: int) -> void:
var node := players_root.get_node_or_null(str(id))
if node:
node.queue_free()
func _on_boss_defeated(_b: Node) -> void:
if Net.is_host():
var t := get_tree().create_timer(2.0)
t.timeout.connect(func():
GameState.dungeon_seed = 0
_return.rpc())
@rpc("authority", "reliable", "call_local")
func _return() -> void:
GameState.change_scene(GameState.SCENE_WORLD)

View File

@@ -1 +1 @@
uid://bfkxrflfn5qx4
uid://civwsilci7nlx

View File

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

View File

@@ -1,20 +0,0 @@
[gd_resource type="Resource" script_class="BossStats" load_steps=2 format=3]
[ext_resource type="Script" path="res://scenes/enemy/boss_stats.gd" id="1"]
[resource]
script = ExtResource("1")
max_health = 500.0
health_regen = 0.0
max_shield = 100.0
shield_regen_delay = 5.0
shield_regen_time = 8.0
speed = 3.0
attack_range = 2.0
attack_cooldown = 1.5
attack_damage = 5.0
regen_fast = 0.1
regen_slow = 0.01
aggro_decay = 1.0
portal_radius = 10.0
alert_radius = 10.0

View File

@@ -1,11 +0,0 @@
extends Node
@onready var entity: CharacterBody3D = get_parent()
func _on_detection_area_body_entered(body: Node3D) -> void:
if body is CharacterBody3D and body.name == "Player":
EventBus.enemy_detected.emit(entity, body)
func _on_detection_area_body_exited(body: Node3D) -> void:
if body is CharacterBody3D and body.name == "Player":
EventBus.enemy_lost.emit(entity, body)

View File

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

View File

@@ -1,96 +0,0 @@
[gd_scene format=3 uid="uid://db8pa55ev4l4a"]
[ext_resource type="Script" uid="uid://vy6hyqok0p8b" path="res://scenes/enemy/init.gd" id="1"]
[ext_resource type="Script" uid="uid://b07aajhufqvb3" path="res://scenes/enemy/detection.gd" id="2"]
[ext_resource type="Resource" uid="uid://cj1shmjwf0xeo" path="res://scenes/enemy/enemy_stats.tres" id="8"]
[ext_resource type="PackedScene" path="res://assets/models/characters/Skeleton_Minion.glb" id="9"]
[sub_resource type="CapsuleShape3D" id="CapsuleShape3D_1"]
radius = 0.4
height = 1.5
[sub_resource type="CapsuleShape3D" id="CapsuleShape3D_2"]
radius = 0.4
height = 1.5
[sub_resource type="SphereShape3D" id="SphereShape3D_1"]
radius = 10.0
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_health_bg"]
bg_color = Color(0.3, 0.1, 0.1, 1)
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_health_fill"]
bg_color = Color(0.2, 0.8, 0.2, 1)
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_shield_bg"]
bg_color = Color(0.1, 0.1, 0.3, 1)
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_shield_fill"]
bg_color = Color(0.2, 0.5, 0.9, 1)
[node name="Enemy" type="CharacterBody3D" unique_id=1724620529]
script = ExtResource("1")
stats = ExtResource("8")
[node name="CollisionShape3D" type="CollisionShape3D" parent="." unique_id=1011138038]
shape = SubResource("CapsuleShape3D_1")
[node name="Mesh" type="Node3D" parent="." unique_id=1598094615]
[node name="Model" parent="Mesh" instance=ExtResource("9")]
transform = Transform3D(-1, 0, 0, 0, 1, 0, 0, 0, -1, 0, -0.75, 0)
[node name="HitArea" type="Area3D" parent="." unique_id=893463784]
collision_layer = 4
collision_mask = 0
[node name="CollisionShape3D" type="CollisionShape3D" parent="HitArea" unique_id=984781962]
shape = SubResource("CapsuleShape3D_2")
[node name="NavigationAgent3D" type="NavigationAgent3D" parent="." unique_id=440641945]
[node name="Detection" type="Node" parent="." unique_id=534240144]
script = ExtResource("2")
[node name="DetectionArea" type="Area3D" parent="." unique_id=1955178598]
collision_layer = 0
[node name="CollisionShape3D" type="CollisionShape3D" parent="DetectionArea" unique_id=557461347]
shape = SubResource("SphereShape3D_1")
[node name="Healthbar" type="Sprite3D" parent="." unique_id=1008728031]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 2, 0)
billboard = 1
[node name="SubViewport" type="SubViewport" parent="Healthbar" unique_id=1219060718]
transparent_bg = true
size = Vector2i(104, 29)
[node name="Border" type="ColorRect" parent="Healthbar/SubViewport" unique_id=848146848]
offset_right = 104.0
offset_bottom = 29.0
color = Color(1, 0.9, 0.2, 1)
[node name="HealthBar" type="ProgressBar" parent="Healthbar/SubViewport" unique_id=1206434403]
offset_left = 2.0
offset_top = 2.0
offset_right = 102.0
offset_bottom = 12.0
theme_override_styles/background = SubResource("StyleBoxFlat_health_bg")
theme_override_styles/fill = SubResource("StyleBoxFlat_health_fill")
value = 100.0
show_percentage = false
[node name="ShieldBar" type="ProgressBar" parent="Healthbar/SubViewport" unique_id=1891108036]
offset_left = 2.0
offset_top = 15.0
offset_right = 102.0
offset_bottom = 27.0
theme_override_styles/background = SubResource("StyleBoxFlat_shield_bg")
theme_override_styles/fill = SubResource("StyleBoxFlat_shield_fill")
max_value = 50.0
value = 50.0
show_percentage = false
[connection signal="body_entered" from="DetectionArea" to="Detection" method="_on_detection_area_body_entered"]
[connection signal="body_exited" from="DetectionArea" to="Detection" method="_on_detection_area_body_exited"]

View File

@@ -1,12 +0,0 @@
extends BaseStats
class_name EnemyStats
@export var speed := 3.0
@export var attack_range := 2.0
@export var attack_cooldown := 1.5
@export var attack_damage := 5.0
@export var regen_fast := 0.10
@export var regen_slow := 0.01
@export var aggro_decay := 1.0
@export var portal_radius := 10.0
@export var alert_radius := 10.0

View File

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

View File

@@ -1,7 +0,0 @@
[gd_resource type="Resource" script_class="EnemyStats" format=3 uid="uid://cj1shmjwf0xeo"]
[ext_resource type="Script" uid="uid://bh2uuuvl30y0x" path="res://scenes/enemy/enemy_stats.gd" id="1"]
[resource]
script = ExtResource("1")
max_shield = 50.0

View File

@@ -1,101 +0,0 @@
extends CharacterBody3D
const SKELETON_WARRIOR: PackedScene = preload("res://assets/models/characters/Skeleton_Warrior.glb")
const SKELETON_MAGE: PackedScene = preload("res://assets/models/characters/Skeleton_Mage.glb")
@export var stats: EnemyStats
var gravity: float = ProjectSettings.get_setting("physics/3d/default_gravity")
var spawn_scale: float = 1.0
var anim_player: AnimationPlayer = null
var current_anim: String = ""
var attack_lock_until: float = 0.0
var is_dying: bool = false
func _ready() -> void:
add_to_group("enemies")
if is_in_group("boss"):
BossData.register(self, stats, spawn_scale)
BossData.set_stat(self, "spawn_position", global_position)
_swap_model(SKELETON_MAGE, 1.3)
else:
EnemyData.register(self, stats, spawn_scale)
EnemyData.set_stat(self, "spawn_position", global_position)
EventBus.entity_died.connect(_on_entity_died)
EventBus.attack_executed.connect(_on_attack_executed)
call_deferred("_check_variant")
call_deferred("_init_anim")
func _init_anim() -> void:
anim_player = get_node_or_null("Mesh/Model/AnimationPlayer")
_play_anim("Idle")
func _check_variant() -> void:
if is_in_group("boss"):
return
if is_in_group("red_enemies") or is_in_group("invasion"):
_swap_model(SKELETON_WARRIOR, 1.0)
anim_player = get_node_or_null("Mesh/Model/AnimationPlayer")
_play_anim("Idle")
func _swap_model(new_scene: PackedScene, scale_factor: float = 1.0) -> void:
var mesh: Node3D = get_node_or_null("Mesh")
if not mesh:
return
var old: Node = mesh.get_node_or_null("Model")
if old:
old.queue_free()
var new_model: Node3D = new_scene.instantiate()
new_model.name = "Model"
new_model.scale = Vector3(scale_factor, scale_factor, scale_factor)
new_model.position = Vector3(0, -0.75, 0)
new_model.rotation.y = PI
mesh.add_child(new_model)
func _exit_tree() -> void:
if is_in_group("boss"):
BossData.deregister(self)
else:
EnemyData.deregister(self)
func _on_entity_died(entity: Node) -> void:
if entity != self:
return
is_dying = true
_play_anim("Death_A", false)
get_tree().create_timer(1.0).timeout.connect(queue_free)
func _on_attack_executed(attacker: Node, _pos: Vector3, _dir: Vector3, _damage: float) -> void:
if attacker != self:
return
_play_anim("1H_Melee_Attack_Chop", false)
attack_lock_until = Time.get_ticks_msec() / 1000.0 + 0.5
func _process(_delta: float) -> void:
if is_dying:
return
var now: float = Time.get_ticks_msec() / 1000.0
if now < attack_lock_until:
return
if velocity.length() > 0.1:
_play_anim("Running_A")
else:
_play_anim("Idle")
func _play_anim(anim_name: String, loop: bool = true) -> void:
if not anim_player:
return
if current_anim == anim_name:
return
if not anim_player.has_animation(anim_name):
return
var anim: Animation = anim_player.get_animation(anim_name)
if anim:
anim.loop_mode = Animation.LOOP_LINEAR if loop else Animation.LOOP_NONE
anim_player.play(anim_name)
current_anim = anim_name
func _physics_process(delta: float) -> void:
if not is_on_floor():
velocity.y -= gravity * delta
move_and_slide()

View File

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

View File

@@ -0,0 +1,36 @@
extends StaticBody3D
@export var building_id: StringName = &""
@onready var mesh: MeshInstance3D = $Mesh
@onready var collision: CollisionShape3D = $Collision
func _enter_tree() -> void:
set_multiplayer_authority(1)
func _ready() -> void:
add_to_group("buildings")
apply_building(building_id)
func apply_building(id: StringName) -> void:
building_id = id
var data: Building = _load_building(id)
if data == null:
return
var box: BoxMesh = mesh.mesh as BoxMesh
if box:
box.size = data.size
var shape: BoxShape3D = collision.shape as BoxShape3D
if shape:
shape.size = data.size
collision.position = Vector3(0.0, data.size.y * 0.5, 0.0)
mesh.position = Vector3(0.0, data.size.y * 0.5, 0.0)
var mat: StandardMaterial3D = StandardMaterial3D.new()
mat.albedo_color = data.color
mesh.material_override = mat
static func _load_building(id: StringName) -> Building:
var path := "res://resources/buildings/%s.tres" % str(id)
if ResourceLoader.exists(path):
return load(path) as Building
return null

View File

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

View File

@@ -0,0 +1,22 @@
[gd_scene load_steps=4 format=3 uid="uid://b0building001"]
[ext_resource type="Script" path="res://scenes/entities/building/building.gd" id="1"]
[sub_resource type="BoxShape3D" id="BoxShape3D_1"]
size = Vector3(1, 1, 1)
[sub_resource type="BoxMesh" id="BoxMesh_1"]
size = Vector3(1, 1, 1)
[node name="Building" type="StaticBody3D"]
collision_layer = 16
collision_mask = 0
script = ExtResource("1")
[node name="Collision" type="CollisionShape3D" parent="."]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0.5, 0)
shape = SubResource("BoxShape3D_1")
[node name="Mesh" type="MeshInstance3D" parent="."]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0.5, 0)
mesh = SubResource("BoxMesh_1")

View File

@@ -0,0 +1,170 @@
extends CharacterBody3D
const GRAVITY: float = 18.0
@export var stats_resource: EnemyStats
@export var is_boss: bool = false
@onready var nav: NavigationAgent3D = $NavAgent
@onready var mesh_holder: Node3D = $MeshHolder
@onready var collision: CollisionShape3D = $Collision
@onready var detection: Area3D = $DetectionArea
@onready var sync: MultiplayerSynchronizer = $Synchronizer
@onready var healthbar: MeshInstance3D = $Healthbar
@onready var name_label: Label3D = $NameLabel
var origin: Vector3 = Vector3.ZERO
var attack_cd: float = 0.0
var dead: bool = false
var invasion_target: Node = null
@export var sync_position: Vector3 = Vector3.ZERO
@export var sync_yaw: float = 0.0
func _enter_tree() -> void:
set_multiplayer_authority(1)
func _ready() -> void:
if is_boss:
add_to_group("boss")
add_to_group("enemies")
if stats_resource == null:
stats_resource = EnemyStats.new()
Stats.register(self, stats_resource)
origin = global_position
detection.body_entered.connect(_on_body_entered)
EventBus.health_changed.connect(_on_health_changed)
EventBus.entity_died.connect(_on_entity_died)
name_label.text = "Boss" if is_boss else "Enemy"
if is_boss:
name_label.text = "Boss"
var mesh: MeshInstance3D = mesh_holder.get_node("Mesh")
mesh.scale = Vector3(1.5, 1.5, 1.5)
var mat: StandardMaterial3D = StandardMaterial3D.new()
mat.albedo_color = Color(0.6, 0.2, 0.8)
mesh.material_override = mat
func _exit_tree() -> void:
Stats.deregister(self)
func _physics_process(delta: float) -> void:
if not is_multiplayer_authority():
global_position = global_position.lerp(sync_position, clamp(delta * 20.0, 0.0, 1.0))
rotation.y = lerp_angle(rotation.y, sync_yaw, clamp(delta * 20.0, 0.0, 1.0))
return
if dead:
return
if not is_on_floor():
velocity.y -= GRAVITY * delta
attack_cd = max(0.0, attack_cd - delta)
var target: Node = _get_target()
if target == null:
_return_to_origin(delta)
else:
_chase_or_attack(target, delta)
move_and_slide()
sync_position = global_position
sync_yaw = rotation.y
func _get_target() -> Node:
var aggro: Node = get_node_or_null("/root/World/Systems/AggroSystem")
if aggro == null:
aggro = get_node_or_null("/root/Dungeon/Systems/AggroSystem")
if aggro and aggro.has_method("target_for"):
var t: Node = aggro.target_for(self)
if t:
return t
if invasion_target and is_instance_valid(invasion_target):
return invasion_target
return null
func _chase_or_attack(target: Node, delta: float) -> void:
var t_pos: Vector3 = (target as Node3D).global_position
var d: float = global_position.distance_to(t_pos)
var attack_range: float = float(Stats.get_stat(self, "attack_range", 2.0))
if d <= attack_range:
velocity.x = move_toward(velocity.x, 0.0, 20.0 * delta)
velocity.z = move_toward(velocity.z, 0.0, 20.0 * delta)
var look := Vector3(t_pos.x - global_position.x, 0.0, t_pos.z - global_position.z)
if look.length() > 0.01:
rotation.y = atan2(look.x, look.z)
if attack_cd <= 0.0:
attack_cd = float(Stats.get_stat(self, "attack_cooldown", 1.5))
var dmg: float = float(Stats.get_stat(self, "attack_damage", 5.0))
EventBus.damage_requested.emit(self, target, dmg, Element.NONE)
else:
var dir := Vector3(t_pos.x - global_position.x, 0.0, t_pos.z - global_position.z).normalized()
var speed: float = float(Stats.get_stat(self, "speed", 3.0))
velocity.x = dir.x * speed
velocity.z = dir.z * speed
if dir.length() > 0.01:
rotation.y = atan2(dir.x, dir.z)
func _return_to_origin(delta: float) -> void:
var d: float = global_position.distance_to(origin)
if d < 0.5:
velocity.x = move_toward(velocity.x, 0.0, 20.0 * delta)
velocity.z = move_toward(velocity.z, 0.0, 20.0 * delta)
var max_hp: float = float(Stats.get_stat(self, "max_health", 100.0))
var hp: float = float(Stats.get_stat(self, "health", 0.0))
if hp > 0.0 and hp < max_hp:
Stats.set_stat(self, "health", min(max_hp, hp + max_hp * 0.20 * delta))
EventBus.health_changed.emit(self, Stats.get_stat(self, "health"), max_hp)
else:
var dir: Vector3 = Vector3(origin.x - global_position.x, 0.0, origin.z - global_position.z).normalized()
var speed: float = float(Stats.get_stat(self, "speed", 3.0)) * 1.5
velocity.x = dir.x * speed
velocity.z = dir.z * speed
if dir.length() > 0.01:
rotation.y = atan2(dir.x, dir.z)
func _on_body_entered(body: Node) -> void:
if not multiplayer.is_server() and multiplayer.multiplayer_peer != null:
return
if body.is_in_group("player") and not dead:
EventBus.enemy_detected.emit(self, body)
func _on_health_changed(entity: Node, current: float, max: float) -> void:
if entity != self:
return
var ratio: float = clamp(current / max if max > 0 else 0.0, 0.0, 1.0)
if healthbar:
healthbar.scale.x = max(0.01, ratio)
func _on_entity_died(entity: Node) -> void:
if entity != self or dead:
return
dead = true
if multiplayer.is_server() or multiplayer.multiplayer_peer == null:
_on_death.rpc()
var loot: Node = get_node_or_null("/root/World/Systems/LootSystem")
if loot == null:
loot = get_node_or_null("/root/Dungeon/Systems/LootSystem")
if loot and loot.has_method("drop_loot_for"):
loot.drop_loot_for(self, global_position)
var xp_sys: Node = get_node_or_null("/root/World/Systems/XpSystem")
if xp_sys == null:
xp_sys = get_node_or_null("/root/Dungeon/Systems/XpSystem")
if xp_sys and xp_sys.has_method("award_for_enemy"):
xp_sys.award_for_enemy(self)
if is_boss:
EventBus.boss_defeated.emit(self)
var t := get_tree().create_timer(2.0)
t.timeout.connect(func():
if is_instance_valid(self):
queue_free())
@rpc("any_peer", "reliable", "call_local")
func _on_death() -> void:
if multiplayer.multiplayer_peer != null and not (multiplayer.multiplayer_peer is OfflineMultiplayerPeer) and multiplayer.get_remote_sender_id() != 0 and multiplayer.get_remote_sender_id() != 1:
return
dead = true
collision.disabled = true
modulate_alpha(0.4)
func modulate_alpha(a: float) -> void:
for child in mesh_holder.get_children():
if child is MeshInstance3D and child.material_override:
var c: Color = child.material_override.albedo_color
c.a = a
child.material_override.albedo_color = c

View File

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

View File

@@ -0,0 +1,76 @@
[gd_scene load_steps=8 format=3 uid="uid://b0enemy00001"]
[ext_resource type="Script" path="res://scenes/entities/enemy/enemy.gd" id="1"]
[sub_resource type="CapsuleShape3D" id="CapsuleShape3D_1"]
height = 1.6
radius = 0.4
[sub_resource type="CapsuleMesh" id="CapsuleMesh_1"]
height = 1.6
radius = 0.4
[sub_resource type="StandardMaterial3D" id="Mat_1"]
albedo_color = Color(0.6, 0.6, 0.6, 1)
[sub_resource type="SphereShape3D" id="SphereShape3D_1"]
radius = 12.0
[sub_resource type="QuadMesh" id="QuadMesh_1"]
size = Vector2(1.0, 0.12)
[sub_resource type="StandardMaterial3D" id="HBMat"]
shading_mode = 0
no_depth_test = true
albedo_color = Color(0.9, 0.2, 0.2, 1)
[sub_resource type="SceneReplicationConfig" id="SceneReplicationConfig_1"]
properties/0/path = NodePath(".:sync_position")
properties/0/spawn = true
properties/0/replication_mode = 1
properties/1/path = NodePath(".:sync_yaw")
properties/1/spawn = true
properties/1/replication_mode = 1
[node name="Enemy" type="CharacterBody3D"]
collision_layer = 4
collision_mask = 1
script = ExtResource("1")
[node name="Collision" type="CollisionShape3D" parent="."]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0.8, 0)
shape = SubResource("CapsuleShape3D_1")
[node name="MeshHolder" type="Node3D" parent="."]
[node name="Mesh" type="MeshInstance3D" parent="MeshHolder"]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0.8, 0)
mesh = SubResource("CapsuleMesh_1")
material_override = SubResource("Mat_1")
[node name="NameLabel" type="Label3D" parent="."]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 2.2, 0)
billboard = 1
text = "Enemy"
font_size = 20
outline_size = 3
[node name="Healthbar" type="MeshInstance3D" parent="."]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 1.95, 0)
mesh = SubResource("QuadMesh_1")
material_override = SubResource("HBMat")
gi_mode = 0
[node name="DetectionArea" type="Area3D" parent="."]
collision_layer = 0
collision_mask = 2
[node name="DetectionShape" type="CollisionShape3D" parent="DetectionArea"]
shape = SubResource("SphereShape3D_1")
[node name="NavAgent" type="NavigationAgent3D" parent="."]
path_desired_distance = 0.5
target_desired_distance = 0.5
[node name="Synchronizer" type="MultiplayerSynchronizer" parent="."]
replication_config = SubResource("SceneReplicationConfig_1")

View File

@@ -0,0 +1,80 @@
extends StaticBody3D
@export var stats_resource: GateStats
@export var is_red: bool = false
@onready var mesh: MeshInstance3D = $Mesh
@onready var healthbar: MeshInstance3D = $Healthbar
@onready var name_label: Label3D = $NameLabel
@onready var spawn_point: Node3D = $SpawnPoint
var spawn_timer: float = 0.0
var spawned_count: int = 0
var dead: bool = false
func _enter_tree() -> void:
set_multiplayer_authority(1)
func _ready() -> void:
add_to_group("gates")
if is_red:
add_to_group("red_gate")
if stats_resource == null:
stats_resource = GateStats.new()
if is_red:
stats_resource.max_health = 600.0
stats_resource.spawn_count = 8
else:
stats_resource.max_health = 200.0
stats_resource.spawn_count = 5
stats_resource.is_red = is_red
Stats.register(self, stats_resource)
EventBus.health_changed.connect(_on_health_changed)
EventBus.entity_died.connect(_on_entity_died)
if is_red:
var mat: StandardMaterial3D = StandardMaterial3D.new()
mat.albedo_color = Color(0.95, 0.2, 0.15)
mat.emission_enabled = true
mat.emission = Color(1.0, 0.4, 0.2)
mat.emission_energy_multiplier = 0.6
mesh.material_override = mat
name_label.text = "Red Gate"
else:
name_label.text = "Gate"
set_physics_process(true)
func _exit_tree() -> void:
Stats.deregister(self)
func _physics_process(delta: float) -> void:
if dead:
return
if not multiplayer.is_server() and multiplayer.multiplayer_peer != null:
return
spawn_timer = max(0.0, spawn_timer - delta)
if spawn_timer <= 0.0 and spawned_count < int(Stats.get_stat(self, "spawn_count", 5)):
var spawn_sys: Node = get_node_or_null("/root/World/Systems/SpawnSystem")
if spawn_sys and spawn_sys.has_method("spawn_enemy_at"):
spawn_sys.spawn_enemy_at(spawn_point.global_position + Vector3(randf_range(-1.5, 1.5), 0.0, randf_range(-1.5, 1.5)), is_red)
spawned_count += 1
spawn_timer = float(Stats.get_stat(self, "spawn_interval", 4.0))
func _on_health_changed(entity: Node, current: float, max: float) -> void:
if entity != self:
return
var ratio: float = clamp(current / max if max > 0 else 0.0, 0.0, 1.0)
healthbar.scale.x = max(0.01, ratio * 2.0)
func _on_entity_died(entity: Node) -> void:
if entity != self or dead:
return
dead = true
if multiplayer.is_server() or multiplayer.multiplayer_peer == null:
var spawn_sys: Node = get_node_or_null("/root/World/Systems/SpawnSystem")
if spawn_sys and spawn_sys.has_method("spawn_portal_at"):
spawn_sys.spawn_portal_at(global_position, is_red)
EventBus.gate_destroyed.emit(self)
var t := get_tree().create_timer(0.5)
t.timeout.connect(func():
if is_instance_valid(self):
queue_free())

View File

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

View File

@@ -0,0 +1,53 @@
[gd_scene load_steps=7 format=3 uid="uid://b0gate0001"]
[ext_resource type="Script" path="res://scenes/entities/gate/gate.gd" id="1"]
[sub_resource type="BoxShape3D" id="BoxShape3D_1"]
size = Vector3(2, 3, 2)
[sub_resource type="BoxMesh" id="BoxMesh_1"]
size = Vector3(2, 3, 2)
[sub_resource type="StandardMaterial3D" id="GateMat"]
albedo_color = Color(0.4, 0.4, 0.55, 1)
emission_enabled = true
emission = Color(0.2, 0.2, 0.4, 1)
emission_energy_multiplier = 0.4
[sub_resource type="QuadMesh" id="QuadMesh_HB"]
size = Vector2(1.0, 0.18)
[sub_resource type="StandardMaterial3D" id="HBMat"]
shading_mode = 0
no_depth_test = true
albedo_color = Color(0.95, 0.4, 0.2, 1)
[node name="Gate" type="StaticBody3D"]
collision_layer = 4
collision_mask = 0
script = ExtResource("1")
[node name="Collision" type="CollisionShape3D" parent="."]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 1.5, 0)
shape = SubResource("BoxShape3D_1")
[node name="Mesh" type="MeshInstance3D" parent="."]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 1.5, 0)
mesh = SubResource("BoxMesh_1")
material_override = SubResource("GateMat")
[node name="NameLabel" type="Label3D" parent="."]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 4.0, 0)
billboard = 1
text = "Gate"
font_size = 22
outline_size = 3
[node name="Healthbar" type="MeshInstance3D" parent="."]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 3.5, 0)
mesh = SubResource("QuadMesh_HB")
material_override = SubResource("HBMat")
gi_mode = 0
[node name="SpawnPoint" type="Node3D" parent="."]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 3)

View File

@@ -0,0 +1,39 @@
extends Area3D
@onready var mesh: MeshInstance3D = $Mesh
@onready var label: Label3D = $Label
@export var item_id: StringName = &""
@export var amount: int = 1
var rotation_speed: float = 1.5
var bob: float = 0.0
func _enter_tree() -> void:
set_multiplayer_authority(1)
func _ready() -> void:
add_to_group("loot")
body_entered.connect(_on_body_entered)
if item_id != &"":
label.text = "%s x%d" % [str(item_id), amount]
else:
label.text = "Loot"
func _process(delta: float) -> void:
bob += delta
mesh.rotation.y += rotation_speed * delta
mesh.position.y = 0.5 + sin(bob * 2.0) * 0.1
func _on_body_entered(body: Node) -> void:
if not multiplayer.is_server() and multiplayer.multiplayer_peer != null:
return
if not body.is_in_group("player"):
return
var inv: Node = get_node_or_null("/root/World/Systems/InventorySystem")
if inv == null:
inv = get_node_or_null("/root/Dungeon/Systems/InventorySystem")
if inv and inv.has_method("add_item"):
inv.add_item(body, item_id, amount)
EventBus.item_picked_up.emit(body, item_id)
queue_free()

View File

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

View File

@@ -0,0 +1,35 @@
[gd_scene load_steps=4 format=3 uid="uid://b0loot0001"]
[ext_resource type="Script" path="res://scenes/entities/loot/loot_drop.gd" id="1"]
[sub_resource type="BoxMesh" id="BoxMesh_1"]
size = Vector3(0.4, 0.4, 0.4)
[sub_resource type="StandardMaterial3D" id="LootMat"]
albedo_color = Color(0.95, 0.85, 0.2, 1)
emission_enabled = true
emission = Color(1.0, 0.9, 0.4, 1)
emission_energy_multiplier = 0.6
[sub_resource type="SphereShape3D" id="SphereShape3D_1"]
radius = 1.2
[node name="LootDrop" type="Area3D"]
collision_layer = 0
collision_mask = 2
script = ExtResource("1")
[node name="Mesh" type="MeshInstance3D" parent="."]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0.5, 0)
mesh = SubResource("BoxMesh_1")
material_override = SubResource("LootMat")
[node name="Label" type="Label3D" parent="."]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 1.5, 0)
billboard = 1
text = "Loot"
font_size = 18
outline_size = 3
[node name="PickupShape" type="CollisionShape3D" parent="."]
shape = SubResource("SphereShape3D_1")

View File

@@ -0,0 +1,52 @@
extends StaticBody3D
@export var profile: NpcProfile
@export var profile_id: StringName = &""
@onready var mesh: MeshInstance3D = $Mesh
@onready var label: Label3D = $Label
@onready var prompt: Label3D = $Prompt
@onready var interact_area: Area3D = $InteractArea
var nearby_player: Node = null
func _enter_tree() -> void:
set_multiplayer_authority(1)
func _ready() -> void:
add_to_group("npc")
if profile == null and profile_id != &"":
var path := "res://resources/npcs/%s.tres" % str(profile_id)
if ResourceLoader.exists(path):
profile = load(path) as NpcProfile
if profile == null:
profile = NpcProfile.new()
profile.display_name = "Villager"
profile.lore = "A simple villager."
profile.personality = "Friendly, curious."
profile.fallback_text = "Hello there!"
label.text = profile.display_name
prompt.visible = false
if profile.color != Color.WHITE:
var mat: StandardMaterial3D = StandardMaterial3D.new()
mat.albedo_color = profile.color
mesh.material_override = mat
interact_area.body_entered.connect(_on_player_near)
interact_area.body_exited.connect(_on_player_left)
func _process(_delta: float) -> void:
if nearby_player and nearby_player.is_multiplayer_authority():
prompt.visible = true
if Input.is_action_just_pressed("interact"):
EventBus.dialog_opened.emit(nearby_player, self)
else:
prompt.visible = false
func _on_player_near(body: Node) -> void:
if body.is_in_group("player"):
if body.is_multiplayer_authority():
nearby_player = body
func _on_player_left(body: Node) -> void:
if body == nearby_player:
nearby_player = null

View File

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

View File

@@ -0,0 +1,53 @@
[gd_scene load_steps=6 format=3 uid="uid://b0npc0001"]
[ext_resource type="Script" path="res://scenes/entities/npc/npc.gd" id="1"]
[sub_resource type="CapsuleShape3D" id="CapsuleShape3D_1"]
height = 1.7
radius = 0.35
[sub_resource type="CapsuleMesh" id="CapsuleMesh_1"]
height = 1.7
radius = 0.35
[sub_resource type="StandardMaterial3D" id="NpcMat"]
albedo_color = Color(0.85, 0.7, 0.5, 1)
[sub_resource type="SphereShape3D" id="InteractShape"]
radius = 2.5
[node name="Npc" type="StaticBody3D"]
collision_layer = 0
collision_mask = 0
script = ExtResource("1")
[node name="Collision" type="CollisionShape3D" parent="."]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0.85, 0)
shape = SubResource("CapsuleShape3D_1")
[node name="Mesh" type="MeshInstance3D" parent="."]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0.85, 0)
mesh = SubResource("CapsuleMesh_1")
material_override = SubResource("NpcMat")
[node name="Label" type="Label3D" parent="."]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 2.2, 0)
billboard = 1
text = "Villager"
font_size = 22
outline_size = 3
modulate = Color(0.95, 0.85, 0.5, 1)
[node name="Prompt" type="Label3D" parent="."]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 2.55, 0)
billboard = 1
text = "[E] Talk"
font_size = 16
outline_size = 2
[node name="InteractArea" type="Area3D" parent="."]
collision_layer = 0
collision_mask = 2
[node name="InteractShape" type="CollisionShape3D" parent="InteractArea"]
shape = SubResource("InteractShape")

View File

@@ -0,0 +1,242 @@
extends CharacterBody3D
const GRAVITY: float = 18.0
const MOUSE_SENS: float = 0.0035
@export var stats_resource: PlayerStats
@onready var pivot: Node3D = $Pivot
@onready var pitch_pivot: Node3D = $Pivot/PitchPivot
@onready var camera: Camera3D = $Pivot/PitchPivot/Camera
@onready var mesh_holder: Node3D = $MeshHolder
@onready var collision: CollisionShape3D = $Collision
@onready var sync: MultiplayerSynchronizer = $Synchronizer
@onready var name_label: Label3D = $NameLabel
var peer_id: int = 1
var role: int = GameState.ROLE_DAMAGE
var look_dragging: bool = false
var current_target: Node = null
var dead: bool = false
var ui_capturing: bool = false
var build_mode: bool = false
@export var sync_position: Vector3 = Vector3.ZERO
@export var sync_velocity: Vector3 = Vector3.ZERO
@export var sync_yaw: float = 0.0
@export var sync_role: int = GameState.ROLE_DAMAGE
func _enter_tree() -> void:
peer_id = name.to_int()
set_multiplayer_authority(peer_id)
func _ready() -> void:
add_to_group("player")
if stats_resource == null:
stats_resource = PlayerStats.new()
Stats.register(self, stats_resource)
Stats.set_stat(self, "role", role)
name_label.text = Net.player_names.get(peer_id, "P%d" % peer_id)
EventBus.entity_died.connect(_on_entity_died_clear_target)
if is_multiplayer_authority():
camera.current = true
Input.mouse_mode = Input.MOUSE_MODE_VISIBLE
else:
camera.current = false
_apply_role_visual(role)
func _on_entity_died_clear_target(entity: Node) -> void:
if entity == current_target:
current_target = null
func _set_target(t: Node) -> void:
current_target = t
EventBus.target_changed.emit(self, t)
if multiplayer.multiplayer_peer != null and not (multiplayer.multiplayer_peer is OfflineMultiplayerPeer) and not multiplayer.is_server():
_request_target.rpc_id(1, String(t.get_path()) if t else "")
@rpc("any_peer", "reliable")
func _request_target(path_str: String) -> void:
if not multiplayer.is_server():
return
var t: Node = get_node_or_null(NodePath(path_str)) if path_str != "" else null
current_target = t
func _exit_tree() -> void:
Stats.deregister(self)
func _input(event: InputEvent) -> void:
if not is_multiplayer_authority():
return
if ui_capturing:
return
if event is InputEventMouseButton and event.button_index == MOUSE_BUTTON_RIGHT:
look_dragging = event.pressed
Input.mouse_mode = Input.MOUSE_MODE_CAPTURED if look_dragging else Input.MOUSE_MODE_VISIBLE
elif event is InputEventMouseMotion and look_dragging:
pivot.rotate_y(-event.relative.x * MOUSE_SENS)
pitch_pivot.rotate_x(-event.relative.y * MOUSE_SENS)
pitch_pivot.rotation.x = clamp(pitch_pivot.rotation.x, -1.2, 0.2)
func _unhandled_input(event: InputEvent) -> void:
if not is_multiplayer_authority():
return
if dead:
return
if build_mode:
return
if event.is_action_pressed("class_tank"):
_request_role(GameState.ROLE_TANK)
elif event.is_action_pressed("class_damage"):
_request_role(GameState.ROLE_DAMAGE)
elif event.is_action_pressed("class_healer"):
_request_role(GameState.ROLE_HEALER)
elif event.is_action_pressed("ability_1"):
EventBus.ability_use_requested.emit(self, 0)
elif event.is_action_pressed("ability_2"):
EventBus.ability_use_requested.emit(self, 1)
elif event.is_action_pressed("ability_3"):
EventBus.ability_use_requested.emit(self, 2)
elif event.is_action_pressed("ability_4"):
EventBus.ability_use_requested.emit(self, 3)
elif event.is_action_pressed("target_next"):
var nt := _cycle_target()
_set_target(nt)
elif event is InputEventMouseButton and event.button_index == MOUSE_BUTTON_LEFT and event.pressed and not look_dragging:
var t := _pick_target_under_mouse()
if t:
_set_target(t)
func _physics_process(delta: float) -> void:
if is_multiplayer_authority():
if not dead:
_process_local(delta)
sync_position = global_position
sync_velocity = velocity
sync_yaw = pivot.rotation.y
sync_role = role
else:
global_position = global_position.lerp(sync_position, clamp(delta * 20.0, 0.0, 1.0))
pivot.rotation.y = lerp_angle(pivot.rotation.y, sync_yaw, clamp(delta * 20.0, 0.0, 1.0))
if sync_role != role:
role = sync_role
_apply_role_visual(role)
func _process_local(delta: float) -> void:
if not is_on_floor():
velocity.y -= GRAVITY * delta
var move_dir: Vector2 = Input.get_vector("move_left", "move_right", "move_forward", "move_back") if not ui_capturing else Vector2.ZERO
var speed: float = float(Stats.get_stat(self, "speed", 5.0))
var basis_y := Basis(Vector3.UP, pivot.rotation.y)
var direction := basis_y * Vector3(move_dir.x, 0.0, move_dir.y)
if direction.length() > 0.01:
velocity.x = direction.x * speed
velocity.z = direction.z * speed
var look_dir := Vector3(velocity.x, 0.0, velocity.z).normalized()
var target_basis := Basis.looking_at(look_dir, Vector3.UP)
mesh_holder.basis = mesh_holder.basis.slerp(target_basis, clamp(delta * 12.0, 0.0, 1.0))
else:
velocity.x = move_toward(velocity.x, 0.0, speed * 6.0 * delta)
velocity.z = move_toward(velocity.z, 0.0, speed * 6.0 * delta)
if Input.is_action_just_pressed("jump") and is_on_floor() and not ui_capturing:
velocity.y = float(Stats.get_stat(self, "jump_velocity", 4.5))
move_and_slide()
func _request_role(new_role: int) -> void:
_set_role.rpc_id(1, new_role)
@rpc("any_peer", "reliable", "call_local")
func _set_role(new_role: int) -> void:
if not multiplayer.is_server():
return
if new_role == role:
return
role = new_role
Stats.set_stat(self, "role", role)
_apply_role.rpc(role)
@rpc("any_peer", "reliable", "call_local")
func _apply_role(new_role: int) -> void:
if multiplayer.multiplayer_peer != null and not (multiplayer.multiplayer_peer is OfflineMultiplayerPeer) and multiplayer.get_remote_sender_id() != 0 and multiplayer.get_remote_sender_id() != 1:
return
role = new_role
if Stats.has(self):
Stats.set_stat(self, "role", role)
_apply_role_visual(role)
EventBus.role_changed.emit(self, role)
func _apply_role_visual(r: int) -> void:
var mesh: MeshInstance3D = mesh_holder.get_node("Mesh")
var mat: StandardMaterial3D = mesh.get_active_material(0).duplicate() if mesh.get_active_material(0) else StandardMaterial3D.new()
match r:
GameState.ROLE_TANK:
mat.albedo_color = Color(0.3, 0.5, 0.95)
GameState.ROLE_DAMAGE:
mat.albedo_color = Color(0.95, 0.3, 0.3)
GameState.ROLE_HEALER:
mat.albedo_color = Color(0.4, 0.85, 0.4)
mesh.material_override = mat
@rpc("any_peer", "reliable", "call_local")
func set_dead(value: bool) -> void:
if multiplayer.multiplayer_peer != null and not (multiplayer.multiplayer_peer is OfflineMultiplayerPeer) and multiplayer.get_remote_sender_id() != 0 and multiplayer.get_remote_sender_id() != 1:
return
dead = value
visible = not value
collision.disabled = value
if value:
velocity = Vector3.ZERO
@rpc("any_peer", "reliable", "call_local")
func teleport_to(pos: Vector3) -> void:
if multiplayer.multiplayer_peer != null and not (multiplayer.multiplayer_peer is OfflineMultiplayerPeer) and multiplayer.get_remote_sender_id() != 0 and multiplayer.get_remote_sender_id() != 1:
return
global_position = pos
sync_position = pos
func set_ui_capturing(v: bool) -> void:
ui_capturing = v
func set_build_mode(v: bool) -> void:
build_mode = v
func _cycle_target() -> Node:
if current_target != null and not is_instance_valid(current_target):
current_target = null
var candidates: Array = []
for n in get_tree().get_nodes_in_group("enemies"):
if is_instance_valid(n):
candidates.append(n)
for n in get_tree().get_nodes_in_group("portals"):
if is_instance_valid(n):
candidates.append(n)
for n in get_tree().get_nodes_in_group("gates"):
if is_instance_valid(n):
candidates.append(n)
if candidates.is_empty():
current_target = null
return null
candidates.sort_custom(func(a, b):
return (a as Node3D).global_position.distance_to(global_position) < (b as Node3D).global_position.distance_to(global_position))
if current_target == null or not current_target in candidates:
current_target = candidates[0]
else:
var idx: int = candidates.find(current_target)
current_target = candidates[(idx + 1) % candidates.size()]
return current_target
func _pick_target_under_mouse() -> Node:
var mouse := get_viewport().get_mouse_position()
var from := camera.project_ray_origin(mouse)
var to := from + camera.project_ray_normal(mouse) * 100.0
var space := get_world_3d().direct_space_state
var query := PhysicsRayQueryParameters3D.create(from, to)
query.collision_mask = 0xFFFFFFFF
query.exclude = [self]
var hit := space.intersect_ray(query)
if hit.is_empty():
return null
var n: Node = hit.collider
while n != null and not (n.is_in_group("enemies") or n.is_in_group("portals") or n.is_in_group("gates") or n.is_in_group("npc")):
n = n.get_parent()
return n

View File

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

View File

@@ -0,0 +1,58 @@
[gd_scene load_steps=8 format=3 uid="uid://b0player00001"]
[ext_resource type="Script" path="res://scenes/entities/player/player.gd" id="1"]
[sub_resource type="CapsuleShape3D" id="CapsuleShape3D_1"]
height = 1.8
radius = 0.35
[sub_resource type="CapsuleMesh" id="CapsuleMesh_1"]
height = 1.8
radius = 0.35
[sub_resource type="StandardMaterial3D" id="StandardMaterial3D_1"]
albedo_color = Color(0.3, 0.55, 0.95, 1)
[sub_resource type="SceneReplicationConfig" id="SceneReplicationConfig_1"]
properties/0/path = NodePath(".:sync_position")
properties/0/spawn = true
properties/0/replication_mode = 1
properties/1/path = NodePath(".:sync_yaw")
properties/1/spawn = true
properties/1/replication_mode = 1
[node name="Player" type="CharacterBody3D"]
collision_layer = 2
collision_mask = 1
script = ExtResource("1")
[node name="Collision" type="CollisionShape3D" parent="."]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0.9, 0)
shape = SubResource("CapsuleShape3D_1")
[node name="MeshHolder" type="Node3D" parent="."]
[node name="Mesh" type="MeshInstance3D" parent="MeshHolder"]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0.9, 0)
mesh = SubResource("CapsuleMesh_1")
surface_material_override/0 = SubResource("StandardMaterial3D_1")
[node name="NameLabel" type="Label3D" parent="."]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 2.0, 0)
billboard = 1
text = "Player"
font_size = 24
outline_size = 4
[node name="Pivot" type="Node3D" parent="."]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 1.5, 0)
[node name="PitchPivot" type="Node3D" parent="Pivot"]
[node name="Camera" type="Camera3D" parent="Pivot/PitchPivot"]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0.5, 5)
current = false
fov = 70.0
[node name="Synchronizer" type="MultiplayerSynchronizer" parent="."]
replication_config = SubResource("SceneReplicationConfig_1")

View File

@@ -0,0 +1,62 @@
extends StaticBody3D
@export var stats_resource: PortalStats
@export var is_red: bool = false
@onready var mesh: MeshInstance3D = $Mesh
@onready var name_label: Label3D = $NameLabel
@onready var enter_area: Area3D = $EnterArea
var triggered: bool = false
func _enter_tree() -> void:
set_multiplayer_authority(1)
func _ready() -> void:
add_to_group("portals")
if is_red:
add_to_group("red_portal")
if stats_resource == null:
stats_resource = PortalStats.new()
stats_resource.max_health = 1.0
stats_resource.is_red = is_red
Stats.register(self, stats_resource)
enter_area.body_entered.connect(_on_body_entered)
if is_red:
var mat: StandardMaterial3D = StandardMaterial3D.new()
mat.albedo_color = Color(0.95, 0.2, 0.2)
mat.emission_enabled = true
mat.emission = Color(1.0, 0.4, 0.3)
mat.emission_energy_multiplier = 1.5
mesh.material_override = mat
name_label.text = "Red Portal"
else:
name_label.text = "Portal"
func _exit_tree() -> void:
Stats.deregister(self)
func _on_body_entered(body: Node) -> void:
if not body.is_in_group("player"):
return
if not body.is_multiplayer_authority():
return
if triggered:
return
triggered = true
EventBus.portal_entered.emit(self, body)
_request_enter.rpc_id(1, is_red, global_position)
@rpc("any_peer", "reliable", "call_local")
func _request_enter(red: bool, return_pos: Vector3) -> void:
if not multiplayer.is_server() and multiplayer.multiplayer_peer != null:
return
var seed: int = randi()
_do_enter.rpc(seed, red, return_pos)
@rpc("authority", "reliable", "call_local")
func _do_enter(seed: int, red: bool, return_pos: Vector3) -> void:
GameState.dungeon_seed = seed
GameState.dungeon_red = red
GameState.portal_return_position = return_pos
GameState.change_scene(GameState.SCENE_DUNGEON)

View File

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

View File

@@ -0,0 +1,49 @@
[gd_scene load_steps=6 format=3 uid="uid://b0portal0001"]
[ext_resource type="Script" path="res://scenes/entities/portal/portal.gd" id="1"]
[sub_resource type="CylinderShape3D" id="CylShape_1"]
height = 0.4
radius = 1.5
[sub_resource type="CylinderMesh" id="CylMesh_1"]
top_radius = 1.5
bottom_radius = 1.5
height = 3.0
[sub_resource type="StandardMaterial3D" id="PortalMat"]
albedo_color = Color(0.3, 0.5, 0.95, 1)
emission_enabled = true
emission = Color(0.5, 0.7, 1.0, 1)
emission_energy_multiplier = 1.5
[sub_resource type="SphereShape3D" id="SphereEnter"]
radius = 1.8
[node name="Portal" type="StaticBody3D"]
collision_layer = 8
collision_mask = 0
script = ExtResource("1")
[node name="Collision" type="CollisionShape3D" parent="."]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0.2, 0)
shape = SubResource("CylShape_1")
[node name="Mesh" type="MeshInstance3D" parent="."]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 1.5, 0)
mesh = SubResource("CylMesh_1")
material_override = SubResource("PortalMat")
[node name="NameLabel" type="Label3D" parent="."]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 4.0, 0)
billboard = 1
text = "Portal"
font_size = 22
outline_size = 3
[node name="EnterArea" type="Area3D" parent="."]
collision_layer = 0
collision_mask = 2
[node name="EnterShape" type="CollisionShape3D" parent="EnterArea"]
shape = SubResource("SphereEnter")

Some files were not shown because too many files have changed in this diff Show More