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>
This commit is contained in:
6
scenes/world/Pearl.tscn
Normal file
6
scenes/world/Pearl.tscn
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
[gd_scene load_steps=2 format=3 uid="uid://dauphincraft_pearl"]
|
||||||
|
|
||||||
|
[ext_resource type="Script" path="res://scripts/world/Pearl.gd" id="1_pearl"]
|
||||||
|
|
||||||
|
[node name="Pearl" type="Area3D"]
|
||||||
|
script = ExtResource("1_pearl")
|
||||||
@@ -56,6 +56,10 @@ func _ready() -> void:
|
|||||||
_chat_manager.name = "ChatManager"
|
_chat_manager.name = "ChatManager"
|
||||||
add_child(_chat_manager)
|
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
|
# Audio
|
||||||
AudioManager.play_ambient_loop("underwater_ambient")
|
AudioManager.play_ambient_loop("underwater_ambient")
|
||||||
AudioManager.play_music("underwater_theme")
|
AudioManager.play_music("underwater_theme")
|
||||||
@@ -158,6 +162,18 @@ func _spawn_mobs() -> void:
|
|||||||
mob_spawner.player = mob_spawner.get_path_to(_my_dolphin)
|
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:
|
func _connect_dolphin_signals(dolphin: CharacterBody3D) -> void:
|
||||||
if dolphin.has_signal("block_break_requested"):
|
if dolphin.has_signal("block_break_requested"):
|
||||||
dolphin.block_break_requested.connect(_on_block_break)
|
dolphin.block_break_requested.connect(_on_block_break)
|
||||||
|
|||||||
@@ -27,6 +27,11 @@ static var RECIPES: Array = [
|
|||||||
"inputs": [{"item_id": 4, "count": 4}, {"item_id": 7, "count": 2}],
|
"inputs": [{"item_id": 4, "count": 4}, {"item_id": 7, "count": 2}],
|
||||||
"output": {"item_id": 104, "count": 1}
|
"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}
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ const ITEM_NAMES: Dictionary = {
|
|||||||
102: "Bulle d'air",
|
102: "Bulle d'air",
|
||||||
103: "Algue cuisinee",
|
103: "Algue cuisinee",
|
||||||
104: "Armure ecailles",
|
104: "Armure ecailles",
|
||||||
|
105: "Perle",
|
||||||
|
106: "Amulette de soin",
|
||||||
}
|
}
|
||||||
|
|
||||||
const ITEM_COLORS: Dictionary = {
|
const ITEM_COLORS: Dictionary = {
|
||||||
@@ -34,10 +36,12 @@ const ITEM_COLORS: Dictionary = {
|
|||||||
102: Color(0.2, 0.9, 0.9),
|
102: Color(0.2, 0.9, 0.9),
|
||||||
103: Color(0.05, 0.4, 0.05),
|
103: Color(0.05, 0.4, 0.05),
|
||||||
104: Color(0.1, 0.55, 0.5),
|
104: Color(0.1, 0.55, 0.5),
|
||||||
|
105: Color(0.95, 0.95, 1.0),
|
||||||
|
106: Color(1.0, 0.85, 0.4),
|
||||||
}
|
}
|
||||||
|
|
||||||
const PLACEABLE_IDS: Array = [2, 3, 4, 5, 6, 7, 8]
|
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]
|
const TOOL_IDS: Array = [100, 101, 104]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
62
scripts/world/Pearl.gd
Normal file
62
scripts/world/Pearl.gd
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
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()
|
||||||
|
|
||||||
|
|
||||||
|
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
|
||||||
|
collected.emit(ITEM_ID_PEARL)
|
||||||
|
queue_free()
|
||||||
116
scripts/world/PearlSpawner.gd
Normal file
116
scripts/world/PearlSpawner.gd
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
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")
|
||||||
|
if am != null and am.has_method("play_bubble_sfx") and is_instance_valid(_player_node):
|
||||||
|
am.call("play_bubble_sfx", _player_node.global_position)
|
||||||
Reference in New Issue
Block a user