Compare commits

17 Commits
v0.2 ... master

Author SHA1 Message Date
f8413ce2d4 [agent:claude-cli] feat(minimap): radar circulaire 32 blocs — joueur (blanc), mobs (rouge pulsé), perles (jaune), blocs biome, orientation joueur, dessin CanvasItem natif 2026-04-21 08:44:30 +00:00
42101246d9 [agent:claude-cli] feat(mobs): shark pursuit 15u + dégâts -20HP + knockback, fish school fuite boost, drops requin(dent)/méduse(gelée), recettes Amulette T2 + Lampe Portable 2026-04-21 08:43:17 +00:00
64b69fd181 [agent:claude-cli] feat(boost): turbo boost avec énergie/cooldown, barre HUD dynamique, speed lines overlay et son distinct au déclenchement 2026-04-21 08:41:47 +00:00
6cb5925da8 [agent:claude-cli] feat(save): save/load automatique (30s + mort) — user://save.json, persist XP/level/inventory/achievements/position, autoload SaveManager 2026-04-21 08:40:22 +00:00
2c49e0c9db [agent:claude-cli] feat(audio): système audio complet — swim loop, boost, block break, echo ping, pearl collect, mort/respawn, deep sea ambient (placeholders AudioStreamGenerator si .ogg absent) 2026-04-21 08:38:59 +00:00
2e4d697977 [agent:surfer] lien infra repo dans AI_CONTEXT.md 2026-04-21 00:35:55 +00:00
d927320358 [agent:surfer] ajout standard AI-ready : AI_CONTEXT, CLAUDE, .gitea workflow 2026-04-21 00:18:32 +00:00
26f9609b53 feat(progression): combo de mining + multiplicateur XP
Nouvel autoload ComboTracker: chaque cassage de bloc dans les 1.8s du
précédent étend le combo (+15% par palier, cap x3.0). Le multiplicateur
s'applique au gain XP de base.

HUD: panneau "COMBO xN (+X%)" animé (pop scale) à partir de x2, avec
barre de temps restant qui se vide. Combo ≥5 au break → bonus XP final
(3 * count) avec popup.

Rétention: incite à chaîner les actions, récompense la dextérité et crée
des pics d'intensité dans la boucle.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 18:14:02 +00:00
27459e1eaa feat(gameplay): consommables utilisables avec touche [F]
- DolphinController expose heal/feed/refill_oxygen + signal
  use_consumable_requested (bindé sur F)
- Main.gd table CONSUMABLE_EFFECTS:
  · Bulle d'air (102): +40 O₂
  · Algue cuisinée (103): +30 faim, +5 HP
  · Amulette de soin (106): +50 HP
- Popup flottant coloré au-dessus du joueur + son bulle à l'utilisation
- HUD: hint dynamique "[F] Consommer : <item>" quand slot sélectionné = consommable

Boucle court-terme: le craft a enfin un usage direct, récompense lisible.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 18:12:56 +00:00
7f6811995d feat(progression): succès/achievements + toasts notification
Nouvel autoload AchievementManager avec 12 succès (Premier éclat, Mineur
confirmé, Chasseur de perles, Fouilleur d'épaves, Abysses, Légende des
océans, etc.). Chaque déblocage donne un bonus XP et joue un son.

HUD: toasts dorés avec icône + titre + desc + fade-in/out, empilés en
haut centre. Son bulle au déblocage.

Long-terme: cible des milestones cumulatifs qui donnent un sentiment de
progression durable.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 18:11:33 +00:00
610d766cb2 feat(progression): quêtes rotatives court-terme + panel HUD
Nouvel autoload QuestManager avec 11 templates (casser X sable, récolter
coraux, collecter perles, plonger à -25m, crafter 2 objets, etc.).
3 quêtes actives simultanément; complétion → récompense XP + roll d'une
nouvelle quête.

