Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f8413ce2d4 | |||
| 42101246d9 | |||
| 64b69fd181 | |||
| 6cb5925da8 | |||
| 2c49e0c9db | |||
| 2e4d697977 | |||
| d927320358 | |||
| 26f9609b53 | |||
| 27459e1eaa | |||
| 7f6811995d | |||
| 610d766cb2 | |||
| 984754183f | |||
| 982a4ec23d | |||
| 183bb0691d | |||
| df63dec39b | |||
| 63500148db | |||
| 59a90f621c |
22
.claude/settings.json
Normal file
22
.claude/settings.json
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(git:*)",
|
||||
"Bash(make:*)",
|
||||
"Bash(python3:*)",
|
||||
"Bash(pip:*)",
|
||||
"Bash(ls:*)",
|
||||
"Bash(cat:*)",
|
||||
"Read(*)",
|
||||
"Edit(*)",
|
||||
"Write(*)",
|
||||
"Glob(*)",
|
||||
"Grep(*)"
|
||||
],
|
||||
"deny": [
|
||||
"Bash(rm -rf:*)",
|
||||
"Bash(curl *password*:*)",
|
||||
"Bash(ssh:*)"
|
||||
]
|
||||
}
|
||||
}
|
||||
24
.gitea/AGENTS.md
Normal file
24
.gitea/AGENTS.md
Normal file
@@ -0,0 +1,24 @@
|
||||
# AGENTS — Identités et permissions
|
||||
|
||||
## Comptes Gitea actifs
|
||||
|
||||
| Username | Rôle | Périmètre |
|
||||
|---|---|---|
|
||||
| `floppyrj45` | Admin / Owner | Tous les repos, valide les PR sur `main` |
|
||||
| `agent-surfer` | Agent principal OpenClaw | Tous les repos du Labo |
|
||||
| `agent-watcher` | Gitea watcher / CI | Lecture + commentaires issues |
|
||||
| `agent-claude-cli` | Claude Code CLI local | Repos assignés par Flag |
|
||||
| `agent-codex` | Codex / OpenCode | Repos assignés par Flag |
|
||||
|
||||
## Créer un nouveau compte agent
|
||||
|
||||
```bash
|
||||
./scripts/gitea-agent-setup.sh <nom-agent>
|
||||
```
|
||||
|
||||
Le script crée le compte Gitea, génère un token, et l'ajoute à `~/.agent-gitea-tokens`.
|
||||
|
||||
## Règle de moindre privilège
|
||||
|
||||
Un agent ne touche qu'aux repos explicitement listés dans `AI_CONTEXT.md` de chaque projet.
|
||||
Il ne lit pas, ne fork pas, ne clone pas les repos hors de son périmètre.
|
||||
54
.gitea/WORKFLOW.md
Normal file
54
.gitea/WORKFLOW.md
Normal file
@@ -0,0 +1,54 @@
|
||||
# WORKFLOW — Règles Git multi-agent
|
||||
|
||||
## Branches
|
||||
|
||||
| Branche | Usage | Qui push |
|
||||
|---|---|---|
|
||||
| `main` | Production stable | Flag uniquement (via PR) |
|
||||
| `develop` | Intégration continue | Agents via PR |
|
||||
| `feat/<agent>/<desc>` | Nouvelle fonctionnalité | Agent concerné |
|
||||
| `fix/<agent>/<desc>` | Correction de bug | Agent concerné |
|
||||
| `chore/<agent>/<desc>` | Maintenance, docs, config | Agent concerné |
|
||||
|
||||
**Jamais de push direct sur `main` ou `develop`.** Toujours via Pull Request.
|
||||
|
||||
## Format de commit
|
||||
|
||||
```
|
||||
[agent:<nom>] <verbe> <description courte>
|
||||
```
|
||||
|
||||
Exemples :
|
||||
- `[agent:surfer] ajout endpoint /mission pour dashboard`
|
||||
- `[agent:claude-cli] fix calcul distance waypoints`
|
||||
- `[flag] merge feat/surfer/telemetry-endpoint`
|
||||
|
||||
Le nom d'agent doit correspondre à un compte Gitea listé dans `AGENTS.md`.
|
||||
|
||||
## Pull Requests
|
||||
|
||||
- Titre : `[agent:<nom>] <description>`
|
||||
- Body : utiliser le template `.gitea/pull_request_template.md`
|
||||
- Reviewer : `floppyrj45` (Flag) pour toute PR vers `main`
|
||||
- PR vers `develop` : peut être mergée par un autre agent si les checks passent
|
||||
|
||||
## Issues
|
||||
|
||||
- Ouvrir une issue avant tout travail non trivial
|
||||
- Assigner l'agent qui prend en charge
|
||||
- Labels : `bug`, `feat`, `chore`, `blocked`, `agent:<nom>`
|
||||
|
||||
## Protection de branche
|
||||
|
||||
- `main` : PR obligatoire, 1 approbation humaine minimum
|
||||
- `develop` : PR obligatoire, checks CI requis
|
||||
|
||||
## Identité Git locale (agents)
|
||||
|
||||
Chaque agent configure :
|
||||
```bash
|
||||
git config user.name "agent-<nom>"
|
||||
git config user.email "agent-<nom>@labo.local"
|
||||
```
|
||||
|
||||
Token d'authentification : voir `~/.agent-gitea-tokens` sur `.82`, ou demander via `gitea-agent-setup.sh`.
|
||||
25
.gitea/pull_request_template.md
Normal file
25
.gitea/pull_request_template.md
Normal file
@@ -0,0 +1,25 @@
|
||||
## Résumé
|
||||
|
||||
<!-- Une phrase sur ce que cette PR fait -->
|
||||
|
||||
## Type de changement
|
||||
|
||||
- [ ] `feat` — nouvelle fonctionnalité
|
||||
- [ ] `fix` — correction de bug
|
||||
- [ ] `chore` — maintenance / docs / config
|
||||
- [ ] `refactor` — refactoring sans changement de comportement
|
||||
|
||||
## Agent / Auteur
|
||||
|
||||
`agent:` <!-- ex: surfer, claude-cli, flag -->
|
||||
|
||||
## Checklist
|
||||
|
||||
- [ ] Code testé localement
|
||||
- [ ] Docs mises à jour si besoin (`docs/`)
|
||||
- [ ] Pas de secret en clair dans le diff
|
||||
- [ ] Commit messages au format `[agent:<nom>] verbe description`
|
||||
|
||||
## Contexte
|
||||
|
||||
<!-- Lien vers l'issue, la tâche, ou le livrable concerné -->
|
||||
64
AI_CONTEXT.md
Normal file
64
AI_CONTEXT.md
Normal file
@@ -0,0 +1,64 @@
|
||||
# AI_CONTEXT.md — Handoff agent
|
||||
|
||||
> **Premier fichier à lire.** Ce fichier permet à n'importe quel agent IA de reprendre le projet sans briefing humain.
|
||||
|
||||
## Projet
|
||||
|
||||
| Champ | Valeur |
|
||||
|---|---|
|
||||
| **Nom** | dauphincraft |
|
||||
| **Description** | DauphinCraft — Minecraft-like sous-marin Godot |
|
||||
| **Statut** | `wip` / `active` / `paused` / `archived` |
|
||||
| **Owner humain** | Flag (`floppyrj45`) |
|
||||
| **Agent principal** | `agent-surfer` |
|
||||
|
||||
## Ressources
|
||||
|
||||
| Ressource | URL / Chemin |
|
||||
|---|---|
|
||||
| **Gitea** | `http://192.168.0.82:3000/floppyrj45/dauphincraft` |
|
||||
| **Nextcloud** | `/mnt/nas-nextcloud/dauphincraft/` (sur `.82`) |
|
||||
| **Docs Sphinx** | `http://192.168.0.82/dauphincraft-docs/` |
|
||||
| **Channel Discord** | `#PROJECT_CHANNEL` |
|
||||
|
||||
## Architecture courte
|
||||
|
||||
*À compléter — 5-10 lignes max. Stack, composants principaux, ports exposés.*
|
||||
|
||||
## État actuel
|
||||
|
||||
*Résumé de l'état du projet au moment de la dernière mise à jour de ce fichier.*
|
||||
|
||||
## Tâches ouvertes
|
||||
|
||||
- [ ] tâche 1
|
||||
- [ ] tâche 2
|
||||
|
||||
## Décisions clés
|
||||
|
||||
*Décisions d'architecture ou de design importantes à connaître.*
|
||||
|
||||
## Comment démarrer (agent)
|
||||
|
||||
```bash
|
||||
# 1. Configurer ton identité Git
|
||||
git config user.name "agent-<ton-nom>"
|
||||
git config user.email "agent-<ton-nom>@labo.local"
|
||||
|
||||
# 2. Lire les règles workflow
|
||||
cat .gitea/WORKFLOW.md
|
||||
|
||||
# 3. Créer ta branche de travail
|
||||
git checkout -b feat/agent-<ton-nom>/<description>
|
||||
|
||||
# 4. Push et ouvrir une PR vers develop
|
||||
git push origin feat/agent-<ton-nom>/<description>
|
||||
```
|
||||
|
||||
Token Gitea : voir `~/.agent-gitea-tokens` sur `.82` ou demander à Flag.
|
||||
|
||||
## Outils & Infra
|
||||
|
||||
- **Carte infra :** `git clone http://192.168.0.82:3000/floppyrj45/infra && cat infra/INFRA.md`
|
||||
- **Outils agents :** `cat infra/TOOLS.md`
|
||||
- **Tokens Gitea :** `source ~/.agent-gitea-tokens` sur `.82`
|
||||
37
CLAUDE.md
Normal file
37
CLAUDE.md
Normal file
@@ -0,0 +1,37 @@
|
||||
# CLAUDE.md — Instructions pour agents Claude
|
||||
|
||||
## Contexte projet
|
||||
|
||||
Voir `AI_CONTEXT.md` pour le contexte complet.
|
||||
|
||||
## Règles de développement
|
||||
|
||||
- Lire `.gitea/WORKFLOW.md` avant tout commit
|
||||
- Branche de travail : `feat/agent-claude-cli/<description>`
|
||||
- Commit format : `[agent:claude-cli] verbe description`
|
||||
- Jamais de push direct sur `main` ou `develop`
|
||||
- Jamais de secret en clair dans le code ou les commits
|
||||
|
||||
## Stack technique
|
||||
|
||||
*À compléter selon le projet.*
|
||||
|
||||
## Commandes utiles
|
||||
|
||||
```bash
|
||||
# Tests
|
||||
# make test
|
||||
|
||||
# Docs
|
||||
make -C docs html
|
||||
|
||||
# Linter
|
||||
# make lint
|
||||
```
|
||||
|
||||
## Périmètre autorisé
|
||||
|
||||
- Modifier le code dans ce repo uniquement
|
||||
- Ouvrir des PRs vers `develop`
|
||||
- Créer des issues si bloqué
|
||||
- Ne pas toucher aux secrets, credentials, ou config infra
|
||||
@@ -6,4 +6,4 @@ PORT="${1:-7777}"
|
||||
BINARY_DIR="$(dirname "$(readlink -f "$0")")"
|
||||
cd "$BINARY_DIR"
|
||||
|
||||
exec ./DauphinCraftServer.x86_64 --server --port "$PORT" --headless
|
||||
exec ./DauphinCraftServer.x86_64 --headless -- --server --port "$PORT"
|
||||
|
||||
@@ -7,7 +7,7 @@ Type=simple
|
||||
User=dauphin
|
||||
Group=dauphin
|
||||
WorkingDirectory=/opt/dauphincraft
|
||||
ExecStart=/opt/dauphincraft/DauphinCraftServer.x86_64 --server --port 7777 --headless
|
||||
ExecStart=/opt/dauphincraft/DauphinCraftServer.x86_64 --headless -- --server --port 7777
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
StandardOutput=append:/var/log/dauphincraft.log
|
||||
|
||||
75
deploy/push_to_gitea.sh
Normal file → Executable file
75
deploy/push_to_gitea.sh
Normal file → Executable file
@@ -1,56 +1,39 @@
|
||||
#!/bin/bash
|
||||
# Push DauphinCraft vers Gitea .86
|
||||
# Push DauphinCraft vers Gitea .0.82
|
||||
# Usage: GITEA_TOKEN=xxx bash deploy/push_to_gitea.sh (recommandé)
|
||||
# ou : GITEA_USER=... GITEA_PASS=... bash deploy/push_to_gitea.sh
|
||||
set -e
|
||||
|
||||
GITEA_URL="${GITEA_URL:-http://192.168.1.86:3000}"
|
||||
GITEA_USER="${GITEA_USER:-flagabat}"
|
||||
GITEA_PASS="${GITEA_PASS:-SuperTeam2026!}"
|
||||
GITEA_URL="${GITEA_URL:-http://192.168.0.82:3000}"
|
||||
GITEA_USER="${GITEA_USER:-floppyrj45}"
|
||||
REPO_NAME="${REPO_NAME:-dauphincraft}"
|
||||
PROJECT_DIR="${PROJECT_DIR:-$(dirname "$0")/..}"
|
||||
|
||||
cd "$PROJECT_DIR"
|
||||
|
||||
# 1. Créer repo via API (idempotent)
|
||||
echo "=== Création repo $REPO_NAME ==="
|
||||
curl -s -u "$GITEA_USER:$GITEA_PASS" -X POST "$GITEA_URL/api/v1/user/repos" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"name\":\"$REPO_NAME\",\"description\":\"DauphinCraft — Minecraft-like sous-marin\",\"private\":false,\"auto_init\":false}" \
|
||||
| head -5 || true
|
||||
|
||||
# 2. Ajouter remote Gitea
|
||||
GITEA_REMOTE="$GITEA_URL/$GITEA_USER/$REPO_NAME.git"
|
||||
git remote remove gitea 2>/dev/null || true
|
||||
git remote add gitea "http://$GITEA_USER:$GITEA_PASS@${GITEA_REMOTE#http://}"
|
||||
|
||||
# 3. Push
|
||||
echo "=== Push master vers Gitea ==="
|
||||
git push -u gitea master
|
||||
|
||||
# 4. Créer release v0.1.0 via API
|
||||
echo "=== Création release v0.1.0 ==="
|
||||
RELEASE_JSON=$(curl -s -u "$GITEA_USER:$GITEA_PASS" -X POST "$GITEA_URL/api/v1/repos/$GITEA_USER/$REPO_NAME/releases" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"tag_name":"v0.1.0","target_commitish":"master","name":"v0.1.0 — First beta","body":"## Première beta publique\n\n- Monde voxel procédural sous-marin\n- Dauphin contrôleur, écholocation\n- Inventaire + 5 recettes craft\n- Mobs: poissons, méduses, requin\n- Multijoueur ENet 16 joueurs\n- Ambiance shaders + audio CC0\n\nDownload Windows + Serveur Linux ci-dessous.","draft":false,"prerelease":true}')
|
||||
echo "$RELEASE_JSON" | head -10
|
||||
RELEASE_ID=$(echo "$RELEASE_JSON" | grep -oP '"id":\s*\K\d+' | head -1)
|
||||
|
||||
# 5. Upload artifacts
|
||||
if [ -n "$RELEASE_ID" ]; then
|
||||
echo "=== Upload client Windows ==="
|
||||
[ -f builds/DauphinCraft.exe ] && curl -s -u "$GITEA_USER:$GITEA_PASS" -X POST \
|
||||
"$GITEA_URL/api/v1/repos/$GITEA_USER/$REPO_NAME/releases/$RELEASE_ID/assets?name=DauphinCraft-v0.1-win64.exe" \
|
||||
-H "Content-Type: application/octet-stream" \
|
||||
--data-binary "@builds/DauphinCraft.exe" | head -3
|
||||
[ -f builds/DauphinCraft.pck ] && curl -s -u "$GITEA_USER:$GITEA_PASS" -X POST \
|
||||
"$GITEA_URL/api/v1/repos/$GITEA_USER/$REPO_NAME/releases/$RELEASE_ID/assets?name=DauphinCraft-v0.1.pck" \
|
||||
-H "Content-Type: application/octet-stream" \
|
||||
--data-binary "@builds/DauphinCraft.pck" | head -3
|
||||
echo "=== Upload serveur Linux ==="
|
||||
[ -f builds/DauphinCraft-Server-v0.1.tar.gz ] && curl -s -u "$GITEA_USER:$GITEA_PASS" -X POST \
|
||||
"$GITEA_URL/api/v1/repos/$GITEA_USER/$REPO_NAME/releases/$RELEASE_ID/assets?name=DauphinCraft-Server-v0.1.tar.gz" \
|
||||
-H "Content-Type: application/octet-stream" \
|
||||
--data-binary "@builds/DauphinCraft-Server-v0.1.tar.gz" | head -3
|
||||
if [ -n "$GITEA_TOKEN" ]; then
|
||||
AUTH_HEADER=(-H "Authorization: token $GITEA_TOKEN")
|
||||
REMOTE_AUTH="$GITEA_USER:$GITEA_TOKEN"
|
||||
elif [ -n "$GITEA_PASS" ]; then
|
||||
AUTH_HEADER=(-u "$GITEA_USER:$GITEA_PASS")
|
||||
REMOTE_AUTH="$GITEA_USER:$GITEA_PASS"
|
||||
else
|
||||
echo "ERREUR: fournir GITEA_TOKEN ou GITEA_PASS" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "=== Terminé ==="
|
||||
echo "Release URL: $GITEA_URL/$GITEA_USER/$REPO_NAME/releases"
|
||||
echo "=== Création repo $REPO_NAME (idempotent) ==="
|
||||
curl -s "${AUTH_HEADER[@]}" -X POST "$GITEA_URL/api/v1/user/repos" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"name\":\"$REPO_NAME\",\"description\":\"DauphinCraft — Minecraft-like sous-marin\",\"private\":false,\"auto_init\":false}" \
|
||||
>/dev/null || true
|
||||
|
||||
GITEA_HOSTPATH="${GITEA_URL#http://}"
|
||||
GITEA_HOSTPATH="${GITEA_HOSTPATH#https://}"
|
||||
git remote remove gitea 2>/dev/null || true
|
||||
git remote add gitea "http://${REMOTE_AUTH}@${GITEA_HOSTPATH}/$GITEA_USER/$REPO_NAME.git"
|
||||
|
||||
echo "=== Push master ==="
|
||||
git push -u gitea master
|
||||
|
||||
echo "=== OK: $GITEA_URL/$GITEA_USER/$REPO_NAME ==="
|
||||
|
||||
@@ -98,6 +98,11 @@ BlockDatabase="*res://scripts/world/BlockDatabase.gd"
|
||||
AudioManager="*res://scripts/ambience/AudioManager.gd"
|
||||
ItemDatabase="*res://scripts/inventory/ItemDatabase.gd"
|
||||
NetworkManager="*res://scripts/net/NetworkManager.gd"
|
||||
PlayerProgress="*res://scripts/progression/PlayerProgress.gd"
|
||||
QuestManager="*res://scripts/progression/QuestManager.gd"
|
||||
AchievementManager="*res://scripts/progression/AchievementManager.gd"
|
||||
ComboTracker="*res://scripts/progression/ComboTracker.gd"
|
||||
SaveManager="*res://scripts/progression/SaveManager.gd"
|
||||
|
||||
[rendering]
|
||||
environment/defaults/default_clear_color=Color(0.05, 0.15, 0.25, 1)
|
||||
|
||||
6
scenes/world/Pearl.tscn
Normal file
6
scenes/world/Pearl.tscn
Normal file
@@ -0,0 +1,6 @@
|
||||
[gd_scene load_steps=2 format=3 uid="uid://dauphincraft_pearl"]
|
||||
|
||||
[ext_resource type="Script" path="res://scripts/world/Pearl.gd" id="1_pearl"]
|
||||
|
||||
[node name="Pearl" type="Area3D"]
|
||||
script = ExtResource("1_pearl")
|
||||
101
scripts/Main.gd
101
scripts/Main.gd
@@ -56,10 +56,15 @@ func _ready() -> void:
|
||||
_chat_manager.name = "ChatManager"
|
||||
add_child(_chat_manager)
|
||||
|
||||
# Pearl spawner — skip on dedicated headless (no local player)
|
||||
if not (mode == NetworkManager.Mode.DEDICATED and DisplayServer.get_name() == "headless"):
|
||||
_spawn_pearl_system()
|
||||
|
||||
# Audio
|
||||
AudioManager.play_ambient_loop("underwater_ambient")
|
||||
AudioManager.play_music("underwater_theme")
|
||||
AudioManager.play_whale_call_random()
|
||||
AudioManager.play_deep_sea_ambient()
|
||||
|
||||
# Starting inventory
|
||||
inventory.add_item(2, 10)
|
||||
@@ -81,6 +86,12 @@ func _spawn_local_dolphin() -> void:
|
||||
|
||||
dolphin.add_to_group("player")
|
||||
|
||||
# Save system
|
||||
var save_mgr: Node = get_node_or_null("/root/SaveManager")
|
||||
if save_mgr != null:
|
||||
save_mgr.register_player(dolphin)
|
||||
save_mgr.load_game()
|
||||
|
||||
var ui_scene: PackedScene = load("res://scenes/InventoryUI.tscn")
|
||||
if ui_scene != null:
|
||||
_inventory_ui = ui_scene.instantiate() as CanvasLayer
|
||||
@@ -158,6 +169,18 @@ func _spawn_mobs() -> void:
|
||||
mob_spawner.player = mob_spawner.get_path_to(_my_dolphin)
|
||||
|
||||
|
||||
func _spawn_pearl_system() -> void:
|
||||
var spawner_script: Script = load("res://scripts/world/PearlSpawner.gd")
|
||||
if spawner_script == null:
|
||||
return
|
||||
var spawner := Node3D.new()
|
||||
spawner.name = "PearlSpawner"
|
||||
spawner.set_script(spawner_script)
|
||||
add_child(spawner)
|
||||
if is_instance_valid(_my_dolphin):
|
||||
spawner.player = spawner.get_path_to(_my_dolphin)
|
||||
|
||||
|
||||
func _connect_dolphin_signals(dolphin: CharacterBody3D) -> void:
|
||||
if dolphin.has_signal("block_break_requested"):
|
||||
dolphin.block_break_requested.connect(_on_block_break)
|
||||
@@ -167,6 +190,8 @@ func _connect_dolphin_signals(dolphin: CharacterBody3D) -> void:
|
||||
dolphin.echolocation_triggered.connect(_on_echolocation)
|
||||
if dolphin.has_signal("hotbar_scroll"):
|
||||
dolphin.hotbar_scroll.connect(inventory.scroll_hotbar)
|
||||
if dolphin.has_signal("use_consumable_requested"):
|
||||
dolphin.use_consumable_requested.connect(_on_use_consumable)
|
||||
|
||||
|
||||
func _on_net_peer_connected(peer_id: int) -> void:
|
||||
@@ -208,10 +233,11 @@ func _on_block_break(hit_position: Vector3, _normal: Vector3) -> void:
|
||||
broken_id = world.break_block(hit_position)
|
||||
if broken_id > 0:
|
||||
inventory.add_item(broken_id, 1)
|
||||
AudioManager.play_bubble_sfx(hit_position)
|
||||
AudioManager.play_block_break(hit_position)
|
||||
_award_break_xp(broken_id, hit_position)
|
||||
else:
|
||||
_world_sync.server_break_block(hit_position)
|
||||
AudioManager.play_bubble_sfx(hit_position)
|
||||
AudioManager.play_block_break(hit_position)
|
||||
|
||||
# Block break particle burst
|
||||
var bbp_script := load("res://scripts/dolphin/BlockBreakParticles.gd")
|
||||
@@ -224,6 +250,35 @@ func _on_block_break(hit_position: Vector3, _normal: Vector3) -> void:
|
||||
burst.emit_burst(hit_position, broken_color)
|
||||
|
||||
|
||||
func _award_break_xp(block_id: int, hit_position: Vector3) -> void:
|
||||
var pp: Node = get_node_or_null("/root/PlayerProgress")
|
||||
if pp == null:
|
||||
return
|
||||
var base_gain: int = pp.XP_BREAK_BY_BLOCK.get(block_id, pp.XP_BREAK_DEFAULT)
|
||||
var combo: Node = get_node_or_null("/root/ComboTracker")
|
||||
var mult: float = 1.0
|
||||
if combo != null:
|
||||
mult = combo.bump()
|
||||
var gain: int = int(round(base_gain * mult))
|
||||
pp.award(gain, "bloc", hit_position)
|
||||
_spawn_xp_popup(gain, hit_position)
|
||||
var qm: Node = get_node_or_null("/root/QuestManager")
|
||||
if qm != null:
|
||||
qm.note_block_break(block_id)
|
||||
var am: Node = get_node_or_null("/root/AchievementManager")
|
||||
if am != null:
|
||||
am.note_block_break(block_id)
|
||||
|
||||
|
||||
func _spawn_xp_popup(amount: int, world_pos: Vector3) -> void:
|
||||
if amount <= 0:
|
||||
return
|
||||
var popup_script: Script = load("res://scripts/progression/XpPopup.gd")
|
||||
if popup_script == null:
|
||||
return
|
||||
popup_script.spawn(self, "+%d XP" % amount, world_pos + Vector3(0, 0.8, 0))
|
||||
|
||||
|
||||
func _on_block_place(hit_position: Vector3, normal: Vector3) -> void:
|
||||
var selected: Variant = inventory.get_selected_item()
|
||||
if selected == null:
|
||||
@@ -249,8 +304,48 @@ func _on_block_place(hit_position: Vector3, normal: Vector3) -> void:
|
||||
inventory.remove_item_from_slot(inventory.selected_hotbar, 1)
|
||||
|
||||
|
||||
const CONSUMABLE_EFFECTS: Dictionary = {
|
||||
102: {"oxygen": 40.0, "hp": 0.0, "hunger": 0.0, "label": "+40 O₂", "color": Color(0.3, 0.9, 1.0)},
|
||||
103: {"oxygen": 0.0, "hp": 5.0, "hunger": 30.0, "label": "+30 🍴", "color": Color(0.6, 1.0, 0.4)},
|
||||
106: {"oxygen": 0.0, "hp": 50.0, "hunger": 0.0, "label": "+50 ❤", "color": Color(1.0, 0.5, 0.5)},
|
||||
}
|
||||
|
||||
|
||||
func _on_use_consumable() -> void:
|
||||
if not is_instance_valid(_my_dolphin):
|
||||
return
|
||||
var slot: Variant = inventory.get_selected_item()
|
||||
if slot == null:
|
||||
return
|
||||
var item_id: int = slot["item_id"]
|
||||
if not CONSUMABLE_EFFECTS.has(item_id):
|
||||
return
|
||||
var effect: Dictionary = CONSUMABLE_EFFECTS[item_id]
|
||||
if effect["oxygen"] > 0.0 and _my_dolphin.has_method("refill_oxygen"):
|
||||
_my_dolphin.refill_oxygen(effect["oxygen"])
|
||||
if effect["hp"] > 0.0 and _my_dolphin.has_method("heal"):
|
||||
_my_dolphin.heal(effect["hp"])
|
||||
if effect["hunger"] > 0.0 and _my_dolphin.has_method("feed"):
|
||||
_my_dolphin.feed(effect["hunger"])
|
||||
inventory.remove_item_from_slot(inventory.selected_hotbar, 1)
|
||||
AudioManager.play_bubble_sfx(_my_dolphin.global_position)
|
||||
_spawn_consumable_popup(effect["label"], effect["color"])
|
||||
|
||||
|
||||
func _spawn_consumable_popup(label: String, color: Color) -> void:
|
||||
if not is_instance_valid(_my_dolphin):
|
||||
return
|
||||
var popup_script: Script = load("res://scripts/progression/XpPopup.gd")
|
||||
if popup_script == null:
|
||||
return
|
||||
popup_script.spawn(self, label, _my_dolphin.global_position + Vector3(0, 1.4, 0), color)
|
||||
|
||||
|
||||
func _on_echolocation(position: Vector3, _radius: float) -> void:
|
||||
AudioManager.play_bubble_sfx(position)
|
||||
# Echo ping is now fired directly from DolphinController; keep achievement hook
|
||||
var am: Node = get_node_or_null("/root/AchievementManager")
|
||||
if am != null:
|
||||
am.note_echolocation()
|
||||
|
||||
|
||||
# Called by WorldSyncComponent when server confirms a block was broken by this client
|
||||
|
||||
@@ -3,6 +3,8 @@ extends Node
|
||||
var music_player: AudioStreamPlayer
|
||||
var ambient_loop: AudioStreamPlayer
|
||||
var whale_player: AudioStreamPlayer
|
||||
var swim_loop_player: AudioStreamPlayer
|
||||
var boost_player: AudioStreamPlayer
|
||||
|
||||
var _whale_timer: Timer
|
||||
var _music_tween: Tween
|
||||
@@ -10,6 +12,9 @@ var _music_tween: Tween
|
||||
const MUSIC_PATH := "res://audio/music/"
|
||||
const SFX_PATH := "res://audio/sfx/"
|
||||
|
||||
# Generated stream cache (AudioStreamGenerator-based placeholders)
|
||||
var _gen_streams: Dictionary = {}
|
||||
|
||||
func _ready() -> void:
|
||||
music_player = AudioStreamPlayer.new()
|
||||
music_player.bus = "Music"
|
||||
@@ -26,6 +31,18 @@ func _ready() -> void:
|
||||
whale_player.volume_db = -15.0
|
||||
add_child(whale_player)
|
||||
|
||||
# Swim loop player (continuous while moving)
|
||||
swim_loop_player = AudioStreamPlayer.new()
|
||||
swim_loop_player.bus = "SFX"
|
||||
swim_loop_player.volume_db = -18.0
|
||||
add_child(swim_loop_player)
|
||||
|
||||
# Boost player (one-shot on boost start)
|
||||
boost_player = AudioStreamPlayer.new()
|
||||
boost_player.bus = "SFX"
|
||||
boost_player.volume_db = -10.0
|
||||
add_child(boost_player)
|
||||
|
||||
_whale_timer = Timer.new()
|
||||
_whale_timer.one_shot = true
|
||||
_whale_timer.timeout.connect(_on_whale_timer)
|
||||
@@ -33,6 +50,18 @@ func _ready() -> void:
|
||||
_schedule_next_whale()
|
||||
|
||||
|
||||
# Returns (or creates) a silent AudioStreamGenerator for placeholder sounds.
|
||||
func _get_silent_stream(duration_sec: float = 0.5) -> AudioStreamGenerator:
|
||||
var key := "silent_%.1f" % duration_sec
|
||||
if _gen_streams.has(key):
|
||||
return _gen_streams[key]
|
||||
var gen := AudioStreamGenerator.new()
|
||||
gen.mix_rate = 22050.0
|
||||
gen.buffer_length = duration_sec
|
||||
_gen_streams[key] = gen
|
||||
return gen
|
||||
|
||||
|
||||
func play_music(track_name: String) -> void:
|
||||
var path := MUSIC_PATH + track_name
|
||||
if not ResourceLoader.exists(path):
|
||||
@@ -56,6 +85,8 @@ func play_music(track_name: String) -> void:
|
||||
func play_ambient_loop(loop_name: String) -> void:
|
||||
var path := SFX_PATH + loop_name
|
||||
if not ResourceLoader.exists(path):
|
||||
# Fall back to silent generator so the slot is occupied
|
||||
ambient_loop.stream = _get_silent_stream(2.0)
|
||||
return
|
||||
|
||||
var stream: AudioStream = load(path)
|
||||
@@ -84,9 +115,6 @@ func play_whale_call_random() -> void:
|
||||
|
||||
func play_bubble_sfx(position: Vector3) -> void:
|
||||
var path := SFX_PATH + "bubbles.ogg"
|
||||
if not ResourceLoader.exists(path):
|
||||
return
|
||||
|
||||
var player := AudioStreamPlayer3D.new()
|
||||
player.bus = "SFX"
|
||||
player.volume_db = -8.0
|
||||
@@ -95,16 +123,178 @@ func play_bubble_sfx(position: Vector3) -> void:
|
||||
add_child(player)
|
||||
player.global_position = position
|
||||
|
||||
var stream: AudioStream = load(path)
|
||||
if stream == null:
|
||||
player.queue_free()
|
||||
return
|
||||
if ResourceLoader.exists(path):
|
||||
var stream: AudioStream = load(path)
|
||||
if stream != null:
|
||||
player.stream = stream
|
||||
player.play()
|
||||
player.finished.connect(player.queue_free)
|
||||
return
|
||||
|
||||
player.stream = stream
|
||||
# Placeholder: silent generator
|
||||
player.stream = _get_silent_stream(0.3)
|
||||
player.play()
|
||||
player.finished.connect(player.queue_free)
|
||||
|
||||
|
||||
# --- New: swim loop ---
|
||||
func play_swim_loop() -> void:
|
||||
if swim_loop_player.playing:
|
||||
return
|
||||
var path := SFX_PATH + "swim_loop.ogg"
|
||||
if ResourceLoader.exists(path):
|
||||
var s: AudioStream = load(path)
|
||||
if s != null:
|
||||
swim_loop_player.stream = s
|
||||
swim_loop_player.play()
|
||||
return
|
||||
# Silent placeholder keeps the architecture wired
|
||||
swim_loop_player.stream = _get_silent_stream(1.0)
|
||||
swim_loop_player.play()
|
||||
|
||||
|
||||
func stop_swim_loop() -> void:
|
||||
if swim_loop_player.playing:
|
||||
swim_loop_player.stop()
|
||||
|
||||
|
||||
# --- New: boost sound ---
|
||||
func play_boost() -> void:
|
||||
var path := SFX_PATH + "boost.ogg"
|
||||
if ResourceLoader.exists(path):
|
||||
var s: AudioStream = load(path)
|
||||
if s != null:
|
||||
boost_player.stream = s
|
||||
boost_player.play()
|
||||
return
|
||||
boost_player.stream = _get_silent_stream(0.4)
|
||||
boost_player.play()
|
||||
|
||||
|
||||
# --- New: block break ---
|
||||
func play_block_break(position: Vector3) -> void:
|
||||
var path := SFX_PATH + "block_break.ogg"
|
||||
var player := AudioStreamPlayer3D.new()
|
||||
player.bus = "SFX"
|
||||
player.volume_db = -6.0
|
||||
player.max_distance = 15.0
|
||||
add_child(player)
|
||||
player.global_position = position
|
||||
if ResourceLoader.exists(path):
|
||||
var s: AudioStream = load(path)
|
||||
if s != null:
|
||||
player.stream = s
|
||||
player.play()
|
||||
player.finished.connect(player.queue_free)
|
||||
return
|
||||
player.stream = _get_silent_stream(0.3)
|
||||
player.play()
|
||||
player.finished.connect(player.queue_free)
|
||||
|
||||
|
||||
# --- New: echolocation ping ---
|
||||
func play_echo_ping(position: Vector3) -> void:
|
||||
var path := SFX_PATH + "echo_ping.ogg"
|
||||
var player := AudioStreamPlayer3D.new()
|
||||
player.bus = "SFX"
|
||||
player.volume_db = -4.0
|
||||
player.max_distance = 40.0
|
||||
add_child(player)
|
||||
player.global_position = position
|
||||
if ResourceLoader.exists(path):
|
||||
var s: AudioStream = load(path)
|
||||
if s != null:
|
||||
player.stream = s
|
||||
player.play()
|
||||
player.finished.connect(player.queue_free)
|
||||
return
|
||||
player.stream = _get_silent_stream(0.5)
|
||||
player.play()
|
||||
player.finished.connect(player.queue_free)
|
||||
|
||||
|
||||
# --- New: pearl collect ---
|
||||
func play_pearl_collect(position: Vector3) -> void:
|
||||
var path := SFX_PATH + "pearl_collect.ogg"
|
||||
var player := AudioStreamPlayer3D.new()
|
||||
player.bus = "SFX"
|
||||
player.volume_db = -5.0
|
||||
player.max_distance = 20.0
|
||||
add_child(player)
|
||||
player.global_position = position
|
||||
if ResourceLoader.exists(path):
|
||||
var s: AudioStream = load(path)
|
||||
if s != null:
|
||||
player.stream = s
|
||||
player.play()
|
||||
player.finished.connect(player.queue_free)
|
||||
return
|
||||
player.stream = _get_silent_stream(0.4)
|
||||
player.play()
|
||||
player.finished.connect(player.queue_free)
|
||||
|
||||
|
||||
# --- New: death / respawn ---
|
||||
func play_death() -> void:
|
||||
var path := SFX_PATH + "death.ogg"
|
||||
if ResourceLoader.exists(path):
|
||||
var s: AudioStream = load(path)
|
||||
if s != null:
|
||||
var p := AudioStreamPlayer.new()
|
||||
p.bus = "SFX"
|
||||
p.volume_db = -5.0
|
||||
add_child(p)
|
||||
p.stream = s
|
||||
p.play()
|
||||
p.finished.connect(p.queue_free)
|
||||
return
|
||||
# silent fallback — architecture is wired
|
||||
var p2 := AudioStreamPlayer.new()
|
||||
p2.bus = "SFX"
|
||||
p2.stream = _get_silent_stream(0.6)
|
||||
add_child(p2)
|
||||
p2.play()
|
||||
p2.finished.connect(p2.queue_free)
|
||||
|
||||
|
||||
func play_respawn() -> void:
|
||||
var path := SFX_PATH + "respawn.ogg"
|
||||
if ResourceLoader.exists(path):
|
||||
var s: AudioStream = load(path)
|
||||
if s != null:
|
||||
var p := AudioStreamPlayer.new()
|
||||
p.bus = "SFX"
|
||||
p.volume_db = -6.0
|
||||
add_child(p)
|
||||
p.stream = s
|
||||
p.play()
|
||||
p.finished.connect(p.queue_free)
|
||||
return
|
||||
var p2 := AudioStreamPlayer.new()
|
||||
p2.bus = "SFX"
|
||||
p2.stream = _get_silent_stream(0.5)
|
||||
add_child(p2)
|
||||
p2.play()
|
||||
p2.finished.connect(p2.queue_free)
|
||||
|
||||
|
||||
# --- New: ambient deep sea (low drone, played on top of underwater_ambient) ---
|
||||
func play_deep_sea_ambient() -> void:
|
||||
var path := SFX_PATH + "deep_sea_ambient.ogg"
|
||||
var player := AudioStreamPlayer.new()
|
||||
player.bus = "SFX"
|
||||
player.volume_db = -20.0
|
||||
add_child(player)
|
||||
if ResourceLoader.exists(path):
|
||||
var s: AudioStream = load(path)
|
||||
if s != null:
|
||||
player.stream = s
|
||||
player.play()
|
||||
return
|
||||
player.stream = _get_silent_stream(2.0)
|
||||
player.play()
|
||||
|
||||
|
||||
func set_music_volume(db: float) -> void:
|
||||
music_player.volume_db = db
|
||||
|
||||
|
||||
@@ -1,29 +1,52 @@
|
||||
extends WorldEnvironment
|
||||
|
||||
const SHALLOW_Y: float = 55.0
|
||||
const ABYSS_Y: float = -40.0
|
||||
|
||||
var _env: Environment = null
|
||||
var _player: Node3D = null
|
||||
var _next_player_scan: float = 0.0
|
||||
|
||||
# Reference palette at shallow depth (near surface)
|
||||
var _shallow_bg: Color = Color(0.03, 0.12, 0.22)
|
||||
var _shallow_ambient: Color = Color(0.2, 0.4, 0.6)
|
||||
var _shallow_fog: Color = Color(0.1, 0.3, 0.5)
|
||||
var _shallow_vol_fog: Color = Color(0.2, 0.5, 0.7)
|
||||
var _shallow_fog_density: float = 0.02
|
||||
var _shallow_ambient_energy: float = 0.4
|
||||
|
||||
# Palette at abyssal depth
|
||||
var _abyss_bg: Color = Color(0.01, 0.02, 0.05)
|
||||
var _abyss_ambient: Color = Color(0.04, 0.07, 0.14)
|
||||
var _abyss_fog: Color = Color(0.02, 0.04, 0.08)
|
||||
var _abyss_vol_fog: Color = Color(0.05, 0.10, 0.18)
|
||||
var _abyss_fog_density: float = 0.055
|
||||
var _abyss_ambient_energy: float = 0.12
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
var env := Environment.new()
|
||||
_env = Environment.new()
|
||||
|
||||
env.background_mode = Environment.BG_COLOR
|
||||
env.background_color = Color(0.03, 0.12, 0.22)
|
||||
_env.background_mode = Environment.BG_COLOR
|
||||
_env.background_color = _shallow_bg
|
||||
|
||||
env.ambient_light_source = Environment.AMBIENT_SOURCE_COLOR
|
||||
env.ambient_light_color = Color(0.2, 0.4, 0.6)
|
||||
env.ambient_light_energy = 0.4
|
||||
_env.ambient_light_source = Environment.AMBIENT_SOURCE_COLOR
|
||||
_env.ambient_light_color = _shallow_ambient
|
||||
_env.ambient_light_energy = _shallow_ambient_energy
|
||||
|
||||
env.fog_enabled = true
|
||||
env.fog_light_color = Color(0.1, 0.3, 0.5)
|
||||
env.fog_density = 0.02
|
||||
env.fog_aerial_perspective = 0.3
|
||||
_env.fog_enabled = true
|
||||
_env.fog_light_color = _shallow_fog
|
||||
_env.fog_density = _shallow_fog_density
|
||||
_env.fog_aerial_perspective = 0.3
|
||||
|
||||
env.volumetric_fog_enabled = true
|
||||
env.volumetric_fog_density = 0.05
|
||||
env.volumetric_fog_albedo = Color(0.2, 0.5, 0.7)
|
||||
env.volumetric_fog_emission = Color(0.02, 0.06, 0.1)
|
||||
env.volumetric_fog_emission_energy = 0.1
|
||||
env.volumetric_fog_length = 64.0
|
||||
env.volumetric_fog_detail_spread = 2.0
|
||||
_env.volumetric_fog_enabled = true
|
||||
_env.volumetric_fog_density = 0.05
|
||||
_env.volumetric_fog_albedo = _shallow_vol_fog
|
||||
_env.volumetric_fog_emission = Color(0.02, 0.06, 0.1)
|
||||
_env.volumetric_fog_emission_energy = 0.1
|
||||
_env.volumetric_fog_length = 64.0
|
||||
_env.volumetric_fog_detail_spread = 2.0
|
||||
|
||||
# Attach custom fog shader
|
||||
var fog_mat := FogMaterial.new()
|
||||
var fog_shader := load("res://shaders/underwater_fog.gdshader")
|
||||
if fog_shader:
|
||||
@@ -34,22 +57,44 @@ func _ready() -> void:
|
||||
fog_volume.material = fog_mat
|
||||
add_child(fog_volume)
|
||||
|
||||
env.glow_enabled = true
|
||||
env.glow_intensity = 0.5
|
||||
env.glow_bloom = 0.1
|
||||
env.glow_blend_mode = Environment.GLOW_BLEND_MODE_SOFTLIGHT
|
||||
_env.glow_enabled = true
|
||||
_env.glow_intensity = 0.5
|
||||
_env.glow_bloom = 0.1
|
||||
_env.glow_blend_mode = Environment.GLOW_BLEND_MODE_SOFTLIGHT
|
||||
|
||||
env.ssao_enabled = true
|
||||
env.ssao_radius = 1.0
|
||||
env.ssao_intensity = 1.0
|
||||
env.ssao_power = 1.5
|
||||
env.ssao_detail = 0.5
|
||||
_env.ssao_enabled = true
|
||||
_env.ssao_radius = 1.0
|
||||
_env.ssao_intensity = 1.0
|
||||
_env.ssao_power = 1.5
|
||||
_env.ssao_detail = 0.5
|
||||
|
||||
env.tone_mapper = Environment.TONE_MAPPER_FILMIC
|
||||
env.exposure = 1.0
|
||||
_env.tone_mapper = Environment.TONE_MAPPER_FILMIC
|
||||
_env.exposure = 1.0
|
||||
|
||||
env.adjustment_enabled = true
|
||||
env.adjustment_saturation = 1.1
|
||||
env.adjustment_color_correction = null
|
||||
_env.adjustment_enabled = true
|
||||
_env.adjustment_saturation = 1.1
|
||||
_env.adjustment_color_correction = null
|
||||
|
||||
environment = env
|
||||
environment = _env
|
||||
|
||||
|
||||
func _process(delta: float) -> void:
|
||||
_next_player_scan -= delta
|
||||
if not is_instance_valid(_player) and _next_player_scan <= 0.0:
|
||||
_next_player_scan = 1.0
|
||||
_player = get_tree().get_first_node_in_group("player") as Node3D
|
||||
|
||||
if not is_instance_valid(_player) or _env == null:
|
||||
return
|
||||
|
||||
var y: float = _player.global_position.y
|
||||
var t: float = clampf(inverse_lerp(SHALLOW_Y, ABYSS_Y, y), 0.0, 1.0)
|
||||
# ease curve: more rapid darkening in mid-depth
|
||||
var t_curved: float = smoothstep(0.0, 1.0, t)
|
||||
|
||||
_env.background_color = _shallow_bg.lerp(_abyss_bg, t_curved)
|
||||
_env.ambient_light_color = _shallow_ambient.lerp(_abyss_ambient, t_curved)
|
||||
_env.ambient_light_energy = lerpf(_shallow_ambient_energy, _abyss_ambient_energy, t_curved)
|
||||
_env.fog_light_color = _shallow_fog.lerp(_abyss_fog, t_curved)
|
||||
_env.fog_density = lerpf(_shallow_fog_density, _abyss_fog_density, t_curved)
|
||||
_env.volumetric_fog_albedo = _shallow_vol_fog.lerp(_abyss_vol_fog, t_curved)
|
||||
|
||||
@@ -18,6 +18,14 @@ signal hotbar_scroll(direction: int)
|
||||
signal echolocation_triggered(position: Vector3, radius: float)
|
||||
signal stats_changed(oxygen: float, health: float, hunger: float)
|
||||
signal player_died()
|
||||
signal use_consumable_requested()
|
||||
signal boost_changed(energy: float, max_energy: float, is_active: bool)
|
||||
|
||||
# --- Boost parameters ---
|
||||
@export var boost_max_energy: float = 100.0
|
||||
@export var boost_drain_rate: float = 40.0 # energy/s while boosting
|
||||
@export var boost_regen_rate: float = 20.0 # energy/s when not boosting
|
||||
@export var boost_min_energy_to_start: float = 15.0 # minimum to engage boost
|
||||
|
||||
# --- Internal State ---
|
||||
var oxygen: float
|
||||
@@ -25,6 +33,7 @@ var health: float
|
||||
var hunger: float
|
||||
var is_boosting: bool = false
|
||||
var is_echolocating: bool = false
|
||||
var boost_energy: float = 100.0
|
||||
|
||||
var _yaw: float = 0.0
|
||||
var _pitch: float = 0.0
|
||||
@@ -39,12 +48,14 @@ var _is_dead: bool = false
|
||||
@onready var bubble_trail: Node = $BubbleEmitterPoint/BubbleTrail
|
||||
|
||||
var _turn_input: float = 0.0
|
||||
var _was_boosting: bool = false
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
oxygen = max_oxygen
|
||||
health = max_health
|
||||
hunger = max_hunger
|
||||
boost_energy = boost_max_energy
|
||||
Input.set_mouse_mode(Input.MOUSE_MODE_CAPTURED)
|
||||
_setup_input_actions()
|
||||
_emit_stats()
|
||||
@@ -61,6 +72,7 @@ func _setup_input_actions() -> void:
|
||||
"boost": KEY_CTRL,
|
||||
"echolocate": KEY_E,
|
||||
"toggle_inventory": KEY_TAB,
|
||||
"use_consumable": KEY_F,
|
||||
}
|
||||
for action_name: String in actions:
|
||||
if not InputMap.has_action(action_name):
|
||||
@@ -103,6 +115,8 @@ func _input(event: InputEvent) -> void:
|
||||
_do_raycast_place()
|
||||
if event.is_action_pressed("echolocate"):
|
||||
_trigger_echolocation()
|
||||
if event.is_action_pressed("use_consumable"):
|
||||
use_consumable_requested.emit()
|
||||
|
||||
|
||||
func _physics_process(delta: float) -> void:
|
||||
@@ -120,7 +134,25 @@ func _physics_process(delta: float) -> void:
|
||||
|
||||
|
||||
func _update_movement(delta: float) -> void:
|
||||
is_boosting = Input.is_action_pressed("boost")
|
||||
var wants_boost: bool = Input.is_action_pressed("boost")
|
||||
# Only boost if we have energy and enough to start
|
||||
if wants_boost and boost_energy >= boost_min_energy_to_start:
|
||||
is_boosting = true
|
||||
elif wants_boost and is_boosting and boost_energy > 0.0:
|
||||
is_boosting = true # keep boosting until empty
|
||||
else:
|
||||
is_boosting = false
|
||||
|
||||
# Update boost energy
|
||||
if is_boosting:
|
||||
boost_energy = max(0.0, boost_energy - boost_drain_rate * delta)
|
||||
if boost_energy <= 0.0:
|
||||
is_boosting = false
|
||||
else:
|
||||
boost_energy = min(boost_max_energy, boost_energy + boost_regen_rate * delta)
|
||||
|
||||
boost_changed.emit(boost_energy, boost_max_energy, is_boosting)
|
||||
|
||||
var speed: float = swim_speed * (boost_multiplier if is_boosting else 1.0)
|
||||
|
||||
# forward/right using camera pitch for intuitive swim direction
|
||||
@@ -157,6 +189,21 @@ func _update_movement(delta: float) -> void:
|
||||
if bubble_trail:
|
||||
bubble_trail.set_intensity(speed_factor)
|
||||
|
||||
# Audio: swim loop
|
||||
var am: Node = get_node_or_null("/root/AudioManager")
|
||||
if am != null:
|
||||
if current_speed > 0.5:
|
||||
if am.has_method("play_swim_loop"):
|
||||
am.call("play_swim_loop")
|
||||
else:
|
||||
if am.has_method("stop_swim_loop"):
|
||||
am.call("stop_swim_loop")
|
||||
# Boost start sound
|
||||
if is_boosting and not _was_boosting:
|
||||
if am.has_method("play_boost"):
|
||||
am.call("play_boost")
|
||||
_was_boosting = is_boosting
|
||||
|
||||
|
||||
func _update_stats(delta: float) -> void:
|
||||
var changed: bool = false
|
||||
@@ -193,6 +240,9 @@ func _emit_stats() -> void:
|
||||
func _die() -> void:
|
||||
_is_dead = true
|
||||
player_died.emit()
|
||||
var am: Node = get_node_or_null("/root/AudioManager")
|
||||
if am != null and am.has_method("play_death"):
|
||||
am.call("play_death")
|
||||
# Respawn after short delay
|
||||
await get_tree().create_timer(2.0).timeout
|
||||
_respawn()
|
||||
@@ -206,6 +256,9 @@ func _respawn() -> void:
|
||||
hunger = max_hunger
|
||||
_is_dead = false
|
||||
_emit_stats()
|
||||
var am: Node = get_node_or_null("/root/AudioManager")
|
||||
if am != null and am.has_method("play_respawn"):
|
||||
am.call("play_respawn")
|
||||
|
||||
|
||||
func _animate_body() -> void:
|
||||
@@ -264,6 +317,25 @@ func take_damage(amount: float) -> void:
|
||||
player_died.emit()
|
||||
|
||||
|
||||
func apply_knockback(impulse: Vector3) -> void:
|
||||
velocity += impulse
|
||||
|
||||
|
||||
func heal(amount: float) -> void:
|
||||
health = min(max_health, health + amount)
|
||||
_emit_stats()
|
||||
|
||||
|
||||
func feed(amount: float) -> void:
|
||||
hunger = min(max_hunger, hunger + amount)
|
||||
_emit_stats()
|
||||
|
||||
|
||||
func refill_oxygen(amount: float) -> void:
|
||||
oxygen = min(max_oxygen, oxygen + amount)
|
||||
_emit_stats()
|
||||
|
||||
|
||||
func _trigger_echolocation() -> void:
|
||||
if is_echolocating:
|
||||
return
|
||||
@@ -271,5 +343,8 @@ func _trigger_echolocation() -> void:
|
||||
echolocation_triggered.emit(global_position, 20.0)
|
||||
if is_instance_valid(_echo_pulse):
|
||||
_echo_pulse.trigger()
|
||||
var am: Node = get_node_or_null("/root/AudioManager")
|
||||
if am != null and am.has_method("play_echo_ping"):
|
||||
am.call("play_echo_ping", global_position)
|
||||
await get_tree().create_timer(1.2).timeout
|
||||
is_echolocating = false
|
||||
|
||||
@@ -1,14 +1,514 @@
|
||||
extends CanvasLayer
|
||||
|
||||
const WATER_SURFACE_Y: float = 60.0
|
||||
|
||||
@onready var _oxygen_bar: ProgressBar = %OxygenBar
|
||||
@onready var _health_bar: ProgressBar = %HealthBar
|
||||
@onready var _hunger_bar: ProgressBar = %HungerBar
|
||||
|
||||
var _dolphin: Node3D = null
|
||||
var _world_seed: int = 12345
|
||||
|
||||
var _info_panel: PanelContainer = null
|
||||
var _depth_label: Label = null
|
||||
var _biome_label: Label = null
|
||||
var _compass_label: Label = null
|
||||
|
||||
var _xp_panel: PanelContainer = null
|
||||
var _xp_bar: ProgressBar = null
|
||||
var _xp_label: Label = null
|
||||
var _level_banner: Label = null
|
||||
var _level_banner_timer: float = 0.0
|
||||
|
||||
var _quest_panel: PanelContainer = null
|
||||
var _quest_list: VBoxContainer = null
|
||||
var _quest_complete_banner: Label = null
|
||||
var _quest_banner_timer: float = 0.0
|
||||
|
||||
var _toast_container: VBoxContainer = null
|
||||
var _toasts: Array = [] # each: {"node": PanelContainer, "timer": float}
|
||||
|
||||
var _biome_cache_pos: Vector3 = Vector3(99999, 99999, 99999)
|
||||
var _biome_cached_name: String = ""
|
||||
|
||||
# Boost bar
|
||||
var _boost_panel: PanelContainer = null
|
||||
var _boost_bar: ProgressBar = null
|
||||
var _boost_label: Label = null
|
||||
|
||||
# Speed lines overlay
|
||||
var _speed_lines: Control = null
|
||||
var _speed_line_data: Array = []
|
||||
const SPEED_LINE_COUNT: int = 16
|
||||
|
||||
# Mini-map
|
||||
var _mini_map: Control = null
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
_style_bar(_oxygen_bar, Color(0.31, 0.76, 0.97))
|
||||
_style_bar(_health_bar, Color(0.91, 0.30, 0.24))
|
||||
_style_bar(_hunger_bar, Color(0.95, 0.61, 0.07))
|
||||
_build_info_panel()
|
||||
_build_xp_panel()
|
||||
_build_level_banner()
|
||||
_build_quest_panel()
|
||||
if Engine.has_singleton("PlayerProgress") or has_node("/root/PlayerProgress"):
|
||||
var pp: Node = get_node_or_null("/root/PlayerProgress")
|
||||
if pp != null:
|
||||
pp.xp_changed.connect(_on_xp_changed)
|
||||
pp.level_up.connect(_on_level_up)
|
||||
_on_xp_changed(pp.current_xp, pp.xp_for_next(), pp.level)
|
||||
var qm: Node = get_node_or_null("/root/QuestManager")
|
||||
if qm != null:
|
||||
qm.quests_updated.connect(_on_quests_updated)
|
||||
qm.quest_completed.connect(_on_quest_completed)
|
||||
_on_quests_updated()
|
||||
_build_toast_container()
|
||||
_build_consumable_hint()
|
||||
_build_combo_panel()
|
||||
_build_boost_bar()
|
||||
_build_speed_lines()
|
||||
_build_mini_map()
|
||||
var am: Node = get_node_or_null("/root/AchievementManager")
|
||||
if am != null:
|
||||
am.achievement_unlocked.connect(_on_achievement_unlocked)
|
||||
var combo: Node = get_node_or_null("/root/ComboTracker")
|
||||
if combo != null:
|
||||
combo.combo_changed.connect(_on_combo_changed)
|
||||
combo.combo_broken.connect(_on_combo_broken)
|
||||
|
||||
|
||||
func _build_info_panel() -> void:
|
||||
_info_panel = PanelContainer.new()
|
||||
_info_panel.anchor_left = 1.0
|
||||
_info_panel.anchor_right = 1.0
|
||||
_info_panel.anchor_top = 0.0
|
||||
_info_panel.anchor_bottom = 0.0
|
||||
_info_panel.offset_left = -220.0
|
||||
_info_panel.offset_top = 16.0
|
||||
_info_panel.offset_right = -16.0
|
||||
_info_panel.offset_bottom = 112.0
|
||||
|
||||
var style := StyleBoxFlat.new()
|
||||
style.bg_color = Color(0.05, 0.05, 0.05, 0.7)
|
||||
style.corner_radius_top_left = 10
|
||||
style.corner_radius_top_right = 10
|
||||
style.corner_radius_bottom_left = 10
|
||||
style.corner_radius_bottom_right = 10
|
||||
_info_panel.add_theme_stylebox_override("panel", style)
|
||||
|
||||
var vbox := VBoxContainer.new()
|
||||
vbox.add_theme_constant_override("separation", 4)
|
||||
_info_panel.add_child(vbox)
|
||||
|
||||
_compass_label = Label.new()
|
||||
_compass_label.text = "⬆ N"
|
||||
_compass_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||||
_compass_label.add_theme_font_size_override("font_size", 20)
|
||||
_compass_label.add_theme_color_override("font_color", Color(0.85, 0.95, 1.0))
|
||||
vbox.add_child(_compass_label)
|
||||
|
||||
_depth_label = Label.new()
|
||||
_depth_label.text = "Prof. 0 m"
|
||||
_depth_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||||
_depth_label.add_theme_font_size_override("font_size", 14)
|
||||
_depth_label.add_theme_color_override("font_color", Color(0.70, 0.88, 1.0))
|
||||
vbox.add_child(_depth_label)
|
||||
|
||||
_biome_label = Label.new()
|
||||
_biome_label.text = "Biome : —"
|
||||
_biome_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||||
_biome_label.add_theme_font_size_override("font_size", 14)
|
||||
_biome_label.add_theme_color_override("font_color", Color(0.85, 0.95, 0.75))
|
||||
vbox.add_child(_biome_label)
|
||||
|
||||
add_child(_info_panel)
|
||||
|
||||
|
||||
func _build_xp_panel() -> void:
|
||||
_xp_panel = PanelContainer.new()
|
||||
_xp_panel.anchor_left = 0.5
|
||||
_xp_panel.anchor_right = 0.5
|
||||
_xp_panel.anchor_top = 1.0
|
||||
_xp_panel.anchor_bottom = 1.0
|
||||
_xp_panel.offset_left = -180.0
|
||||
_xp_panel.offset_right = 180.0
|
||||
_xp_panel.offset_top = -78.0
|
||||
_xp_panel.offset_bottom = -68.0
|
||||
|
||||
var style := StyleBoxFlat.new()
|
||||
style.bg_color = Color(0.03, 0.05, 0.09, 0.75)
|
||||
style.set_corner_radius_all(6)
|
||||
style.border_color = Color(1.0, 0.84, 0.2, 0.55)
|
||||
style.set_border_width_all(1)
|
||||
_xp_panel.add_theme_stylebox_override("panel", style)
|
||||
|
||||
var vbox := VBoxContainer.new()
|
||||
vbox.add_theme_constant_override("separation", 0)
|
||||
_xp_panel.add_child(vbox)
|
||||
|
||||
_xp_label = Label.new()
|
||||
_xp_label.text = "Niv. 1 — 0 / 50"
|
||||
_xp_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||||
_xp_label.add_theme_font_size_override("font_size", 11)
|
||||
_xp_label.add_theme_color_override("font_color", Color(1.0, 0.9, 0.4))
|
||||
vbox.add_child(_xp_label)
|
||||
|
||||
_xp_bar = ProgressBar.new()
|
||||
_xp_bar.custom_minimum_size = Vector2(340, 6)
|
||||
_xp_bar.max_value = 50.0
|
||||
_xp_bar.value = 0.0
|
||||
_xp_bar.show_percentage = false
|
||||
var fill := StyleBoxFlat.new()
|
||||
fill.bg_color = Color(1.0, 0.82, 0.25)
|
||||
fill.set_corner_radius_all(3)
|
||||
_xp_bar.add_theme_stylebox_override("fill", fill)
|
||||
var bg := StyleBoxFlat.new()
|
||||
bg.bg_color = Color(0.08, 0.08, 0.10, 0.8)
|
||||
bg.set_corner_radius_all(3)
|
||||
_xp_bar.add_theme_stylebox_override("background", bg)
|
||||
vbox.add_child(_xp_bar)
|
||||
|
||||
add_child(_xp_panel)
|
||||
|
||||
|
||||
func _build_quest_panel() -> void:
|
||||
_quest_panel = PanelContainer.new()
|
||||
_quest_panel.anchor_left = 1.0
|
||||
_quest_panel.anchor_right = 1.0
|
||||
_quest_panel.anchor_top = 0.0
|
||||
_quest_panel.anchor_bottom = 0.0
|
||||
_quest_panel.offset_left = -260.0
|
||||
_quest_panel.offset_top = 130.0
|
||||
_quest_panel.offset_right = -16.0
|
||||
_quest_panel.offset_bottom = 260.0
|
||||
|
||||
var style := StyleBoxFlat.new()
|
||||
style.bg_color = Color(0.04, 0.08, 0.12, 0.72)
|
||||
style.border_color = Color(0.35, 0.85, 0.95, 0.65)
|
||||
style.set_border_width_all(1)
|
||||
style.set_corner_radius_all(8)
|
||||
_quest_panel.add_theme_stylebox_override("panel", style)
|
||||
|
||||
var vbox := VBoxContainer.new()
|
||||
vbox.add_theme_constant_override("separation", 3)
|
||||
_quest_panel.add_child(vbox)
|
||||
|
||||
var title := Label.new()
|
||||
title.text = "◈ OBJECTIFS"
|
||||
title.add_theme_font_size_override("font_size", 12)
|
||||
title.add_theme_color_override("font_color", Color(0.55, 0.95, 1.0))
|
||||
vbox.add_child(title)
|
||||
|
||||
_quest_list = VBoxContainer.new()
|
||||
_quest_list.add_theme_constant_override("separation", 2)
|
||||
vbox.add_child(_quest_list)
|
||||
|
||||
add_child(_quest_panel)
|
||||
|
||||
|
||||
func _on_quests_updated() -> void:
|
||||
if _quest_list == null:
|
||||
return
|
||||
for c in _quest_list.get_children():
|
||||
c.queue_free()
|
||||
var qm: Node = get_node_or_null("/root/QuestManager")
|
||||
if qm == null:
|
||||
return
|
||||
for q: Dictionary in qm.active:
|
||||
var row := VBoxContainer.new()
|
||||
row.add_theme_constant_override("separation", 0)
|
||||
var desc := Label.new()
|
||||
desc.text = "• %s" % q["desc"]
|
||||
desc.add_theme_font_size_override("font_size", 11)
|
||||
desc.add_theme_color_override("font_color", Color(0.92, 0.96, 1.0))
|
||||
row.add_child(desc)
|
||||
var prog := Label.new()
|
||||
prog.text = " %d/%d +%d XP" % [q["progress"], q["target"], q["reward_xp"]]
|
||||
prog.add_theme_font_size_override("font_size", 10)
|
||||
var completion: float = float(q["progress"]) / float(q["target"])
|
||||
var col: Color = Color(0.6, 0.7, 0.8).lerp(Color(0.4, 1.0, 0.5), completion)
|
||||
prog.add_theme_color_override("font_color", col)
|
||||
row.add_child(prog)
|
||||
_quest_list.add_child(row)
|
||||
|
||||
|
||||
func _on_quest_completed(q: Dictionary) -> void:
|
||||
if _quest_complete_banner == null:
|
||||
_quest_complete_banner = Label.new()
|
||||
_quest_complete_banner.anchor_left = 0.5
|
||||
_quest_complete_banner.anchor_right = 0.5
|
||||
_quest_complete_banner.anchor_top = 0.33
|
||||
_quest_complete_banner.anchor_bottom = 0.33
|
||||
_quest_complete_banner.offset_left = -260.0
|
||||
_quest_complete_banner.offset_right = 260.0
|
||||
_quest_complete_banner.offset_top = -22.0
|
||||
_quest_complete_banner.offset_bottom = 22.0
|
||||
_quest_complete_banner.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||||
_quest_complete_banner.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
|
||||
_quest_complete_banner.add_theme_font_size_override("font_size", 22)
|
||||
_quest_complete_banner.add_theme_color_override("font_color", Color(0.55, 1.0, 0.7))
|
||||
_quest_complete_banner.add_theme_color_override("font_outline_color", Color(0, 0, 0, 0.9))
|
||||
_quest_complete_banner.add_theme_constant_override("outline_size", 5)
|
||||
add_child(_quest_complete_banner)
|
||||
_quest_complete_banner.text = "✓ QUÊTE : %s (+%d XP)" % [q["desc"], q["reward_xp"]]
|
||||
_quest_complete_banner.modulate.a = 1.0
|
||||
_quest_banner_timer = 2.8
|
||||
var am: Node = get_node_or_null("/root/AudioManager")
|
||||
if am != null and is_instance_valid(_dolphin):
|
||||
if am.has_method("play_bubble_sfx"):
|
||||
am.call("play_bubble_sfx", _dolphin.global_position)
|
||||
|
||||
|
||||
var _consumable_hint: Label = null
|
||||
|
||||
var _combo_panel: PanelContainer = null
|
||||
var _combo_label: Label = null
|
||||
var _combo_bar: ProgressBar = null
|
||||
|
||||
|
||||
func _build_combo_panel() -> void:
|
||||
_combo_panel = PanelContainer.new()
|
||||
_combo_panel.anchor_left = 0.5
|
||||
_combo_panel.anchor_right = 0.5
|
||||
_combo_panel.anchor_top = 0.5
|
||||
_combo_panel.anchor_bottom = 0.5
|
||||
_combo_panel.offset_left = 120.0
|
||||
_combo_panel.offset_right = 320.0
|
||||
_combo_panel.offset_top = 30.0
|
||||
_combo_panel.offset_bottom = 80.0
|
||||
|
||||
var style := StyleBoxFlat.new()
|
||||
style.bg_color = Color(0.2, 0.08, 0.05, 0.85)
|
||||
style.border_color = Color(1.0, 0.55, 0.15)
|
||||
style.set_border_width_all(2)
|
||||
style.set_corner_radius_all(8)
|
||||
_combo_panel.add_theme_stylebox_override("panel", style)
|
||||
|
||||
var vbox := VBoxContainer.new()
|
||||
vbox.add_theme_constant_override("separation", 2)
|
||||
_combo_panel.add_child(vbox)
|
||||
|
||||
_combo_label = Label.new()
|
||||
_combo_label.text = "COMBO x1"
|
||||
_combo_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||||
_combo_label.add_theme_font_size_override("font_size", 20)
|
||||
_combo_label.add_theme_color_override("font_color", Color(1.0, 0.85, 0.3))
|
||||
_combo_label.add_theme_color_override("font_outline_color", Color(0, 0, 0, 0.9))
|
||||
_combo_label.add_theme_constant_override("outline_size", 4)
|
||||
vbox.add_child(_combo_label)
|
||||
|
||||
_combo_bar = ProgressBar.new()
|
||||
_combo_bar.max_value = 1.0
|
||||
_combo_bar.value = 0.0
|
||||
_combo_bar.custom_minimum_size = Vector2(180, 4)
|
||||
_combo_bar.show_percentage = false
|
||||
var fill := StyleBoxFlat.new()
|
||||
fill.bg_color = Color(1.0, 0.55, 0.15)
|
||||
fill.set_corner_radius_all(2)
|
||||
_combo_bar.add_theme_stylebox_override("fill", fill)
|
||||
var bg := StyleBoxFlat.new()
|
||||
bg.bg_color = Color(0.1, 0.05, 0.03, 0.8)
|
||||
bg.set_corner_radius_all(2)
|
||||
_combo_bar.add_theme_stylebox_override("background", bg)
|
||||
vbox.add_child(_combo_bar)
|
||||
|
||||
_combo_panel.visible = false
|
||||
add_child(_combo_panel)
|
||||
|
||||
|
||||
func _on_combo_changed(cnt: int, mult: float) -> void:
|
||||
if _combo_panel == null:
|
||||
return
|
||||
if cnt < 2:
|
||||
_combo_panel.visible = false
|
||||
return
|
||||
_combo_panel.visible = true
|
||||
_combo_label.text = "COMBO x%d (+%d%%)" % [cnt, int(round((mult - 1.0) * 100.0))]
|
||||
_combo_panel.scale = Vector2(1.15, 1.15)
|
||||
var tween := create_tween()
|
||||
tween.tween_property(_combo_panel, "scale", Vector2.ONE, 0.18).set_trans(Tween.TRANS_SINE)
|
||||
|
||||
|
||||
func _on_combo_broken(final_count: int) -> void:
|
||||
if final_count >= 5:
|
||||
var pp: Node = get_node_or_null("/root/PlayerProgress")
|
||||
if pp != null:
|
||||
var bonus: int = final_count * 3
|
||||
pp.award(bonus, "bonus combo x%d" % final_count, Vector3.ZERO)
|
||||
var main: Node = get_tree().get_first_node_in_group("main")
|
||||
if main != null and is_instance_valid(_dolphin) and main.has_method("_spawn_xp_popup"):
|
||||
main.call("_spawn_xp_popup", bonus, _dolphin.global_position + Vector3(0, 2.0, 0))
|
||||
|
||||
|
||||
func _update_combo_bar() -> void:
|
||||
var combo: Node = get_node_or_null("/root/ComboTracker")
|
||||
if combo == null or _combo_bar == null:
|
||||
return
|
||||
_combo_bar.value = combo.time_remaining_ratio()
|
||||
|
||||
|
||||
func _build_consumable_hint() -> void:
|
||||
_consumable_hint = Label.new()
|
||||
_consumable_hint.anchor_left = 0.5
|
||||
_consumable_hint.anchor_right = 0.5
|
||||
_consumable_hint.anchor_top = 1.0
|
||||
_consumable_hint.anchor_bottom = 1.0
|
||||
_consumable_hint.offset_left = -220.0
|
||||
_consumable_hint.offset_right = 220.0
|
||||
_consumable_hint.offset_top = -105.0
|
||||
_consumable_hint.offset_bottom = -85.0
|
||||
_consumable_hint.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||||
_consumable_hint.add_theme_font_size_override("font_size", 11)
|
||||
_consumable_hint.add_theme_color_override("font_color", Color(0.85, 0.95, 1.0, 0.9))
|
||||
_consumable_hint.add_theme_color_override("font_outline_color", Color(0, 0, 0, 0.85))
|
||||
_consumable_hint.add_theme_constant_override("outline_size", 3)
|
||||
_consumable_hint.visible = false
|
||||
add_child(_consumable_hint)
|
||||
|
||||
|
||||
func _update_consumable_hint() -> void:
|
||||
if _consumable_hint == null or not is_instance_valid(_dolphin):
|
||||
return
|
||||
var main: Node = get_tree().get_first_node_in_group("main")
|
||||
if main == null or not ("inventory" in main):
|
||||
_consumable_hint.visible = false
|
||||
return
|
||||
var slot: Variant = main.inventory.get_selected_item()
|
||||
if slot == null:
|
||||
_consumable_hint.visible = false
|
||||
return
|
||||
if not ItemDatabase.is_consumable(slot["item_id"]):
|
||||
_consumable_hint.visible = false
|
||||
return
|
||||
_consumable_hint.text = "[F] Consommer : %s" % ItemDatabase.get_item_name(slot["item_id"])
|
||||
_consumable_hint.visible = true
|
||||
|
||||
|
||||
func _build_toast_container() -> void:
|
||||
_toast_container = VBoxContainer.new()
|
||||
_toast_container.anchor_left = 0.5
|
||||
_toast_container.anchor_right = 0.5
|
||||
_toast_container.anchor_top = 0.0
|
||||
_toast_container.anchor_bottom = 0.0
|
||||
_toast_container.offset_left = -220.0
|
||||
_toast_container.offset_right = 220.0
|
||||
_toast_container.offset_top = 16.0
|
||||
_toast_container.offset_bottom = 16.0
|
||||
_toast_container.add_theme_constant_override("separation", 6)
|
||||
add_child(_toast_container)
|
||||
|
||||
|
||||
func _on_achievement_unlocked(ach: Dictionary) -> void:
|
||||
if _toast_container == null:
|
||||
return
|
||||
var panel := PanelContainer.new()
|
||||
var style := StyleBoxFlat.new()
|
||||
style.bg_color = Color(0.18, 0.14, 0.05, 0.92)
|
||||
style.border_color = Color(1.0, 0.82, 0.2)
|
||||
style.set_border_width_all(2)
|
||||
style.set_corner_radius_all(8)
|
||||
panel.add_theme_stylebox_override("panel", style)
|
||||
|
||||
var hbox := HBoxContainer.new()
|
||||
hbox.add_theme_constant_override("separation", 10)
|
||||
panel.add_child(hbox)
|
||||
|
||||
var icon := Label.new()
|
||||
icon.text = ach.get("icon", "★")
|
||||
icon.add_theme_font_size_override("font_size", 28)
|
||||
icon.add_theme_color_override("font_color", Color(1.0, 0.92, 0.4))
|
||||
hbox.add_child(icon)
|
||||
|
||||
var vbox := VBoxContainer.new()
|
||||
vbox.add_theme_constant_override("separation", 0)
|
||||
hbox.add_child(vbox)
|
||||
|
||||
var title := Label.new()
|
||||
title.text = "SUCCÈS DÉBLOQUÉ : %s" % ach["name"]
|
||||
title.add_theme_font_size_override("font_size", 13)
|
||||
title.add_theme_color_override("font_color", Color(1.0, 0.95, 0.6))
|
||||
vbox.add_child(title)
|
||||
|
||||
var desc := Label.new()
|
||||
desc.text = "%s (+%d XP)" % [ach["desc"], ach["xp"]]
|
||||
desc.add_theme_font_size_override("font_size", 11)
|
||||
desc.add_theme_color_override("font_color", Color(0.88, 0.88, 0.88))
|
||||
vbox.add_child(desc)
|
||||
|
||||
_toast_container.add_child(panel)
|
||||
panel.modulate.a = 0.0
|
||||
_toasts.append({"node": panel, "timer": 4.0, "age": 0.0})
|
||||
|
||||
var audio: Node = get_node_or_null("/root/AudioManager")
|
||||
if audio != null and is_instance_valid(_dolphin) and audio.has_method("play_bubble_sfx"):
|
||||
audio.call("play_bubble_sfx", _dolphin.global_position)
|
||||
|
||||
|
||||
func _update_toasts(delta: float) -> void:
|
||||
if _toasts.is_empty():
|
||||
return
|
||||
var i: int = _toasts.size() - 1
|
||||
while i >= 0:
|
||||
var t: Dictionary = _toasts[i]
|
||||
t["age"] += delta
|
||||
t["timer"] -= delta
|
||||
var node: PanelContainer = t["node"]
|
||||
if not is_instance_valid(node):
|
||||
_toasts.remove_at(i)
|
||||
i -= 1
|
||||
continue
|
||||
if t["age"] < 0.35:
|
||||
node.modulate.a = t["age"] / 0.35
|
||||
elif t["timer"] < 0.5:
|
||||
node.modulate.a = maxf(t["timer"] / 0.5, 0.0)
|
||||
else:
|
||||
node.modulate.a = 1.0
|
||||
if t["timer"] <= 0.0:
|
||||
node.queue_free()
|
||||
_toasts.remove_at(i)
|
||||
i -= 1
|
||||
|
||||
|
||||
func _build_level_banner() -> void:
|
||||
_level_banner = Label.new()
|
||||
_level_banner.anchor_left = 0.5
|
||||
_level_banner.anchor_right = 0.5
|
||||
_level_banner.anchor_top = 0.25
|
||||
_level_banner.anchor_bottom = 0.25
|
||||
_level_banner.offset_left = -200.0
|
||||
_level_banner.offset_right = 200.0
|
||||
_level_banner.offset_top = -28.0
|
||||
_level_banner.offset_bottom = 28.0
|
||||
_level_banner.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||||
_level_banner.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
|
||||
_level_banner.add_theme_font_size_override("font_size", 40)
|
||||
_level_banner.add_theme_color_override("font_color", Color(1.0, 0.95, 0.45))
|
||||
_level_banner.add_theme_color_override("font_outline_color", Color(0, 0, 0, 0.9))
|
||||
_level_banner.add_theme_constant_override("outline_size", 6)
|
||||
_level_banner.modulate.a = 0.0
|
||||
add_child(_level_banner)
|
||||
|
||||
|
||||
func _on_xp_changed(current_xp: int, xp_for_next: int, level: int) -> void:
|
||||
if _xp_bar == null or _xp_label == null:
|
||||
return
|
||||
_xp_bar.max_value = float(xp_for_next)
|
||||
_xp_bar.value = float(current_xp)
|
||||
_xp_label.text = "Niv. %d — %d / %d" % [level, current_xp, xp_for_next]
|
||||
|
||||
|
||||
func _on_level_up(new_level: int) -> void:
|
||||
if _level_banner == null:
|
||||
return
|
||||
_level_banner.text = "⬆ NIVEAU %d" % new_level
|
||||
_level_banner.modulate.a = 1.0
|
||||
_level_banner_timer = 2.2
|
||||
var am: Node = get_node_or_null("/root/AudioManager")
|
||||
if am != null and is_instance_valid(_dolphin):
|
||||
if am.has_method("play_bubble_sfx"):
|
||||
am.call("play_bubble_sfx", _dolphin.global_position)
|
||||
|
||||
|
||||
func _style_bar(bar: ProgressBar, color: Color) -> void:
|
||||
@@ -29,11 +529,219 @@ func _style_bar(bar: ProgressBar, color: Color) -> void:
|
||||
bar.add_theme_stylebox_override("background", bg_style)
|
||||
|
||||
|
||||
func _build_boost_bar() -> void:
|
||||
_boost_panel = PanelContainer.new()
|
||||
_boost_panel.anchor_left = 0.0
|
||||
_boost_panel.anchor_right = 0.0
|
||||
_boost_panel.anchor_top = 1.0
|
||||
_boost_panel.anchor_bottom = 1.0
|
||||
_boost_panel.offset_left = 16.0
|
||||
_boost_panel.offset_right = 220.0
|
||||
_boost_panel.offset_top = -78.0
|
||||
_boost_panel.offset_bottom = -48.0
|
||||
|
||||
var style := StyleBoxFlat.new()
|
||||
style.bg_color = Color(0.04, 0.06, 0.12, 0.78)
|
||||
style.border_color = Color(0.3, 0.7, 1.0, 0.6)
|
||||
style.set_border_width_all(1)
|
||||
style.set_corner_radius_all(6)
|
||||
_boost_panel.add_theme_stylebox_override("panel", style)
|
||||
|
||||
var vbox := VBoxContainer.new()
|
||||
vbox.add_theme_constant_override("separation", 2)
|
||||
_boost_panel.add_child(vbox)
|
||||
|
||||
_boost_label = Label.new()
|
||||
_boost_label.text = "BOOST"
|
||||
_boost_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||||
_boost_label.add_theme_font_size_override("font_size", 10)
|
||||
_boost_label.add_theme_color_override("font_color", Color(0.55, 0.85, 1.0))
|
||||
vbox.add_child(_boost_label)
|
||||
|
||||
_boost_bar = ProgressBar.new()
|
||||
_boost_bar.custom_minimum_size = Vector2(188, 7)
|
||||
_boost_bar.max_value = 100.0
|
||||
_boost_bar.value = 100.0
|
||||
_boost_bar.show_percentage = false
|
||||
var fill := StyleBoxFlat.new()
|
||||
fill.bg_color = Color(0.3, 0.75, 1.0)
|
||||
fill.set_corner_radius_all(3)
|
||||
_boost_bar.add_theme_stylebox_override("fill", fill)
|
||||
var bg := StyleBoxFlat.new()
|
||||
bg.bg_color = Color(0.06, 0.08, 0.14, 0.8)
|
||||
bg.set_corner_radius_all(3)
|
||||
_boost_bar.add_theme_stylebox_override("background", bg)
|
||||
vbox.add_child(_boost_bar)
|
||||
|
||||
add_child(_boost_panel)
|
||||
|
||||
|
||||
func _build_speed_lines() -> void:
|
||||
_speed_lines = Control.new()
|
||||
_speed_lines.set_anchors_preset(Control.PRESET_FULL_RECT)
|
||||
_speed_lines.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
_speed_lines.modulate.a = 0.0
|
||||
_speed_lines.draw.connect(_draw_speed_lines)
|
||||
add_child(_speed_lines)
|
||||
# Init line data (angle, length ratio, alpha)
|
||||
for i: int in range(SPEED_LINE_COUNT):
|
||||
_speed_line_data.append({
|
||||
"angle": (float(i) / float(SPEED_LINE_COUNT)) * TAU + randf() * 0.3,
|
||||
"len": randf_range(0.25, 0.75),
|
||||
"alpha": randf_range(0.4, 1.0),
|
||||
"offset": randf_range(0.3, 0.85)
|
||||
})
|
||||
|
||||
|
||||
func _draw_speed_lines() -> void:
|
||||
if _speed_lines == null:
|
||||
return
|
||||
var size: Vector2 = _speed_lines.size
|
||||
var center: Vector2 = size * 0.5
|
||||
var max_r: float = size.length() * 0.55
|
||||
for line: Dictionary in _speed_line_data:
|
||||
var dir: Vector2 = Vector2(cos(line["angle"]), sin(line["angle"]))
|
||||
var start: Vector2 = center + dir * max_r * line["offset"]
|
||||
var end: Vector2 = center + dir * max_r * (line["offset"] + line["len"] * 0.35)
|
||||
var col: Color = Color(0.55, 0.85, 1.0, line["alpha"] * 0.55)
|
||||
_speed_lines.draw_line(start, end, col, 1.5)
|
||||
|
||||
|
||||
func _build_mini_map() -> void:
|
||||
var mm_script: Script = load("res://scripts/dolphin/MiniMap.gd")
|
||||
if mm_script == null:
|
||||
return
|
||||
_mini_map = Control.new()
|
||||
_mini_map.set_script(mm_script)
|
||||
add_child(_mini_map)
|
||||
|
||||
|
||||
func connect_to_dolphin(dolphin: CharacterBody3D) -> void:
|
||||
dolphin.stats_changed.connect(_on_stats_changed)
|
||||
if dolphin.has_signal("boost_changed"):
|
||||
dolphin.boost_changed.connect(_on_boost_changed)
|
||||
_dolphin = dolphin
|
||||
if _mini_map != null and _mini_map.has_method("setup"):
|
||||
_mini_map.call("setup", dolphin)
|
||||
|
||||
var main: Node = get_tree().get_first_node_in_group("main")
|
||||
if main != null:
|
||||
var cm: Node = main.get_node_or_null("World/ChunkManager")
|
||||
if cm != null and cm.get("world_seed") != null:
|
||||
_world_seed = cm.world_seed
|
||||
|
||||
|
||||
func _on_stats_changed(oxygen: float, hp: float, hunger: float) -> void:
|
||||
_oxygen_bar.value = oxygen
|
||||
_health_bar.value = hp
|
||||
_hunger_bar.value = hunger
|
||||
|
||||
|
||||
func _process(delta: float) -> void:
|
||||
if _level_banner_timer > 0.0:
|
||||
_level_banner_timer -= delta
|
||||
if _level_banner != null:
|
||||
var a: float = clampf(_level_banner_timer / 0.6, 0.0, 1.0)
|
||||
_level_banner.modulate.a = a
|
||||
_level_banner.scale = Vector2.ONE * (1.0 + (1.0 - a) * 0.2)
|
||||
|
||||
if _quest_banner_timer > 0.0:
|
||||
_quest_banner_timer -= delta
|
||||
if _quest_complete_banner != null:
|
||||
_quest_complete_banner.modulate.a = clampf(_quest_banner_timer / 0.6, 0.0, 1.0)
|
||||
|
||||
_update_toasts(delta)
|
||||
_update_consumable_hint()
|
||||
_update_combo_bar()
|
||||
_update_speed_lines(delta)
|
||||
|
||||
if not is_instance_valid(_dolphin):
|
||||
return
|
||||
|
||||
var depth_m: int = int(round(WATER_SURFACE_Y - _dolphin.global_position.y))
|
||||
if _depth_label != null:
|
||||
_depth_label.text = "Prof. %d m" % depth_m
|
||||
|
||||
if depth_m > 0:
|
||||
var pp: Node = get_node_or_null("/root/PlayerProgress")
|
||||
if pp != null and pp.has_method("note_depth"):
|
||||
pp.call("note_depth", depth_m)
|
||||
var qm: Node = get_node_or_null("/root/QuestManager")
|
||||
if qm != null and qm.has_method("note_depth"):
|
||||
qm.call("note_depth", depth_m)
|
||||
var am2: Node = get_node_or_null("/root/AchievementManager")
|
||||
if am2 != null and am2.has_method("note_depth"):
|
||||
am2.call("note_depth", depth_m)
|
||||
|
||||
if _compass_label != null:
|
||||
_compass_label.text = _compass_text(_dolphin.rotation.y)
|
||||
|
||||
if _biome_label != null:
|
||||
_update_biome_label()
|
||||
|
||||
|
||||
func _compass_text(yaw: float) -> String:
|
||||
# Player forward is -Z when yaw=0 → North. Yaw rotates counterclockwise in Godot.
|
||||
# Convert yaw to a heading [0, 360) where 0 = North, 90 = East.
|
||||
var heading: float = rad_to_deg(-yaw)
|
||||
heading = fposmod(heading, 360.0)
|
||||
|
||||
var labels: PackedStringArray = PackedStringArray(["N", "NE", "E", "SE", "S", "SO", "O", "NO"])
|
||||
var arrows: PackedStringArray = PackedStringArray([
|
||||
"⬆", "⬈", "➡", "⬊", "⬇", "⬋", "⬅", "⬉"
|
||||
])
|
||||
var idx: int = int(round(heading / 45.0)) % 8
|
||||
return "%s %s" % [arrows[idx], labels[idx]]
|
||||
|
||||
|
||||
func _update_biome_label() -> void:
|
||||
var pos: Vector3 = _dolphin.global_position
|
||||
if pos.distance_to(_biome_cache_pos) < 4.0 and _biome_cached_name != "":
|
||||
_biome_label.text = "Biome : %s" % _biome_cached_name
|
||||
return
|
||||
_biome_cache_pos = pos
|
||||
_biome_cached_name = WorldGenerator.biome_name_at(pos.x, pos.z, pos.y, _world_seed)
|
||||
_biome_label.text = "Biome : %s" % _biome_cached_name
|
||||
|
||||
|
||||
func _on_boost_changed(energy: float, max_energy: float, active: bool) -> void:
|
||||
if _boost_bar == null:
|
||||
return
|
||||
_boost_bar.max_value = max_energy
|
||||
_boost_bar.value = energy
|
||||
# Color shift: full = cyan, low = orange
|
||||
var t: float = energy / max_energy
|
||||
var fill := StyleBoxFlat.new()
|
||||
fill.bg_color = Color(1.0 - t * 0.7, 0.55 + t * 0.3, t)
|
||||
fill.set_corner_radius_all(3)
|
||||
_boost_bar.add_theme_stylebox_override("fill", fill)
|
||||
if _boost_label != null:
|
||||
_boost_label.text = "BOOST %d%%" % int(energy / max_energy * 100.0)
|
||||
if active:
|
||||
_boost_label.add_theme_color_override("font_color", Color(0.3, 0.9, 1.0))
|
||||
elif energy < max_energy * 0.25:
|
||||
_boost_label.add_theme_color_override("font_color", Color(1.0, 0.45, 0.2))
|
||||
else:
|
||||
_boost_label.add_theme_color_override("font_color", Color(0.55, 0.85, 1.0))
|
||||
|
||||
|
||||
var _boost_active: bool = false
|
||||
|
||||
|
||||
func _update_speed_lines(delta: float) -> void:
|
||||
if _speed_lines == null:
|
||||
return
|
||||
var target_alpha: float = 0.0
|
||||
if is_instance_valid(_dolphin) and _dolphin.get("is_boosting"):
|
||||
if _dolphin.is_boosting:
|
||||
target_alpha = 1.0
|
||||
_boost_active = true
|
||||
# Animate line angles for motion feel
|
||||
var t: float = Time.get_ticks_msec() * 0.001
|
||||
for i: int in range(_speed_line_data.size()):
|
||||
_speed_line_data[i]["angle"] += delta * (0.4 + float(i) * 0.05)
|
||||
else:
|
||||
_boost_active = false
|
||||
_speed_lines.modulate.a = lerpf(_speed_lines.modulate.a, target_alpha, delta * 5.0)
|
||||
if _speed_lines.modulate.a > 0.02:
|
||||
_speed_lines.queue_redraw()
|
||||
|
||||
183
scripts/dolphin/MiniMap.gd
Normal file
183
scripts/dolphin/MiniMap.gd
Normal file
@@ -0,0 +1,183 @@
|
||||
extends Control
|
||||
## MiniMap — radar circulaire 2D dans le coin HUD.
|
||||
## Rayon 32 blocs. Montre : joueur (blanc), mobs (rouge), perles (jaune),
|
||||
## blocs spéciaux via couleur biome (dessin manuel sur CanvasItem).
|
||||
|
||||
const MAP_RADIUS_PX: float = 52.0 # rayon en pixels du cercle radar
|
||||
const MAP_WORLD_RADIUS: float = 32.0 # rayon en unités monde représenté
|
||||
|
||||
var _dolphin: Node3D = null
|
||||
|
||||
# Pulsation des icônes mobs
|
||||
var _time: float = 0.0
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
# Positionnement: coin bas-gauche au-dessus de la barre boost
|
||||
anchor_left = 0.0
|
||||
anchor_right = 0.0
|
||||
anchor_top = 1.0
|
||||
anchor_bottom = 1.0
|
||||
var size: float = MAP_RADIUS_PX * 2.0 + 8.0
|
||||
offset_left = 16.0
|
||||
offset_right = 16.0 + size
|
||||
offset_top = -(78.0 + size + 12.0)
|
||||
offset_bottom = -(78.0 + 12.0)
|
||||
custom_minimum_size = Vector2(size, size)
|
||||
mouse_filter = MOUSE_FILTER_IGNORE
|
||||
|
||||
|
||||
func setup(dolphin: Node3D) -> void:
|
||||
_dolphin = dolphin
|
||||
|
||||
|
||||
func _process(delta: float) -> void:
|
||||
_time += delta
|
||||
queue_redraw()
|
||||
|
||||
|
||||
func _draw() -> void:
|
||||
if not is_instance_valid(_dolphin):
|
||||
return
|
||||
|
||||
var center: Vector2 = Vector2(MAP_RADIUS_PX + 4.0, MAP_RADIUS_PX + 4.0)
|
||||
var r: float = MAP_RADIUS_PX
|
||||
|
||||
# --- Background circle ---
|
||||
draw_circle(center, r + 2.0, Color(0.0, 0.0, 0.0, 0.45))
|
||||
draw_arc(center, r + 2.0, 0.0, TAU, 48, Color(0.3, 0.7, 1.0, 0.55), 1.5)
|
||||
|
||||
# --- Clip mask simulation: draw a dark ring outside the circle to fake clipping ---
|
||||
# (Godot 4 CanvasItem draw doesn't natively clip circles — we use stencil trick with overdraw)
|
||||
|
||||
var player_pos: Vector3 = _dolphin.global_position
|
||||
var player_yaw: float = _dolphin.rotation.y # for oriented radar
|
||||
|
||||
# --- Draw world blip grid (simplified biome color dots) ---
|
||||
_draw_biome_blips(center, r, player_pos, player_yaw)
|
||||
|
||||
# --- Draw Pearls ---
|
||||
var pearls := get_tree().get_nodes_in_group("pearl")
|
||||
for pearl: Node in pearls:
|
||||
if not is_instance_valid(pearl):
|
||||
continue
|
||||
var wp: Vector3 = pearl.global_position
|
||||
var blip: Vector2 = _world_to_radar(player_pos, player_yaw, wp, r)
|
||||
if blip.length() <= r:
|
||||
draw_circle(center + blip, 3.5, Color(1.0, 0.92, 0.2, 0.9))
|
||||
|
||||
# --- Draw Mobs ---
|
||||
var mobs: Array = []
|
||||
mobs.append_array(get_tree().get_nodes_in_group("shark"))
|
||||
mobs.append_array(get_tree().get_nodes_in_group("mob"))
|
||||
# Also scan MobSpawner children directly
|
||||
var spawner: Node = _find_mob_spawner()
|
||||
if spawner != null:
|
||||
_collect_mob_children(spawner, mobs)
|
||||
|
||||
for mob: Node in mobs:
|
||||
if not is_instance_valid(mob):
|
||||
continue
|
||||
var mp: Vector3 = mob.global_position
|
||||
var blip: Vector2 = _world_to_radar(player_pos, player_yaw, mp, r)
|
||||
if blip.length() <= r:
|
||||
var pulse: float = 0.75 + sin(_time * 4.0) * 0.25
|
||||
draw_circle(center + blip, 3.5, Color(1.0, 0.2, 0.2, pulse))
|
||||
|
||||
# --- Draw Player dot (center, white with ring) ---
|
||||
draw_circle(center, 4.5, Color(1.0, 1.0, 1.0, 1.0))
|
||||
draw_arc(center, 5.5, 0.0, TAU, 16, Color(0.5, 0.8, 1.0, 0.8), 1.0)
|
||||
|
||||
# --- Direction tick (north indicator) ---
|
||||
var north_angle: float = -player_yaw - PI * 0.5
|
||||
var north_dir: Vector2 = Vector2(cos(north_angle), sin(north_angle)) * (r - 4.0)
|
||||
draw_line(center + north_dir * 0.85, center + north_dir, Color(1.0, 0.3, 0.3, 0.9), 2.0)
|
||||
|
||||
# --- Border ring (final, on top) ---
|
||||
draw_arc(center, r, 0.0, TAU, 64, Color(0.25, 0.6, 0.9, 0.75), 2.0)
|
||||
|
||||
|
||||
func _world_to_radar(player_pos: Vector3, player_yaw: float, world_pos: Vector3, r: float) -> Vector2:
|
||||
var dx: float = world_pos.x - player_pos.x
|
||||
var dz: float = world_pos.z - player_pos.z
|
||||
# Rotate by -player_yaw so the map is oriented (player forward = up)
|
||||
var cos_y: float = cos(-player_yaw)
|
||||
var sin_y: float = sin(-player_yaw)
|
||||
var rx: float = dx * cos_y - dz * sin_y
|
||||
var rz: float = dx * sin_y + dz * cos_y
|
||||
# Scale to radar pixels
|
||||
var scale: float = r / MAP_WORLD_RADIUS
|
||||
return Vector2(rx * scale, rz * scale)
|
||||
|
||||
|
||||
func _draw_biome_blips(center: Vector2, r: float, player_pos: Vector3, player_yaw: float) -> void:
|
||||
# Sample a coarse grid around the player and draw colored pixels by block type
|
||||
# We do a 5x5 sparse grid to keep it performant
|
||||
var chunk_mgr: Node = _find_chunk_manager()
|
||||
if chunk_mgr == null or not chunk_mgr.has_method("get_block"):
|
||||
return
|
||||
|
||||
var step: float = MAP_WORLD_RADIUS / 5.0
|
||||
for ix: int in range(-5, 6):
|
||||
for iz: int in range(-5, 6):
|
||||
var wx: float = player_pos.x + ix * step
|
||||
var wz: float = player_pos.z + iz * step
|
||||
var wy: float = player_pos.y
|
||||
var block_id: int = chunk_mgr.call("get_block", Vector3(wx, wy, wz))
|
||||
if block_id <= 1:
|
||||
continue
|
||||
var col: Color = _block_color(block_id)
|
||||
col.a = 0.3
|
||||
var blip: Vector2 = _world_to_radar(player_pos, player_yaw, Vector3(wx, wy, wz), r)
|
||||
if blip.length() < r - 4.0:
|
||||
draw_rect(Rect2(center + blip - Vector2(1.5, 1.5), Vector2(3.0, 3.0)), col)
|
||||
|
||||
|
||||
func _block_color(block_id: int) -> Color:
|
||||
match block_id:
|
||||
2: return Color(0.76, 0.70, 0.50) # sand
|
||||
3: return Color(0.45, 0.45, 0.45) # rock
|
||||
4: return Color(0.9, 0.2, 0.2) # coral rouge
|
||||
5: return Color(0.2, 0.4, 0.9) # coral bleu
|
||||
6: return Color(0.1, 0.6, 0.1) # kelp
|
||||
7: return Color(0.55, 0.35, 0.15) # epave
|
||||
8: return Color(0.7, 0.9, 1.0) # glace
|
||||
_: return Color(0.5, 0.5, 0.5)
|
||||
|
||||
|
||||
func _find_chunk_manager() -> Node:
|
||||
var candidates := get_tree().get_nodes_in_group("chunk_manager")
|
||||
if candidates.size() > 0:
|
||||
return candidates[0]
|
||||
return get_node_or_null("/root/Main/World/ChunkManager")
|
||||
|
||||
|
||||
func _find_mob_spawner() -> Node:
|
||||
var root: Node = get_tree().current_scene
|
||||
if root == null:
|
||||
return null
|
||||
return _search_for_mob_spawner(root)
|
||||
|
||||
|
||||
func _search_for_mob_spawner(node: Node) -> Node:
|
||||
if node.get_script() != null:
|
||||
var path: String = node.get_script().resource_path
|
||||
if "MobSpawner" in path:
|
||||
return node
|
||||
for child: Node in node.get_children():
|
||||
var found: Node = _search_for_mob_spawner(child)
|
||||
if found != null:
|
||||
return found
|
||||
return null
|
||||
|
||||
|
||||
func _collect_mob_children(mob_spawner: Node, out: Array) -> void:
|
||||
# MobSpawner spawns mobs as children of current_scene, tracked in _sharks/_jellyfish_list
|
||||
# We can read them directly from scene tree by checking script type
|
||||
var scene_root: Node = get_tree().current_scene
|
||||
if scene_root == null:
|
||||
return
|
||||
for child: Node in scene_root.get_children():
|
||||
if child is CharacterBody3D and child != _dolphin:
|
||||
if not out.has(child):
|
||||
out.append(child)
|
||||
@@ -27,6 +27,21 @@ static var RECIPES: Array = [
|
||||
"inputs": [{"item_id": 4, "count": 4}, {"item_id": 7, "count": 2}],
|
||||
"output": {"item_id": 104, "count": 1}
|
||||
},
|
||||
{
|
||||
"name": "Amulette de soin",
|
||||
"inputs": [{"item_id": 105, "count": 2}, {"item_id": 4, "count": 1}],
|
||||
"output": {"item_id": 106, "count": 1}
|
||||
},
|
||||
{
|
||||
"name": "Amulette Tier 2",
|
||||
"inputs": [{"item_id": 107, "count": 2}, {"item_id": 106, "count": 1}],
|
||||
"output": {"item_id": 109, "count": 1}
|
||||
},
|
||||
{
|
||||
"name": "Lampe Portable",
|
||||
"inputs": [{"item_id": 108, "count": 2}, {"item_id": 100, "count": 1}],
|
||||
"output": {"item_id": 110, "count": 1}
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
@@ -47,4 +62,18 @@ static func craft(inventory: Inventory, recipe_index: int) -> bool:
|
||||
var leftover: int = inventory.add_item(recipe["output"]["item_id"], recipe["output"]["count"])
|
||||
if leftover > 0:
|
||||
push_warning("CraftingRecipes: craft overflow — %d items lost" % leftover)
|
||||
_award_craft_xp(recipe)
|
||||
return true
|
||||
|
||||
|
||||
static func _award_craft_xp(recipe: Dictionary) -> void:
|
||||
var root: Node = Engine.get_main_loop().root
|
||||
var pp: Node = root.get_node_or_null("/root/PlayerProgress")
|
||||
if pp != null:
|
||||
pp.award(pp.XP_CRAFT, "craft %s" % str(recipe.get("name", "")), Vector3.ZERO)
|
||||
var qm: Node = root.get_node_or_null("/root/QuestManager")
|
||||
if qm != null:
|
||||
qm.note_craft()
|
||||
var ach: Node = root.get_node_or_null("/root/AchievementManager")
|
||||
if ach != null:
|
||||
ach.note_craft()
|
||||
|
||||
@@ -75,6 +75,16 @@ func has_items(requirements: Array) -> bool:
|
||||
return true
|
||||
|
||||
|
||||
func get_all_slots() -> Array:
|
||||
return slots.duplicate(true)
|
||||
|
||||
|
||||
func load_slots(saved_slots: Array) -> void:
|
||||
for i: int in range(min(saved_slots.size(), TOTAL_SLOTS)):
|
||||
slots[i] = saved_slots[i]
|
||||
inventory_changed.emit()
|
||||
|
||||
|
||||
func consume_items(requirements: Array) -> bool:
|
||||
if not has_items(requirements):
|
||||
return false
|
||||
|
||||
@@ -16,6 +16,12 @@ const ITEM_NAMES: Dictionary = {
|
||||
102: "Bulle d'air",
|
||||
103: "Algue cuisinee",
|
||||
104: "Armure ecailles",
|
||||
105: "Perle",
|
||||
106: "Amulette de soin",
|
||||
107: "Dent de Requin",
|
||||
108: "Gelée Bioluminescente",
|
||||
109: "Amulette Tier 2",
|
||||
110: "Lampe Portable",
|
||||
}
|
||||
|
||||
const ITEM_COLORS: Dictionary = {
|
||||
@@ -34,10 +40,16 @@ const ITEM_COLORS: Dictionary = {
|
||||
102: Color(0.2, 0.9, 0.9),
|
||||
103: Color(0.05, 0.4, 0.05),
|
||||
104: Color(0.1, 0.55, 0.5),
|
||||
105: Color(0.95, 0.95, 1.0),
|
||||
106: Color(1.0, 0.85, 0.4),
|
||||
107: Color(0.9, 0.9, 0.85),
|
||||
108: Color(0.2, 0.85, 0.65),
|
||||
109: Color(1.0, 0.75, 0.15),
|
||||
110: Color(0.5, 0.9, 1.0),
|
||||
}
|
||||
|
||||
const PLACEABLE_IDS: Array = [2, 3, 4, 5, 6, 7, 8]
|
||||
const CONSUMABLE_IDS: Array = [102, 103]
|
||||
const CONSUMABLE_IDS: Array = [102, 103, 106]
|
||||
const TOOL_IDS: Array = [100, 101, 104]
|
||||
|
||||
|
||||
|
||||
@@ -110,13 +110,20 @@ func _update_boids(dt: float) -> void:
|
||||
if center_offset.length() > school_radius:
|
||||
vel += -center_offset.normalized() * 1.5 * dt * 4.0
|
||||
|
||||
# --- Player avoidance ---
|
||||
# --- Player avoidance (boosted player = bigger + faster flee) ---
|
||||
if is_instance_valid(_player):
|
||||
var world_fish_pos: Vector3 = global_position + fish.position
|
||||
var dist_to_player: float = world_fish_pos.distance_to(_player.global_position)
|
||||
if dist_to_player < 3.0:
|
||||
var player_boosting: bool = _player.get("is_boosting") if "is_boosting" in _player else false
|
||||
var flee_radius: float = 6.0 if player_boosting else 3.0
|
||||
var flee_force: float = 9.0 if player_boosting else 4.0
|
||||
if dist_to_player < flee_radius:
|
||||
var flee: Vector3 = (world_fish_pos - _player.global_position).normalized()
|
||||
vel += flee * 4.0 * dt * (3.0 - dist_to_player)
|
||||
# Only flee if player is moving toward the school
|
||||
var player_vel: Vector3 = _player.velocity if "velocity" in _player else Vector3.ZERO
|
||||
var toward: bool = player_vel.dot((world_fish_pos - _player.global_position).normalized()) > 0.2
|
||||
if toward or player_boosting:
|
||||
vel += flee * flee_force * dt * (flee_radius - dist_to_player)
|
||||
|
||||
# Clamp speed
|
||||
if vel.length() > swim_speed * 1.5:
|
||||
|
||||
@@ -160,4 +160,21 @@ func _pick_wander_target() -> void:
|
||||
func take_damage(dmg: float) -> void:
|
||||
health -= dmg
|
||||
if health <= 0.0:
|
||||
_drop_loot()
|
||||
queue_free()
|
||||
|
||||
|
||||
func _drop_loot() -> void:
|
||||
if not is_instance_valid(_player):
|
||||
return
|
||||
var dist: float = global_position.distance_to(_player.global_position)
|
||||
if dist > 10.0:
|
||||
return
|
||||
var main: Node = get_tree().get_first_node_in_group("main")
|
||||
if main == null or main.get("inventory") == null:
|
||||
return
|
||||
# Drop 1 gelée bioluminescente
|
||||
main.inventory.add_item(108, 1)
|
||||
var pp: Node = get_node_or_null("/root/PlayerProgress")
|
||||
if pp != null:
|
||||
pp.award(8, "méduse", global_position)
|
||||
|
||||
@@ -178,10 +178,11 @@ func _load_and_instantiate(scene_path: String, pos: Vector3) -> Node3D:
|
||||
func _connect_mob_signal(mob: Node3D) -> void:
|
||||
if mob.has_signal("attacked_player"):
|
||||
if not mob.is_connected("attacked_player", _on_mob_attacked_player):
|
||||
# Use variadic-safe connect: bind by checking signal params
|
||||
mob.attacked_player.connect(_on_mob_attacked_player)
|
||||
|
||||
|
||||
func _on_mob_attacked_player(dmg: float) -> void:
|
||||
func _on_mob_attacked_player(dmg: float, kb_dir: Vector3 = Vector3.ZERO) -> void:
|
||||
if is_instance_valid(_player_node):
|
||||
if _player_node.has_method("take_damage"):
|
||||
_player_node.take_damage(dmg)
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
extends CharacterBody3D
|
||||
|
||||
signal attacked_player(damage: float)
|
||||
signal attacked_player(damage: float, knockback_dir: Vector3)
|
||||
|
||||
@export var max_health: float = 50.0
|
||||
@export var damage: float = 15.0
|
||||
@export var detection_radius: float = 12.0
|
||||
@export var damage: float = 20.0
|
||||
@export var detection_radius: float = 15.0 # chase trigger distance (15 units)
|
||||
@export var move_speed: float = 4.0
|
||||
@export var chase_speed: float = 7.0
|
||||
@export var knockback_force: float = 14.0
|
||||
|
||||
enum State { PATROL, CHASE, ATTACK }
|
||||
|
||||
@@ -30,6 +31,7 @@ func _ready() -> void:
|
||||
_build_visuals()
|
||||
_find_player()
|
||||
_generate_patrol_points()
|
||||
add_to_group("shark")
|
||||
|
||||
|
||||
func _find_player() -> void:
|
||||
@@ -153,7 +155,13 @@ func _physics_process(delta: float) -> void:
|
||||
|
||||
State.ATTACK:
|
||||
if is_instance_valid(_player) and _attack_cooldown <= 0.0:
|
||||
attacked_player.emit(damage)
|
||||
var kb_dir: Vector3 = (_player.global_position - global_position).normalized()
|
||||
attacked_player.emit(damage, kb_dir)
|
||||
# Apply knockback directly on player
|
||||
if _player.has_method("apply_knockback"):
|
||||
_player.call("apply_knockback", kb_dir * knockback_force)
|
||||
elif "velocity" in _player:
|
||||
_player.velocity += kb_dir * knockback_force
|
||||
_attack_cooldown = 2.0
|
||||
if player_dist > 3.0:
|
||||
_state = State.CHASE
|
||||
@@ -186,4 +194,26 @@ func _do_patrol(delta: float) -> void:
|
||||
func take_damage(dmg: float) -> void:
|
||||
health -= dmg
|
||||
if health <= 0.0:
|
||||
_drop_loot()
|
||||
queue_free()
|
||||
|
||||
|
||||
func _drop_loot() -> void:
|
||||
# Give shark tooth to nearby player inventory
|
||||
if not is_instance_valid(_player):
|
||||
return
|
||||
var dist: float = global_position.distance_to(_player.global_position)
|
||||
if dist > 12.0:
|
||||
return
|
||||
var main: Node = get_tree().get_first_node_in_group("main")
|
||||
if main == null or main.get("inventory") == null:
|
||||
return
|
||||
# Drop 1-2 shark teeth
|
||||
var count: int = randi_range(1, 2)
|
||||
main.inventory.add_item(107, count)
|
||||
# Visual popup
|
||||
if main.has_method("_spawn_xp_popup"):
|
||||
main.call("_spawn_xp_popup", 0, global_position)
|
||||
var pp: Node = get_node_or_null("/root/PlayerProgress")
|
||||
if pp != null:
|
||||
pp.award(15, "requin", global_position)
|
||||
|
||||
91
scripts/progression/AchievementManager.gd
Normal file
91
scripts/progression/AchievementManager.gd
Normal file
@@ -0,0 +1,91 @@
|
||||
extends Node
|
||||
|
||||
signal achievement_unlocked(ach: Dictionary)
|
||||
|
||||
const ACHIEVEMENTS: Array = [
|
||||
{"id": "first_break", "name": "Premier éclat", "desc": "Casser ton premier bloc", "xp": 10, "icon": "⛏"},
|
||||
{"id": "ten_breaks", "name": "Mineur apprenti", "desc": "Casser 10 blocs", "xp": 20, "icon": "⛏"},
|
||||
{"id": "hundred_breaks", "name": "Mineur confirmé", "desc": "Casser 100 blocs", "xp": 80, "icon": "⛏"},
|
||||
{"id": "first_pearl", "name": "Lueur nacrée", "desc": "Collecter ta première perle", "xp": 20, "icon": "◉"},
|
||||
{"id": "ten_pearls", "name": "Chasseur de perles", "desc": "Collecter 10 perles", "xp": 120, "icon": "◉"},
|
||||
{"id": "first_craft", "name": "Artisan", "desc": "Crafter ton premier objet", "xp": 15, "icon": "✦"},
|
||||
{"id": "first_wreck", "name": "Fouilleur d'épaves", "desc": "Piller une épave", "xp": 30, "icon": "⚓"},
|
||||
{"id": "depth_50", "name": "Plongée profonde", "desc": "Atteindre -50 m", "xp": 60, "icon": "▼"},
|
||||
{"id": "depth_100", "name": "Abysses", "desc": "Atteindre -100 m", "xp": 200, "icon": "▼"},
|
||||
{"id": "level_5", "name": "Dauphin aguerri", "desc": "Atteindre le niveau 5", "xp": 50, "icon": "★"},
|
||||
{"id": "level_10", "name": "Légende des océans", "desc": "Atteindre le niveau 10", "xp": 150, "icon": "★"},
|
||||
{"id": "echo_first", "name": "Sonar aiguisé", "desc": "Utiliser l'écholocation", "xp": 10, "icon": "))"},
|
||||
]
|
||||
|
||||
var unlocked: Dictionary = {} # id -> true
|
||||
|
||||
var _break_count: int = 0
|
||||
var _pearl_count: int = 0
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
var pp: Node = get_node_or_null("/root/PlayerProgress")
|
||||
if pp != null:
|
||||
pp.level_up.connect(_on_level_up)
|
||||
|
||||
|
||||
func note_block_break(block_id: int) -> void:
|
||||
_break_count += 1
|
||||
if _break_count >= 1:
|
||||
_unlock("first_break")
|
||||
if _break_count >= 10:
|
||||
_unlock("ten_breaks")
|
||||
if _break_count >= 100:
|
||||
_unlock("hundred_breaks")
|
||||
if block_id == 7:
|
||||
_unlock("first_wreck")
|
||||
|
||||
|
||||
func note_pearl_collected() -> void:
|
||||
_pearl_count += 1
|
||||
if _pearl_count >= 1:
|
||||
_unlock("first_pearl")
|
||||
if _pearl_count >= 10:
|
||||
_unlock("ten_pearls")
|
||||
|
||||
|
||||
func note_craft() -> void:
|
||||
_unlock("first_craft")
|
||||
|
||||
|
||||
func note_depth(depth_m: int) -> void:
|
||||
if depth_m >= 50:
|
||||
_unlock("depth_50")
|
||||
if depth_m >= 100:
|
||||
_unlock("depth_100")
|
||||
|
||||
|
||||
func note_echolocation() -> void:
|
||||
_unlock("echo_first")
|
||||
|
||||
|
||||
func _on_level_up(new_level: int) -> void:
|
||||
if new_level >= 5:
|
||||
_unlock("level_5")
|
||||
if new_level >= 10:
|
||||
_unlock("level_10")
|
||||
|
||||
|
||||
func _unlock(id: String) -> void:
|
||||
if unlocked.has(id):
|
||||
return
|
||||
var ach: Dictionary = _find(id)
|
||||
if ach.is_empty():
|
||||
return
|
||||
unlocked[id] = true
|
||||
achievement_unlocked.emit(ach)
|
||||
var pp: Node = get_node_or_null("/root/PlayerProgress")
|
||||
if pp != null:
|
||||
pp.award(ach["xp"], "succès: %s" % ach["name"], Vector3.ZERO)
|
||||
|
||||
|
||||
func _find(id: String) -> Dictionary:
|
||||
for a: Dictionary in ACHIEVEMENTS:
|
||||
if a["id"] == id:
|
||||
return a
|
||||
return {}
|
||||
48
scripts/progression/ComboTracker.gd
Normal file
48
scripts/progression/ComboTracker.gd
Normal file
@@ -0,0 +1,48 @@
|
||||
extends Node
|
||||
|
||||
signal combo_changed(count: int, multiplier: float)
|
||||
signal combo_broken(final_count: int)
|
||||
|
||||
const WINDOW_SEC: float = 1.8
|
||||
const STEP_BONUS: float = 0.15 # +15% per combo step
|
||||
const MAX_MULT: float = 3.0
|
||||
|
||||
var count: int = 0
|
||||
var multiplier: float = 1.0
|
||||
var _timer: float = 0.0
|
||||
var _active: bool = false
|
||||
|
||||
|
||||
func _process(delta: float) -> void:
|
||||
if not _active:
|
||||
return
|
||||
_timer -= delta
|
||||
if _timer <= 0.0:
|
||||
_break()
|
||||
|
||||
|
||||
func bump() -> float:
|
||||
# Advances combo and returns multiplier to apply to the triggering gain.
|
||||
count += 1
|
||||
multiplier = minf(1.0 + float(count - 1) * STEP_BONUS, MAX_MULT)
|
||||
_timer = WINDOW_SEC
|
||||
_active = true
|
||||
combo_changed.emit(count, multiplier)
|
||||
return multiplier
|
||||
|
||||
|
||||
func _break() -> void:
|
||||
var final_count: int = count
|
||||
_active = false
|
||||
combo_changed.emit(0, 1.0)
|
||||
if final_count >= 2:
|
||||
combo_broken.emit(final_count)
|
||||
count = 0
|
||||
multiplier = 1.0
|
||||
_timer = 0.0
|
||||
|
||||
|
||||
func time_remaining_ratio() -> float:
|
||||
if not _active:
|
||||
return 0.0
|
||||
return clampf(_timer / WINDOW_SEC, 0.0, 1.0)
|
||||
67
scripts/progression/PlayerProgress.gd
Normal file
67
scripts/progression/PlayerProgress.gd
Normal file
@@ -0,0 +1,67 @@
|
||||
extends Node
|
||||
|
||||
signal xp_gained(amount: int, source: String, world_position: Vector3)
|
||||
signal xp_changed(current_xp: int, xp_for_next: int, level: int)
|
||||
signal level_up(new_level: int)
|
||||
|
||||
const XP_BREAK_DEFAULT: int = 2
|
||||
const XP_BREAK_BY_BLOCK: Dictionary = {
|
||||
2: 1, # sand
|
||||
3: 2, # rock
|
||||
4: 5, # coral rouge
|
||||
5: 5, # coral bleu
|
||||
6: 1, # kelp
|
||||
7: 8, # epave
|
||||
8: 3, # glace
|
||||
}
|
||||
const XP_PEARL: int = 25
|
||||
const XP_CRAFT: int = 10
|
||||
const XP_ECHOLOCATE: int = 3
|
||||
const XP_KILL_HOSTILE: int = 15
|
||||
const XP_DEPTH_MILESTONE: int = 20
|
||||
|
||||
var current_xp: int = 0
|
||||
var level: int = 1
|
||||
|
||||
var _reached_depths: Array[int] = []
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
emit_state()
|
||||
|
||||
|
||||
func xp_for_level(lv: int) -> int:
|
||||
return 50 + (lv - 1) * 40 + (lv - 1) * (lv - 1) * 10
|
||||
|
||||
|
||||
func xp_for_next() -> int:
|
||||
return xp_for_level(level)
|
||||
|
||||
|
||||
func award(amount: int, source: String = "", world_pos: Vector3 = Vector3.ZERO) -> void:
|
||||
if amount <= 0:
|
||||
return
|
||||
current_xp += amount
|
||||
xp_gained.emit(amount, source, world_pos)
|
||||
while current_xp >= xp_for_next():
|
||||
current_xp -= xp_for_next()
|
||||
level += 1
|
||||
level_up.emit(level)
|
||||
emit_state()
|
||||
|
||||
|
||||
func award_block_break(block_id: int, world_pos: Vector3) -> void:
|
||||
var gain: int = XP_BREAK_BY_BLOCK.get(block_id, XP_BREAK_DEFAULT)
|
||||
award(gain, "bloc", world_pos)
|
||||
|
||||
|
||||
func emit_state() -> void:
|
||||
xp_changed.emit(current_xp, xp_for_next(), level)
|
||||
|
||||
|
||||
func note_depth(depth_m: int) -> void:
|
||||
var milestones: Array[int] = [10, 25, 50, 75, 100]
|
||||
for m: int in milestones:
|
||||
if depth_m >= m and not _reached_depths.has(m):
|
||||
_reached_depths.append(m)
|
||||
award(XP_DEPTH_MILESTONE, "profondeur %dm" % m, Vector3.ZERO)
|
||||
107
scripts/progression/QuestManager.gd
Normal file
107
scripts/progression/QuestManager.gd
Normal file
@@ -0,0 +1,107 @@
|
||||
extends Node
|
||||
|
||||
# Short-term objectives that rotate. Each quest has:
|
||||
# id (string), desc, kind (break/pearl/depth/craft), target (int), reward_xp (int)
|
||||
# Player always has up to MAX_ACTIVE quests active; completing one rolls a new one.
|
||||
|
||||
signal quests_updated()
|
||||
signal quest_completed(quest: Dictionary)
|
||||
|
||||
const MAX_ACTIVE: int = 3
|
||||
const QUEST_POOL: Array = [
|
||||
{"id": "break_sand", "desc": "Casser 15 blocs de sable", "kind": "break", "item_id": 2, "target": 15, "reward_xp": 40},
|
||||
{"id": "break_rock", "desc": "Casser 10 blocs de roche", "kind": "break", "item_id": 3, "target": 10, "reward_xp": 50},
|
||||
{"id": "break_kelp", "desc": "Récolter 8 kelp", "kind": "break", "item_id": 6, "target": 8, "reward_xp": 35},
|
||||
{"id": "break_coral_r", "desc": "Récolter 5 coraux rouges", "kind": "break", "item_id": 4, "target": 5, "reward_xp": 60},
|
||||
{"id": "break_coral_b", "desc": "Récolter 5 coraux bleus", "kind": "break", "item_id": 5, "target": 5, "reward_xp": 60},
|
||||
{"id": "find_wreck", "desc": "Fouiller 3 épaves", "kind": "break", "item_id": 7, "target": 3, "reward_xp": 80},
|
||||
{"id": "pearl_3", "desc": "Collecter 3 perles", "kind": "pearl", "target": 3, "reward_xp": 70},
|
||||
{"id": "pearl_5", "desc": "Collecter 5 perles", "kind": "pearl", "target": 5, "reward_xp": 110},
|
||||
{"id": "depth_25", "desc": "Plonger à -25 m", "kind": "depth", "target": 25, "reward_xp": 50},
|
||||
{"id": "depth_50", "desc": "Plonger à -50 m", "kind": "depth", "target": 50, "reward_xp": 90},
|
||||
{"id": "craft_2", "desc": "Crafter 2 objets", "kind": "craft", "target": 2, "reward_xp": 45},
|
||||
]
|
||||
|
||||
var active: Array = [] # each: Dictionary {template_ref, progress}
|
||||
var _used_ids: Array[String] = []
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
_ensure_active_count()
|
||||
|
||||
|
||||
func _ensure_active_count() -> void:
|
||||
while active.size() < MAX_ACTIVE:
|
||||
var q: Dictionary = _pick_next_quest()
|
||||
if q.is_empty():
|
||||
break
|
||||
active.append({
|
||||
"id": q["id"],
|
||||
"desc": q["desc"],
|
||||
"kind": q["kind"],
|
||||
"item_id": q.get("item_id", -1),
|
||||
"target": q["target"],
|
||||
"reward_xp": q["reward_xp"],
|
||||
"progress": 0,
|
||||
})
|
||||
quests_updated.emit()
|
||||
|
||||
|
||||
func _pick_next_quest() -> Dictionary:
|
||||
var candidates: Array = []
|
||||
var active_ids: Array[String] = []
|
||||
for q: Dictionary in active:
|
||||
active_ids.append(q["id"])
|
||||
for tpl: Dictionary in QUEST_POOL:
|
||||
if active_ids.has(tpl["id"]):
|
||||
continue
|
||||
candidates.append(tpl)
|
||||
if candidates.is_empty():
|
||||
# all in use; allow recycle
|
||||
_used_ids.clear()
|
||||
candidates = QUEST_POOL
|
||||
return candidates[randi() % candidates.size()]
|
||||
|
||||
|
||||
func note_block_break(block_id: int) -> void:
|
||||
for q: Dictionary in active:
|
||||
if q["kind"] == "break" and q["item_id"] == block_id:
|
||||
_progress(q, 1)
|
||||
|
||||
|
||||
func note_pearl_collected() -> void:
|
||||
for q: Dictionary in active:
|
||||
if q["kind"] == "pearl":
|
||||
_progress(q, 1)
|
||||
|
||||
|
||||
func note_craft() -> void:
|
||||
for q: Dictionary in active:
|
||||
if q["kind"] == "craft":
|
||||
_progress(q, 1)
|
||||
|
||||
|
||||
func note_depth(depth_m: int) -> void:
|
||||
for q: Dictionary in active:
|
||||
if q["kind"] == "depth" and depth_m >= q["target"]:
|
||||
q["progress"] = q["target"]
|
||||
_check_complete(q)
|
||||
|
||||
|
||||
func _progress(q: Dictionary, amount: int) -> void:
|
||||
q["progress"] = mini(q["progress"] + amount, q["target"])
|
||||
_check_complete(q)
|
||||
quests_updated.emit()
|
||||
|
||||
|
||||
func _check_complete(q: Dictionary) -> void:
|
||||
if q["progress"] < q["target"]:
|
||||
return
|
||||
# Complete
|
||||
var snapshot: Dictionary = q.duplicate()
|
||||
quest_completed.emit(snapshot)
|
||||
var pp: Node = get_node_or_null("/root/PlayerProgress")
|
||||
if pp != null:
|
||||
pp.award(q["reward_xp"], "quête: %s" % q["desc"], Vector3.ZERO)
|
||||
active.erase(q)
|
||||
_ensure_active_count()
|
||||
131
scripts/progression/SaveManager.gd
Normal file
131
scripts/progression/SaveManager.gd
Normal file
@@ -0,0 +1,131 @@
|
||||
extends Node
|
||||
## SaveManager — autoload singleton
|
||||
## Sauvegarde automatique (toutes les 30s + à la mort).
|
||||
## Persiste : XP, niveau, inventaire, achievements débloqués, position spawn.
|
||||
|
||||
const SAVE_PATH := "user://save.json"
|
||||
const AUTOSAVE_INTERVAL: float = 30.0
|
||||
|
||||
var _autosave_timer: float = 0.0
|
||||
var _player: Node = null
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
# Connect mort du joueur dès que le nœud player existe
|
||||
# (on le cherche au premier process car le joueur est spawné après)
|
||||
pass
|
||||
|
||||
|
||||
func register_player(player: Node) -> void:
|
||||
_player = player
|
||||
if player.has_signal("player_died"):
|
||||
if not player.player_died.is_connected(_on_player_died):
|
||||
player.player_died.connect(_on_player_died)
|
||||
|
||||
|
||||
func _process(delta: float) -> void:
|
||||
_autosave_timer += delta
|
||||
if _autosave_timer >= AUTOSAVE_INTERVAL:
|
||||
_autosave_timer = 0.0
|
||||
save_game()
|
||||
|
||||
|
||||
func save_game() -> void:
|
||||
var data: Dictionary = {}
|
||||
|
||||
# --- PlayerProgress ---
|
||||
var pp: Node = get_node_or_null("/root/PlayerProgress")
|
||||
if pp != null:
|
||||
data["xp"] = pp.current_xp
|
||||
data["level"] = pp.level
|
||||
|
||||
# --- Inventory ---
|
||||
var main: Node = get_tree().get_first_node_in_group("main")
|
||||
if main != null and main.get("inventory") != null:
|
||||
var inv: Node = main.inventory
|
||||
var slots: Array = []
|
||||
if inv.has_method("get_all_slots"):
|
||||
slots = inv.call("get_all_slots")
|
||||
else:
|
||||
# Fallback: read slots array directly if exposed
|
||||
if "slots" in inv:
|
||||
for s in inv.slots:
|
||||
slots.append(s)
|
||||
data["inventory"] = slots
|
||||
|
||||
# --- Achievements ---
|
||||
var am: Node = get_node_or_null("/root/AchievementManager")
|
||||
if am != null and am.get("_unlocked") != null:
|
||||
data["achievements"] = am._unlocked
|
||||
|
||||
# --- Spawn position ---
|
||||
if is_instance_valid(_player):
|
||||
var pos: Vector3 = _player.global_position
|
||||
data["spawn"] = {"x": pos.x, "y": pos.y, "z": pos.z}
|
||||
|
||||
# Write JSON
|
||||
var file := FileAccess.open(SAVE_PATH, FileAccess.WRITE)
|
||||
if file == null:
|
||||
push_warning("SaveManager: impossible d'ouvrir " + SAVE_PATH)
|
||||
return
|
||||
file.store_string(JSON.stringify(data, "\t"))
|
||||
file.close()
|
||||
|
||||
|
||||
func load_game() -> bool:
|
||||
if not FileAccess.file_exists(SAVE_PATH):
|
||||
return false
|
||||
|
||||
var file := FileAccess.open(SAVE_PATH, FileAccess.READ)
|
||||
if file == null:
|
||||
return false
|
||||
var raw: String = file.get_as_text()
|
||||
file.close()
|
||||
|
||||
var parsed = JSON.parse_string(raw)
|
||||
if parsed == null or not (parsed is Dictionary):
|
||||
push_warning("SaveManager: save corrompu")
|
||||
return false
|
||||
|
||||
var data: Dictionary = parsed
|
||||
|
||||
# --- PlayerProgress ---
|
||||
var pp: Node = get_node_or_null("/root/PlayerProgress")
|
||||
if pp != null:
|
||||
if data.has("xp"):
|
||||
pp.current_xp = int(data["xp"])
|
||||
if data.has("level"):
|
||||
pp.level = int(data["level"])
|
||||
pp.emit_state()
|
||||
|
||||
# --- Achievements ---
|
||||
var am: Node = get_node_or_null("/root/AchievementManager")
|
||||
if am != null and data.has("achievements"):
|
||||
am._unlocked = data["achievements"]
|
||||
|
||||
# --- Inventory ---
|
||||
var main: Node = get_tree().get_first_node_in_group("main")
|
||||
if main != null and main.get("inventory") != null and data.has("inventory"):
|
||||
var inv: Node = main.inventory
|
||||
var slots: Array = data["inventory"]
|
||||
if inv.has_method("load_slots"):
|
||||
inv.call("load_slots", slots)
|
||||
elif "slots" in inv:
|
||||
# Best-effort direct assignment
|
||||
for i: int in range(min(slots.size(), inv.slots.size())):
|
||||
inv.slots[i] = slots[i]
|
||||
|
||||
# --- Spawn position ---
|
||||
if is_instance_valid(_player) and data.has("spawn"):
|
||||
var sp: Dictionary = data["spawn"]
|
||||
_player.global_position = Vector3(
|
||||
float(sp.get("x", 0.0)),
|
||||
float(sp.get("y", 55.0)),
|
||||
float(sp.get("z", 0.0))
|
||||
)
|
||||
|
||||
return true
|
||||
|
||||
|
||||
func _on_player_died() -> void:
|
||||
save_game()
|
||||
42
scripts/progression/XpPopup.gd
Normal file
42
scripts/progression/XpPopup.gd
Normal file
@@ -0,0 +1,42 @@
|
||||
extends Node3D
|
||||
|
||||
var _label: Label3D = null
|
||||
var _elapsed: float = 0.0
|
||||
var _lifetime: float = 1.4
|
||||
var _drift: float = 1.6
|
||||
|
||||
|
||||
static func spawn(parent: Node, text: String, world_pos: Vector3, color: Color = Color(1.0, 0.9, 0.2)) -> void:
|
||||
var popup := Node3D.new()
|
||||
popup.set_script(load("res://scripts/progression/XpPopup.gd"))
|
||||
parent.add_child(popup)
|
||||
popup.global_position = world_pos
|
||||
popup.configure(text, color)
|
||||
|
||||
|
||||
func configure(text: String, color: Color) -> void:
|
||||
_label = Label3D.new()
|
||||
_label.text = text
|
||||
_label.font_size = 54
|
||||
_label.outline_size = 6
|
||||
_label.modulate = color
|
||||
_label.billboard = BaseMaterial3D.BILLBOARD_ENABLED
|
||||
_label.no_depth_test = true
|
||||
_label.render_priority = 4
|
||||
_label.pixel_size = 0.008
|
||||
add_child(_label)
|
||||
|
||||
|
||||
func _process(delta: float) -> void:
|
||||
_elapsed += delta
|
||||
var t: float = _elapsed / _lifetime
|
||||
position.y += _drift * delta
|
||||
if _label != null:
|
||||
var alpha: float = 1.0 - clampf(t, 0.0, 1.0)
|
||||
var c: Color = _label.modulate
|
||||
c.a = alpha
|
||||
_label.modulate = c
|
||||
var scale_factor: float = 1.0 + (1.0 - alpha) * 0.4
|
||||
_label.scale = Vector3.ONE * scale_factor
|
||||
if _elapsed >= _lifetime:
|
||||
queue_free()
|
||||
66
scripts/world/Pearl.gd
Normal file
66
scripts/world/Pearl.gd
Normal file
@@ -0,0 +1,66 @@
|
||||
extends Area3D
|
||||
|
||||
signal collected(item_id: int)
|
||||
|
||||
const ITEM_ID_PEARL: int = 105
|
||||
|
||||
var _time: float = 0.0
|
||||
var _mesh: MeshInstance3D = null
|
||||
var _collected: bool = false
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
monitoring = true
|
||||
monitorable = false
|
||||
body_entered.connect(_on_body_entered)
|
||||
_build_visual()
|
||||
_build_shape()
|
||||
add_to_group("pearl")
|
||||
|
||||
|
||||
func _build_visual() -> void:
|
||||
_mesh = MeshInstance3D.new()
|
||||
var sphere := SphereMesh.new()
|
||||
sphere.radius = 0.22
|
||||
sphere.height = 0.44
|
||||
sphere.radial_segments = 12
|
||||
sphere.rings = 8
|
||||
_mesh.mesh = sphere
|
||||
|
||||
var mat := StandardMaterial3D.new()
|
||||
mat.albedo_color = Color(0.96, 0.96, 1.0, 1.0)
|
||||
mat.metallic = 0.3
|
||||
mat.roughness = 0.08
|
||||
mat.emission_enabled = true
|
||||
mat.emission = Color(0.85, 0.92, 1.0)
|
||||
mat.emission_energy_multiplier = 0.8
|
||||
_mesh.material_override = mat
|
||||
|
||||
add_child(_mesh)
|
||||
|
||||
|
||||
func _build_shape() -> void:
|
||||
var shape := CollisionShape3D.new()
|
||||
var s := SphereShape3D.new()
|
||||
s.radius = 0.75
|
||||
shape.shape = s
|
||||
add_child(shape)
|
||||
|
||||
|
||||
func _process(delta: float) -> void:
|
||||
_time += delta
|
||||
if _mesh != null:
|
||||
_mesh.position.y = sin(_time * 2.0) * 0.18
|
||||
_mesh.rotation.y += delta * 1.4
|
||||
|
||||
|
||||
func _on_body_entered(body: Node) -> void:
|
||||
if _collected:
|
||||
return
|
||||
if body.is_in_group("player"):
|
||||
_collected = true
|
||||
var am: Node = get_node_or_null("/root/AudioManager")
|
||||
if am != null and am.has_method("play_pearl_collect"):
|
||||
am.call("play_pearl_collect", global_position)
|
||||
collected.emit(ITEM_ID_PEARL)
|
||||
queue_free()
|
||||
130
scripts/world/PearlSpawner.gd
Normal file
130
scripts/world/PearlSpawner.gd
Normal file
@@ -0,0 +1,130 @@
|
||||
extends Node3D
|
||||
|
||||
@export var player: NodePath = NodePath("")
|
||||
@export var spawn_radius: float = 28.0
|
||||
@export var despawn_radius: float = 55.0
|
||||
@export var max_pearls: int = 3
|
||||
|
||||
const PEARL_SCENE: String = "res://scenes/world/Pearl.tscn"
|
||||
const SPAWN_INTERVAL: float = 9.0
|
||||
|
||||
var _player_node: CharacterBody3D = null
|
||||
var _chunk_manager: Node = null
|
||||
var _pearls: Array[Area3D] = []
|
||||
var _timer: float = 0.0
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
_resolve_player()
|
||||
_find_chunk_manager()
|
||||
|
||||
|
||||
func _resolve_player() -> void:
|
||||
if not player.is_empty():
|
||||
var n: Node = get_node_or_null(player)
|
||||
if n is CharacterBody3D:
|
||||
_player_node = n
|
||||
if _player_node == null:
|
||||
var players := get_tree().get_nodes_in_group("player")
|
||||
if players.size() > 0:
|
||||
_player_node = players[0] as CharacterBody3D
|
||||
|
||||
|
||||
func _find_chunk_manager() -> void:
|
||||
var cm: Node = get_node_or_null("/root/Main/World/ChunkManager")
|
||||
if cm != null:
|
||||
_chunk_manager = cm
|
||||
|
||||
|
||||
func _physics_process(delta: float) -> void:
|
||||
_timer += delta
|
||||
if _timer < SPAWN_INTERVAL:
|
||||
return
|
||||
_timer = 0.0
|
||||
_resolve_player()
|
||||
_tick()
|
||||
|
||||
|
||||
func _tick() -> void:
|
||||
if not is_instance_valid(_player_node):
|
||||
return
|
||||
_cull()
|
||||
if _pearls.size() >= max_pearls:
|
||||
return
|
||||
var pos: Vector3 = _random_spawn_pos()
|
||||
if not _is_valid_spawn(pos):
|
||||
return
|
||||
var packed: PackedScene = load(PEARL_SCENE)
|
||||
if packed == null:
|
||||
return
|
||||
var pearl: Area3D = packed.instantiate() as Area3D
|
||||
if pearl == null:
|
||||
return
|
||||
get_tree().current_scene.add_child(pearl)
|
||||
pearl.global_position = pos
|
||||
if pearl.has_signal("collected"):
|
||||
pearl.collected.connect(_on_pearl_collected)
|
||||
_pearls.append(pearl)
|
||||
|
||||
|
||||
func _random_spawn_pos() -> Vector3:
|
||||
var player_pos: Vector3 = _player_node.global_position
|
||||
var angle: float = randf_range(0.0, TAU)
|
||||
var dist: float = randf_range(12.0, spawn_radius)
|
||||
# Pearls float just above the seabed in the shallow-to-mid zone.
|
||||
var depth: float = randf_range(-22.0, 0.0)
|
||||
return Vector3(
|
||||
player_pos.x + cos(angle) * dist,
|
||||
depth,
|
||||
player_pos.z + sin(angle) * dist
|
||||
)
|
||||
|
||||
|
||||
func _is_valid_spawn(pos: Vector3) -> bool:
|
||||
if _chunk_manager == null:
|
||||
return true
|
||||
if not _chunk_manager.has_method("get_block"):
|
||||
return true
|
||||
var block_id: int = _chunk_manager.call("get_block", pos)
|
||||
# Avoid spawning inside solid blocks (ids 2..9)
|
||||
if block_id >= 2 and block_id <= 9:
|
||||
return false
|
||||
return true
|
||||
|
||||
|
||||
func _cull() -> void:
|
||||
if not is_instance_valid(_player_node):
|
||||
return
|
||||
var ppos: Vector3 = _player_node.global_position
|
||||
var i: int = _pearls.size() - 1
|
||||
while i >= 0:
|
||||
if not is_instance_valid(_pearls[i]):
|
||||
_pearls.remove_at(i)
|
||||
elif _pearls[i].global_position.distance_to(ppos) > despawn_radius:
|
||||
_pearls[i].queue_free()
|
||||
_pearls.remove_at(i)
|
||||
i -= 1
|
||||
|
||||
|
||||
func _on_pearl_collected(item_id: int) -> void:
|
||||
var main: Node = get_tree().get_first_node_in_group("main")
|
||||
if main == null or not ("inventory" in main):
|
||||
return
|
||||
main.inventory.add_item(item_id, 1)
|
||||
var am: Node = get_node_or_null("/root/AudioManager")
|
||||
var player_pos: Vector3 = Vector3.ZERO
|
||||
if is_instance_valid(_player_node):
|
||||
player_pos = _player_node.global_position
|
||||
if am != null and am.has_method("play_bubble_sfx") and is_instance_valid(_player_node):
|
||||
am.call("play_bubble_sfx", player_pos)
|
||||
var pp: Node = get_node_or_null("/root/PlayerProgress")
|
||||
if pp != null:
|
||||
pp.award(pp.XP_PEARL, "perle", player_pos)
|
||||
if main.has_method("_spawn_xp_popup"):
|
||||
main.call("_spawn_xp_popup", pp.XP_PEARL, player_pos + Vector3(0, 1.2, 0))
|
||||
var qm: Node = get_node_or_null("/root/QuestManager")
|
||||
if qm != null:
|
||||
qm.note_pearl_collected()
|
||||
var ach: Node = get_node_or_null("/root/AchievementManager")
|
||||
if ach != null:
|
||||
ach.note_pearl_collected()
|
||||
@@ -64,6 +64,30 @@ static func _get_biome(biome_val: float, ground_height: int) -> int:
|
||||
else:
|
||||
return 3
|
||||
|
||||
|
||||
const BIOME_NAMES: PackedStringArray = PackedStringArray([
|
||||
"Récif Corallien",
|
||||
"Forêt de Kelp",
|
||||
"Plateau Rocheux",
|
||||
"Abysses"
|
||||
])
|
||||
|
||||
|
||||
static func biome_at(world_x: float, world_z: float, seed_val: int) -> int:
|
||||
var noise_biome: FastNoiseLite = FastNoiseLite.new()
|
||||
noise_biome.seed = seed_val + 1337
|
||||
noise_biome.noise_type = FastNoiseLite.TYPE_SIMPLEX_SMOOTH
|
||||
noise_biome.frequency = 0.004
|
||||
var biome_val: float = noise_biome.get_noise_2d(world_x, world_z)
|
||||
return _get_biome(biome_val, 0)
|
||||
|
||||
|
||||
static func biome_name_at(world_x: float, world_z: float, world_y: float, seed_val: int) -> String:
|
||||
if world_y < -40.0:
|
||||
return BIOME_NAMES[3]
|
||||
var b: int = biome_at(world_x, world_z, seed_val)
|
||||
return BIOME_NAMES[b]
|
||||
|
||||
static func _get_block_at(wy: int, ground_height: int, biome: int,
|
||||
wx: int, wz: int, lx: int, lz: int,
|
||||
is_wreck_center: bool, noise_detail: FastNoiseLite, noise_glow: FastNoiseLite, seed: int) -> int:
|
||||
|
||||
Reference in New Issue
Block a user