HUD: panneau "OBJECTIFS" top-right avec progression couleur (gris→vert),
bannière centrale "✓ QUÊTE" + son bulle au complete.

Motivation moyen-terme (5-15 min): le joueur a toujours qqch à faire.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 18:10:01 +00:00
984754183f feat(progression): XP, niveaux et popups flottants (+N XP)
Nouvel autoload PlayerProgress qui gagne de l'XP sur:
- cassage de blocs (gain variable selon le type: perle, épave, coral > sand)
- collecte de perles (+25)
- crafting (+10)
- paliers de profondeur atteints (+20 à 10/25/50/75/100m)

HUD: barre XP + label niveau en bas centre, bannière "NIVEAU X" au level-up
(son bulle + fade). Popup 3D "+N XP" spawn au point d'action.

Boucle court-terme dopamine: chaque action = feedback visuel immédiat.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 18:08:26 +00:00
982a4ec23d feat(gameplay): perles collectibles + recette amulette de soin
Area3D Pearl flottant/tournant qui émet une couleur nacrée, spawned
périodiquement près du joueur (PearlSpawner, 3 max, intervalle 9s,
zone haute du seafloor). Collecte automatique au contact, ajout à
l'inventaire (item 105 Perle). Nouvelle recette : 2 perles + 1 corail
rouge → Amulette de soin (item 106, consommable). Spawner désactivé
sur serveur dédié headless.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 18:01:57 +00:00
183bb0691d polish(ambience): depth-based fog/ambient darkening
Interpole en temps réel bg/ambient/fog entre palette de surface (teal
lumineux y=55) et palette abyssale (bleu-noir y=-40). Fog density
augmente en plongée (0.02 → 0.055) pour renforcer la sensation
oppressante des grandes profondeurs. Courbe smoothstep pour transition
organique.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 17:59:08 +00:00
df63dec39b feat(hud): compass + depth + biome indicator
Panneau info top-right avec boussole (8 directions FR), profondeur
en mètres depuis la surface, et nom de biome courant (Récif Corallien,
Forêt de Kelp, Plateau Rocheux, Abysses). Helper biome_name_at dans
WorldGenerator pour requête client-side sans relire les chunks.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 17:58:19 +00:00
63500148db fix(deploy): push_to_gitea.sh points to .0.82 + supports GITEA_TOKEN
The old script hit 192.168.1.86:3000 which never existed on this LAN.
Real Gitea lives on 192.168.0.82:3000. Also prefer GITEA_TOKEN over
embedding a password; GITEA_PASS remains as a fallback.
2026-04-20 12:45:46 +00:00
59a90f621c fix(deploy): use '--' separator so Godot passes --server to user args
Godot reads get_cmdline_user_args() which only contains args after '--'.
Without the separator, NetworkManager never saw --server and the
ENetMultiplayerPeer never bound UDP 7777.

Verified on 192.168.0.89: udp/7777 now listening.
2026-04-20 12:42:00 +00:00
33 changed files with 2430 additions and 102 deletions

22
.claude/settings.json Normal file
View 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
View 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
View 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`.

View 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
View 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
View 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

View File

@@ -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"

View File

@@ -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
View 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 ==="

View File

@@ -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
View 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")

View File

@@ -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

View File

@@ -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,14 +123,176 @@ func play_bubble_sfx(position: Vector3) -> void:
add_child(player)
player.global_position = position
if ResourceLoader.exists(path):
var stream: AudioStream = load(path)
if stream == null:
player.queue_free()
return
if stream != null:
player.stream = stream
player.play()
player.finished.connect(player.queue_free)
return
# 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:

View File

@@ -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)

View File

@@ -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

View File

@@ -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
View 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)

View File

@@ -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()

View File

@@ -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

View File

@@ -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]

View File

@@ -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:

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)

View 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 {}

View 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)

View 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)

View 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()

View 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()

View 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
View 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()

View 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()

View File

@@ -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: