diff --git a/data/items.json b/data/items.json index e3e52e3..620a0e8 100644 --- a/data/items.json +++ b/data/items.json @@ -1093,6 +1093,59 @@ "marks": "treasure" } }, + { + "id": "ship_compass", + "name": "Ship's Compass", + "category": "tool", + "stack": 1, + "description": "A brass sea compass from the pirate chest, its needle still true. No boat leaves for the Highlands without one.", + "mesh": "ship_compass" + }, + { + "id": "spyglass", + "name": "Spyglass", + "category": "tool", + "stack": 1, + "description": "A captain's brass telescope. Hold right-click to look through it.", + "mesh": "spyglass", + "zoom": 4.0 + }, + { + "id": "pirate_gold", + "name": "Pirate Gold", + "category": "placeable", + "stack": 20, + "description": "Old gold coins from the pirate chest. Nothing on the island takes coin (yet), but a heap of it looks fine in a hut.", + "mesh": "pirate_gold", + "place": { + "piece": "trophy", + "footprint": [ + 0.6, + 0.6 + ] + } + }, + { + "id": "cutlass", + "name": "Cutlass", + "category": "weapon", + "stack": 1, + "description": "A pirate's curved blade: quick, and it hits harder than the iron spear.", + "mesh": "cutlass", + "weapon": { + "damage": 30, + "reach": 2.3, + "stamina": 8, + "cooldown": 0.45 + }, + "durability": 220, + "repair": { + "inputs": { + "iron_bar": 1 + }, + "station": "forge" + } + }, { "id": "smoking_rack", "name": "Smoking Rack", diff --git a/scripts/creatures/creature.gd b/scripts/creatures/creature.gd index 12f18dd..52c333d 100644 --- a/scripts/creatures/creature.gd +++ b/scripts/creatures/creature.gd @@ -60,6 +60,12 @@ const KINDS := { "notice": 0.0, "notice_loud": 0.0, "calm": 30.0, "damage": 20.0, "attack_range": 2.0, "attack_cd": 2.0, "bone": true, "drops": {"iron_nugget": [1, 2]}}, + # 0.6.0: the drowned crew (guard.gd, at a treasure dig: treasure.gd). The + # Cove's barrow guards: slow, hard-hitting, unafraid of fire. + "drowned": {"name": "Drowned Sailor", "hp": 70.0, "walk": 1.3, "run": 3.1, "turn": 4.0, + "notice": 0.0, "notice_loud": 0.0, "calm": 30.0, + "damage": 16.0, "attack_range": 2.0, "attack_cd": 1.8, "bone": true, + "drops": {"salt": [0, 1]}}, # P5: the Old Stag (old_stag.gd), called by the standing stones, and the # spirit wolves it summons (they just run at you: the base "charge"). "old_stag": {"name": "Old Stag", "hp": 900.0, "walk": 2.0, "run": 13.0, "turn": 3.0, @@ -140,7 +146,7 @@ func setup(k: String, w: Node3D, pos: Vector3, seed: int) -> void: var eyes := MeshInstance3D.new() eyes.mesh = parts.eyes var em := StandardMaterial3D.new() - var eye: Color = {"alpha": Color(1.0, 0.18, 0.1), "guard": Color(0.45, 0.8, 1.0), "old_stag": Color(0.55, 0.95, 1.0), + var eye: Color = {"alpha": Color(1.0, 0.18, 0.1), "guard": Color(0.45, 0.8, 1.0), "drowned": Color(0.5, 1.0, 0.6), "old_stag": Color(0.55, 0.95, 1.0), "spirit_wolf": Color(0.55, 0.95, 1.0)}.get(k, Color(1.0, 0.75, 0.3)) em.albedo_color = eye.lerp(Color.WHITE, 0.2) em.emission_enabled = true @@ -164,7 +170,7 @@ func setup(k: String, w: Node3D, pos: Vector3, seed: int) -> void: cap.height = maxf(parts.height, parts.radius * 2.0 + 0.01) cs.shape = cap cs.position = Vector3(0, parts.height * 0.5, 0) - if k != "rabbit" and k != "guard": # (rabbits are round, guards stand upright) + if not k in ["rabbit", "guard", "drowned"]: # (rabbits are round, guards stand upright) # Long bodies: lay the capsule along the spine. cap.height = {"deer": 1.5, "boar": 1.4, "wolf": 1.25, "alpha": 1.65, "bear": 2.0, "fox": 0.8, "crow": 0.3, "old_stag": 3.3, "spirit_wolf": 1.25}.get(k, 1.4) cs.rotation.x = PI * 0.5 @@ -516,6 +522,7 @@ const VOICES := { "boar": {"alert": "boar_grunt", "angry": "boar_grunt", "attack": "boar_grunt", "hurt": "boar_squeal", "idle": "boar_grunt"}, "old_stag": {"alert": "stag_bellow", "angry": "stag_bellow", "attack": "stag_snort", "hurt": "stag_snort"}, "guard": {"alert": "guard_rattle", "angry": "guard_rattle", "attack": "guard_rattle"}, + "drowned": {"alert": "guard_rattle", "angry": "guard_rattle", "attack": "guard_rattle"}, "crow": {"alert": "crow_caw", "hurt": "crow_caw", "idle": "crow_caw"}, "fox": {"alert": "fox_yip", "hurt": "fox_yip"}, } diff --git a/scripts/creatures/creature_meshes.gd b/scripts/creatures/creature_meshes.gd index 5a83fd0..8232c87 100644 --- a/scripts/creatures/creature_meshes.gd +++ b/scripts/creatures/creature_meshes.gd @@ -23,6 +23,7 @@ static func build(kind: String) -> Dictionary: "fox": d = _scaled(_wolf_parts(Color(0.78, 0.4, 0.15), Color(0.92, 0.88, 0.8), 281), 0.6) "crow": d = _crow() "guard": d = _guard() + "drowned": d = _drowned() "old_stag": d = _scaled(_stag(), 2.1) "spirit_wolf": d = _wolf_parts(Color(0.62, 0.72, 0.78), Color(0.85, 0.92, 0.95), 351) _: d = _boar() @@ -253,6 +254,53 @@ static func _guard() -> Dictionary: "hips": [Vector3(-0.1, 0.93, 0), Vector3(0.1, 0.93, 0)], "radius": 0.3, "height": 1.85} +## A drowned sailor (0.6.0): the barrow guard's bones, sea-greyed and hung +## with weed, a rag of a shirt, a bandana, and a cutlass; no shield or helm. +static func _drowned() -> Dictionary: + var bone := Color(0.62, 0.66, 0.58) + var old := Color(0.44, 0.5, 0.42) + var weed := Color(0.16, 0.32, 0.18) + var rag := Color(0.36, 0.33, 0.27) + var red := Color(0.45, 0.12, 0.1) + var steel := Color(0.5, 0.52, 0.55) + var dark := Color(0.05, 0.07, 0.06) + var st := P._begin() + var r := _rng(331) + var n := _noise(331, 2.2) + P._blob(st, Vector3(0, 0.95, 0), Vector3(0.17, 0.07, 0.1), n, 0.2, old, r, 0) # pelvis + P._cylinder(st, Vector3(0, 0.97, 0.02), Vector3(0, 1.45, 0.0), 0.035, 0.03, 5, bone, r) # spine + for i in range(4): # ribs + var y := 1.18 + 0.075 * i + P._cylinder(st, Vector3(-0.15 + 0.01 * i, y, -0.02), Vector3(0.15 - 0.01 * i, y, -0.02), 0.018, 0.018, 4, bone, r) + P._blob(st, Vector3(0.02, 1.3, 0.0), Vector3(0.19, 0.17, 0.12), n, 0.45, rag, r, 0) # shirt, in tatters + P._cylinder(st, Vector3(-0.22, 1.47, 0), Vector3(0.22, 1.47, 0), 0.025, 0.025, 4, bone, r) # shoulders + P._blob(st, Vector3(0, 1.66, -0.02), Vector3(0.11, 0.13, 0.12), n, 0.12, bone, r, 1) # skull + P._blob(st, Vector3(0, 1.56, -0.08), Vector3(0.07, 0.04, 0.06), n, 0.1, old, r, 0) # jaw + P._blob(st, Vector3(0, 1.75, 0.0), Vector3(0.12, 0.05, 0.13), n, 0.15, red, r, 0) # bandana + for s in [-1.0, 1.0]: + P._blob(st, Vector3(s * 0.045, 1.67, -0.115), Vector3(0.03, 0.025, 0.012), n, 0.0, dark, r, 0) # sockets + for i in range(5): # weed hanging off + var x := -0.16 + i * 0.08 + P._cylinder(st, Vector3(x, 1.45, -0.04), Vector3(x + 0.02, 1.05 - 0.05 * (i % 2), -0.06), 0.012, 0.004, 3, weed, r) + # Right arm forward with the cutlass, left hanging. + P._cylinder(st, Vector3(0.22, 1.47, 0), Vector3(0.28, 1.2, -0.12), 0.025, 0.022, 4, bone, r) + P._cylinder(st, Vector3(0.28, 1.2, -0.12), Vector3(0.27, 1.08, -0.38), 0.022, 0.02, 4, bone, r) + P._cylinder(st, Vector3(0.27, 1.08, -0.4), Vector3(0.33, 1.42, -0.85), 0.035, 0.008, 4, steel, r) # curved blade + P._cylinder(st, Vector3(0.21, 1.06, -0.38), Vector3(0.33, 1.1, -0.4), 0.02, 0.02, 4, steel, r) # guard + P._cylinder(st, Vector3(-0.22, 1.47, 0), Vector3(-0.27, 1.15, 0.02), 0.025, 0.022, 4, bone, r) + P._cylinder(st, Vector3(-0.27, 1.15, 0.02), Vector3(-0.26, 0.9, -0.02), 0.022, 0.02, 4, bone, r) + var est := P._begin() + for s in [-1.0, 1.0]: + P._blob(est, Vector3(s * 0.045, 1.67, -0.125), Vector3.ONE * 0.012, n, 0.0, Color(0.5, 1.0, 0.6), r, 0) + var lst := P._begin() + P._cylinder(lst, Vector3(0, 0, 0), Vector3(0, -0.45, 0.02), 0.03, 0.025, 4, bone, r) + P._cylinder(lst, Vector3(0, -0.45, 0.02), Vector3(0, -0.9, 0), 0.025, 0.022, 4, bone, r) + P._blob(lst, Vector3(0, -0.2, 0), Vector3(0.07, 0.18, 0.07), n, 0.4, rag, r, 0) # breeches + P._blob(lst, Vector3(0, -0.9, -0.05), Vector3(0.04, 0.025, 0.08), n, 0.1, old, r, 0) + return {"body": P._commit(st), "eyes": P._commit(est), "leg": P._commit(lst), + "hips": [Vector3(-0.1, 0.93, 0), Vector3(0.1, 0.93, 0)], + "radius": 0.3, "height": 1.85} + ## The Old Stag (before scaling up): a pale, moss-hung spirit deer with a ## huge crown of antlers. static func _stag() -> Dictionary: diff --git a/scripts/creatures/guard.gd b/scripts/creatures/guard.gd index f81d03f..54ea799 100644 --- a/scripts/creatures/guard.gd +++ b/scripts/creatures/guard.gd @@ -18,11 +18,12 @@ func display_name() -> String: func interact_prompt(p: Node) -> String: if is_dead(): return "Bones [E] Search" - var s := "Barrow guard" + # (A barrow guard, or a drowned sailor at a treasure dig: treasure.gd.) + var s: String = def.name match state: - "rest": s = "Barrow guard (still... for now)" - "charge": s = "Barrow guard (coming for you!)" - "home": s = "Barrow guard (going back to its post)" + "rest": s += " (still... for now)" + "charge": s += " (coming for you!)" + "home": s += " (going back to its post)" if not Actions.melee_stats(p).armed and not ItemDB.get_item(p.held_item_id()).has("bow"): s += " (you need a weapon: a club cracks bone)" return s @@ -46,7 +47,7 @@ func _think(dt: float) -> void: _goal_d = d if d < def.attack_range and _attack_cd <= 0.0 and _can_reach(player): _attack_cd = def.attack_cd - player.survival.damage(def.damage * world.difficulty_mult("damage"), global_position, "a barrow guard") + player.survival.damage(def.damage * world.difficulty_mult("damage"), global_position, "a %s" % String(def.name).to_lower()) "home": if barrow.wakes_for(player) and barrow.center().distance_to(pp) < barrow.LEASH: state = "charge" diff --git a/scripts/items/item_meshes.gd b/scripts/items/item_meshes.gd index ce45511..58cc599 100644 --- a/scripts/items/item_meshes.gd +++ b/scripts/items/item_meshes.gd @@ -38,6 +38,10 @@ static func build(key: String) -> ArrayMesh: "stick": m = _stick() "driftwood": m = _driftwood() "treasure_map": m = _treasure_map() + "ship_compass": m = _ship_compass() + "spyglass": m = _spyglass() + "pirate_gold": m = _pirate_gold() + "cutlass": m = _cutlass() "stone": m = _pebble(11, STONE, Vector3(0.14, 0.10, 0.12)) "flint": m = _pebble(23, FLINT, Vector3(0.12, 0.07, 0.15), 0.5) "wood": m = _wood() @@ -206,6 +210,50 @@ static func _treasure_map() -> ArrayMesh: P._cylinder(st, Vector3(-0.01, 0.04, 0), Vector3(0.01, 0.04, 0), 0.043, 0.043, 8, Color(0.45, 0.12, 0.08), r) return P._commit(st) +const BRASS := Color(0.78, 0.6, 0.25) +const GOLD := Color(0.95, 0.75, 0.2) + +## A brass sea compass: a flat round case, a glass face, a red needle. +static func _ship_compass() -> ArrayMesh: + var st := P._begin() + var r := _rng(47) + P._cylinder(st, Vector3(0, 0.0, 0), Vector3(0, 0.05, 0), 0.11, 0.11, 12, BRASS, r, -1.0, BRASS) + P._cylinder(st, Vector3(0, 0.05, 0), Vector3(0, 0.056, 0), 0.095, 0.095, 12, Color(0.9, 0.88, 0.8), r, -1.0, Color(0.9, 0.88, 0.8)) + P._cylinder(st, Vector3(0, 0.06, 0.07), Vector3(0, 0.06, -0.07), 0.012, 0.004, 4, Color(0.75, 0.1, 0.08), r) + return P._commit(st) + +## A brass telescope, drawn out: three tubes, the big end forward. +static func _spyglass() -> ArrayMesh: + var st := P._begin() + var r := _rng(49) + P._cylinder(st, Vector3(0, 0.04, 0.22), Vector3(0, 0.04, 0.06), 0.03, 0.03, 8, BRASS, r, -1.0, BRASS) + P._cylinder(st, Vector3(0, 0.04, 0.06), Vector3(0, 0.04, -0.1), 0.036, 0.036, 8, Color(0.3, 0.2, 0.12), r, -1.0, BRASS) + P._cylinder(st, Vector3(0, 0.04, -0.1), Vector3(0, 0.04, -0.26), 0.044, 0.048, 8, BRASS, r, -1.0, Color(0.6, 0.7, 0.8)) + return P._commit(st) + +## A heap of old gold coins, and a few spilled. +static func _pirate_gold() -> ArrayMesh: + var st := P._begin() + var r := _rng(53) + var n := FastNoiseLite.new() + n.seed = 53 + P._blob(st, Vector3(0, 0.06, 0), Vector3(0.2, 0.09, 0.18), n, 0.25, GOLD, r, 1) + for i in range(7): + var a := i * 0.9 + var c := Vector3(cos(a) * (0.16 + 0.05 * (i % 3)), 0.01, sin(a) * (0.16 + 0.05 * (i % 3))) + P._cylinder(st, c, c + Vector3(0, 0.012, 0), 0.03, 0.03, 8, GOLD.darkened(0.1 * (i % 3)), r, -1.0, GOLD) + return P._commit(st) + +## A cutlass: a curved, broad blade, a brass basket guard, a dark grip. +static func _cutlass() -> ArrayMesh: + var st := P._begin() + var r := _rng(59) + _handle(st, 0.14, r) + P._cylinder(st, Vector3(-0.06, 0.15, 0), Vector3(0.06, 0.15, 0), 0.025, 0.025, 5, BRASS, r) + P._cylinder(st, Vector3(0, 0.16, 0), Vector3(0.02, 0.45, 0), 0.03, 0.028, 4, Color(0.72, 0.74, 0.78), r) + P._cylinder(st, Vector3(0.02, 0.45, 0), Vector3(0.08, 0.72, 0), 0.028, 0.006, 4, Color(0.72, 0.74, 0.78), r) + return P._commit(st) + static func _wood() -> ArrayMesh: var st := P._begin() var r := _rng(9) diff --git a/scripts/net/creature_net.gd b/scripts/net/creature_net.gd index 48368f2..d19cd4b 100644 --- a/scripts/net/creature_net.gd +++ b/scripts/net/creature_net.gd @@ -99,7 +99,7 @@ func _server_tick(w: Node3D, delta: float) -> void: const PER_PACKET := 40 const ENTRY := 23 -const KIND_IDS := ["rabbit", "deer", "boar", "wolf", "alpha", "bear", "crow", "fox", "guard", "old_stag", "spirit_wolf"] +const KIND_IDS := ["rabbit", "deer", "boar", "wolf", "alpha", "bear", "crow", "fox", "guard", "old_stag", "spirit_wolf", "drowned"] const STATE_IDS := ["idle", "wander", "flee", "charge", "dead", "rest", "alert", "stalk", "lunge", "retreat", "home", "defend", "bash", "warn", "raid", "eat", "fly", "fly_off", "peck", "sneak", "carry", "circle", "telegraph", "stunned", "summon", "stomp"] @@ -198,7 +198,7 @@ func _s2c_life(packed: PackedByteArray, fl: Array) -> void: var c: Node3D = got if is_instance_valid(got) else null if c == null: # (Kinds with their own look: the bear rears up, a crow flaps.) - var script: Script = {"bear": BearScript, "crow": CrowScript, "guard": GuardScript, "old_stag": StagScript}.get(kind, Creature) + var script: Script = {"bear": BearScript, "crow": CrowScript, "guard": GuardScript, "drowned": GuardScript, "old_stag": StagScript}.get(kind, Creature) c = script.new() c.puppet = true c.setup(kind, world, e[2], cid) diff --git a/scripts/net/player_net.gd b/scripts/net/player_net.gd index 58557e1..6d7685a 100644 --- a/scripts/net/player_net.gd +++ b/scripts/net/player_net.gd @@ -354,7 +354,11 @@ func _run(p: Node3D, what: String, a: Array) -> void: "hit": var n: Node3D = w.props.resource_nodes.get(_s(a, 0)) var at: Vector3 = _v(a, 1) - if n == null and w.tunnel != null: + if n == null and _s(a, 0) == "treasure:" + String(p.player_id) and w.treasure != null: + var m: Node3D = w.treasure.mound_for(String(p.player_id)) # (your own X: treasure.gd) + if m != null and at.is_finite() and at.distance_to(m.global_position) < 2.5: + Actions.harvest_hit(p, m, at) + elif n == null and w.tunnel != null: var f: Node3D = w.tunnel.face_by_uid(_s(a, 0)) # (the cliff tunnel's seam or face) if f != null and at.is_finite(): Actions.harvest_hit(p, f, at) # (the tunnel tells everyone: tunnel.gd) @@ -529,7 +533,7 @@ func _run(p: Node3D, what: String, a: Array) -> void: for m in w.get_tree().get_nodes_in_group("processor"): m.step(float(a[0]) if a.size() > 0 and (a[0] is float or a[0] is int) else 60.0) "debug_spawn": - if cheats and Creature.KINDS.has(_s(a, 0)) and not (_s(a, 0) in ["wolf", "alpha", "bear", "crow", "fox", "guard", "old_stag", "spirit_wolf"]) and _v(a, 1).is_finite(): + if cheats and Creature.KINDS.has(_s(a, 0)) and not (_s(a, 0) in ["wolf", "alpha", "bear", "crow", "fox", "guard", "old_stag", "spirit_wolf", "drowned"]) and _v(a, 1).is_finite(): w.creatures.spawn(_s(a, 0), _v(a, 1)) "debug_boss": # Tests: ["hold", secs] stands the Old Stag still (stunned); diff --git a/scripts/net/world_net.gd b/scripts/net/world_net.gd index d866aba..d926464 100644 --- a/scripts/net/world_net.gd +++ b/scripts/net/world_net.gd @@ -377,6 +377,18 @@ func tunnel_state(d: Dictionary) -> void: if Lands.hub: Lands.push_tunnel(int(d.get("dug", 0))) +## Whose treasure's been dug (and how far): everyone here sees their own +## mound go down, or go. +func treasure_state(d: Dictionary) -> void: + _all("_s2c_treasure", [d]) + mark_changed() + +@rpc("authority", "call_remote", "reliable") +func _s2c_treasure(d: Dictionary) -> void: + if not is_client() or world.treasure == null: + return + world.treasure.load_data(d) + ## Who's had their share of the wreck's sea chest changed: everyone here ## sees whether it's empty for them. func wreck_state(d: Dictionary) -> void: diff --git a/scripts/player/hands.gd b/scripts/player/hands.gd index 2ec98c9..ae76210 100644 --- a/scripts/player/hands.gd +++ b/scripts/player/hands.gd @@ -145,6 +145,7 @@ func _target() -> Object: func _physics_process(delta: float) -> void: _swing_cd = maxf(_swing_cd - delta, 0.0) + _zoom(delta) if player.ui_open: _ghost.visible = false _set_prompt("") @@ -176,6 +177,8 @@ func _physics_process(delta: float) -> void: _set_prompt("[RMB] Wear %s" % def.name) elif def.has("read"): _set_prompt("[RMB] Read the %s" % String(def.name).to_lower()) + elif def.has("zoom"): + _set_prompt("[hold RMB] Look through the %s" % String(def.name).to_lower()) elif def.has("bow"): var n: int = Actions.ammo_count(player, def.bow) _set_prompt("Drawing %d%%" % roundi(draw_power() * 100.0) if _drawing else "[hold LMB] Draw (%d arrows)" % n) @@ -234,6 +237,21 @@ func _update_ghost(def: Dictionary) -> void: _ghost.material_override = _ghost_ok_mat if _place_ok else _ghost_bad_mat _ghost.visible = true +## The spyglass (0.6.0): holding right-click with it narrows the view by its +## "zoom", eased in and out. +var _base_fov := 0.0 +func _zoom(delta: float) -> void: + var cam := get_parent() as Camera3D + if cam == null: + return + if _base_fov <= 0.0: + _base_fov = cam.fov + var z := float(_held_def().get("zoom", 0.0)) + var looking: bool = z > 1.0 and not player.ui_open and Input.is_action_pressed("use_item") \ + and Input.mouse_mode == Input.MOUSE_MODE_CAPTURED + var want: float = _base_fov / z if looking else _base_fov + cam.fov = move_toward(cam.fov, want, delta * 160.0) + func _unhandled_input(event: InputEvent) -> void: if player.ui_open: return diff --git a/scripts/world/crew_site.gd b/scripts/world/crew_site.gd new file mode 100644 index 0000000..cef1ee0 --- /dev/null +++ b/scripts/world/crew_site.gd @@ -0,0 +1,47 @@ +extends Node3D +## Where a band of the drowned crew rose (treasure.gd): what their guard +## brains (guard.gd) ask of their "barrow". They're up and angry from the +## start, chase whoever's nearest within LEASH of the dig, and go back to +## stand round it when nobody is. By day, once nobody's been near for a +## while, they sink back into the ground. + +const LEASH := 20.0 +const SINK_AFTER := 30.0 ## seconds with nobody near, by day + +var world: Node3D +var spot := Vector3.ZERO +var alarmed := true +var crew: Array = [] +var _alone := 0.0 + +func center() -> Vector3: + return spot + +func wakes_for(p: Node3D) -> bool: + return alarmed and not p.survival.dead + +func alarm() -> void: + alarmed = true + +## (Out in the open: they walk on the ground.) +func floor_y_at(_p: Vector3) -> float: + return -INF + +func is_night() -> bool: + var h: float = world.day_night.hours() + return h >= 19.5 or h < 5.5 + +func alive() -> Array: + return crew.filter(func(g): return is_instance_valid(g) and not g.is_dead()) + +func _physics_process(delta: float) -> void: + var anyone := false + for p in world.players(): + if not p.survival.dead and p.global_position.distance_to(spot) < LEASH: + anyone = true + _alone = 0.0 if anyone or is_night() else _alone + delta + if alive().is_empty() or _alone > SINK_AFTER: + for g in crew: + if is_instance_valid(g) and not g.is_dead(): + g.queue_free() + queue_free() diff --git a/scripts/world/crew_site.gd.uid b/scripts/world/crew_site.gd.uid new file mode 100644 index 0000000..bb154fe --- /dev/null +++ b/scripts/world/crew_site.gd.uid @@ -0,0 +1 @@ +uid://dn5ytepdehl8k diff --git a/scripts/world/treasure.gd b/scripts/world/treasure.gd new file mode 100644 index 0000000..9b47667 --- /dev/null +++ b/scripts/world/treasure.gd @@ -0,0 +1,209 @@ +extends Node3D +## The pirate treasure (0.6.0, docs/WORLDLETS_DESIGN.md ยง5). Each player's +## treasure map (the wreck's sea chest) has its own X (world.treasure_spot). +## While you carry your map, a mound of loose earth by a marker stone shows +## at your X (only to you); a pickaxe digs it, and the pirate chest comes up: +## a ship's compass, a spyglass, a cutlass and pirate gold. The map's used up. +## Once for each player. +## +## The drowned crew guard it: digging at night brings them up round you on +## the first blow; by day they rise as the chest comes up. They're the Cove's +## barrow guards (guard.gd, kind "drowned", at a crew_site.gd). +## +## Single-player, all here. Online, the server keeps whose treasure's been +## dug and how far, gives the loot and runs the crew; your game only shows +## your own mound and asks the server to dig it. + +const MoundScript = preload("res://scripts/world/treasure_mound.gd") +const CrewSite = preload("res://scripts/world/crew_site.gd") +const GuardScript = preload("res://scripts/creatures/guard.gd") +const RockPile = preload("res://scripts/world/rock_pile.gd") +const ProcMesh = preload("res://scripts/world/proc_mesh.gd") +const LOOT := {"ship_compass": 1, "spyglass": 1, "cutlass": 1, "pirate_gold": 3} +const CREW := 3 +const DIG_HP := 18.0 + +var world: Node3D +var dug: Array = [] ## the player ids whose treasure's been dug up +var left: Dictionary = {} ## player id -> what's left of their mound (dug a bit) +var mounds: Dictionary = {} ## player id -> treasure_mound.gd +var crews: Dictionary = {} ## player id -> crew_site.gd, while they're up +var force_crew := false ## (tests) raise the crew even with creatures off +var _look_left := 0.0 + +func setup(w: Node3D) -> bool: + world = w + if w.wreck == null: + return false + Actions.harvested.connect(_on_harvested) + add_to_group("treasure") + return true + +# --- the mounds --------------------------------------------------------------------- + +## Player `id`'s mound (made if need be), or null if theirs is dug or they +## have no X here. +func mound_for(id: String) -> StaticBody3D: + if id in dug: + return null + if mounds.has(id) and is_instance_valid(mounds[id]): + return mounds[id] + var at: Vector3 = world.treasure_spot(id) + if at == Vector3.INF: + return null + var m: StaticBody3D = MoundScript.new() + m.name = "Mound_%s" % id.substr(0, 8) + m.treasure = self + m.owner_id = id + m.uid = "treasure:" + id + m.max_hp = DIG_HP + m.hp = clampf(float(left.get(id, DIG_HP)), 0.5, DIG_HP) + m.collision_layer = 1 + m.add_to_group("harvestable") + var cs := CollisionShape3D.new() + var box := BoxShape3D.new() + box.size = Vector3(1.6, 0.6, 1.6) + cs.shape = box + cs.position = Vector3(0, 0.2, 0) + m.add_child(cs) + if not world.server_mode: + _dress(m, id) + add_child(m) + m.global_position = at + mounds[id] = m + return m + +## How a mound looks: loose earth, a marker stone, two sticks crossed in it. +func _dress(m: Node3D, id: String) -> void: + var st := ProcMesh._begin() + var r := RandomNumberGenerator.new() + r.seed = hash(id) + var n := FastNoiseLite.new() + n.seed = hash(id) % 1000 + n.frequency = 1.5 + ProcMesh._blob(st, Vector3(0, 0.05, 0), Vector3(0.9, 0.28, 0.8), n, 0.3, Color(0.34, 0.25, 0.16), r, 0) + ProcMesh._cylinder(st, Vector3(-0.25, 0.1, -0.25), Vector3(0.25, 0.7, 0.25), 0.03, 0.025, 4, Color(0.4, 0.3, 0.18), r) + ProcMesh._cylinder(st, Vector3(0.25, 0.1, -0.25), Vector3(-0.25, 0.7, 0.25), 0.03, 0.025, 4, Color(0.4, 0.3, 0.18), r) + var mi := MeshInstance3D.new() + mi.mesh = ProcMesh._commit(st) + var mat := StandardMaterial3D.new() + mat.vertex_color_use_as_albedo = true + mat.vertex_color_is_srgb = true + mat.roughness = 1.0 + mi.material_override = mat + m.add_child(mi) + RockPile.add(m, null, hash(id) % 997, Vector3(1.3, 0.35, 0.2), Vector3(0.35, 0.8, 0.3), 0.6, RockPile.material(world.terrain), true) + +## (Your game) your mound shows while you carry your map and it's not dug. +func _process(delta: float) -> void: + if world.server_mode: + return + _look_left -= delta + if _look_left > 0.0: + return + _look_left = 0.5 + var me: String = world.my_id() + var want: bool = not me in dug and world.player.inventory.count("treasure_map") > 0 + if want: + mound_for(me) + elif mounds.has(me) and is_instance_valid(mounds[me]): + mounds[me].queue_free() + mounds.erase(me) + +# --- digging ------------------------------------------------------------------------ + +## A blow on a mound (treasure_mound.gd): remembered, and at night the first +## one brings the crew up. +func mound_hit(m: Node) -> void: + left[m.owner_id] = m.hp + if Net.is_server(): + WorldNet.treasure_state(to_data()) + if _night() and not crews.has(m.owner_id): + _raise_crew(m.owner_id, m.global_position) + +func _on_harvested(p: Node, node: Node, felled: bool) -> void: + if not felled or node == null or node.get_script() != MoundScript or node.treasure != self: + return + _dug_up(p, String(node.owner_id), node.global_position) + +## The chest comes up: the loot for the player who dug it, the map used up. +func _dug_up(p: Node, id: String, at: Vector3) -> void: + if id in dug: + return + dug.append(id) + left.erase(id) + if mounds.has(id) and is_instance_valid(mounds[id]): + mounds[id].queue_free() + mounds.erase(id) + p.inventory.remove("treasure_map", 1) + var spilled := false + for item in LOOT: + var rest: int = p.inventory.add(item, int(LOOT[item])) + if rest > 0: + spilled = true + world.drop_item(item, rest, Transform3D(Basis(), at + Vector3(0, 0.3, 0))) + Actions.tell(p, "The pirate chest! A ship's compass, a spyglass, a cutlass and %d pirate gold%s" % [ + int(LOOT.pirate_gold), " (some at your feet: your bag's full)" if spilled else ""]) + Sfx.at("door_open", at) + if not crews.has(id): + _raise_crew(id, at) + if crews.has(id): + Actions.tell(p, "Something's clawing its way up out of the ground...") + if Net.is_server(): + WorldNet.treasure_state(to_data()) + +func _night() -> bool: + var h: float = world.day_night.hours() + return h >= 19.5 or h < 5.5 + +## The drowned crew rise round `at` (where creatures run: here alone, or the +## server; not with --no-creatures, unless a test asks). +func _raise_crew(id: String, at: Vector3) -> void: + if world.is_online(): + return + var running: bool = force_crew or (not "--no-creatures" in OS.get_cmdline_user_args() \ + and (world.server_mode or world.creatures.enabled)) + if not running: + return + var site: Node3D = CrewSite.new() + site.name = "Crew_%s" % id.substr(0, 8) + site.world = world + site.spot = at + add_child(site) + var t: Node3D = world.terrain + for i in range(CREW): + var a := TAU * i / CREW + float(hash(id) % 100) * 0.06 + var post := at + Vector3(cos(a), 0, sin(a)) * 5.0 + post.y = t.sample_height(post.x, post.z) + var g: AnimatableBody3D = GuardScript.new() + g.setup("drowned", world, post, hash(id) % 10000 + i) + g.barrow = site + g.post = post + g.post_yaw = atan2(-(at - post).x, -(at - post).z) + g.state = "charge" + g.yaw = g.post_yaw + g.rotation.y = g.yaw + g.top_level = true + site.add_child(g) + g.global_position = post + site.crew.append(g) + crews[id] = site + site.tree_exited.connect(func(): crews.erase(id)) + +# --- saving ------------------------------------------------------------------------ + +func to_data() -> Dictionary: + return {"dug": dug.duplicate(), "left": left.duplicate()} + +func load_data(d: Dictionary) -> void: + dug = (d.get("dug", []) as Array).duplicate() + left = (d.get("left", {}) as Dictionary).duplicate() + for id in mounds.keys(): + var m: Node = mounds[id] + if not is_instance_valid(m): + mounds.erase(id) + elif id in dug: + m.queue_free() + mounds.erase(id) + else: + m.hp = clampf(float(left.get(id, DIG_HP)), 0.5, DIG_HP) diff --git a/scripts/world/treasure.gd.uid b/scripts/world/treasure.gd.uid new file mode 100644 index 0000000..26bb31a --- /dev/null +++ b/scripts/world/treasure.gd.uid @@ -0,0 +1 @@ +uid://dscr3836h55qo diff --git a/scripts/world/treasure_mound.gd b/scripts/world/treasure_mound.gd new file mode 100644 index 0000000..4bc3f75 --- /dev/null +++ b/scripts/world/treasure_mound.gd @@ -0,0 +1,35 @@ +extends StaticBody3D +## Your X, on the ground (treasure.gd): a mound of loose earth by a marker +## stone, with two sticks crossed in it. It answers like a boulder does +## (Actions.harvest_hit), so a pickaxe digs it; the treasure decides the rest. + +var treasure: Node3D +var owner_id := "" +var uid := "" +var tool_kind := "pickaxe" +var min_tier := 1 +var max_hp := 18.0 +var hp := 18.0 +var depleted := false +var kind := "rock" +var chip_color := Color(0.36, 0.27, 0.18) + +func display_name() -> String: + return "Loose earth" + +func refusal() -> String: + return "" + +func interact_prompt(_player: Node) -> String: + return "Loose earth by a marker stone: X marks the spot. [LMB] Dig with a pickaxe (about %d blows left)" % ceili(hp) + +func interact(player: Node) -> void: + Actions.tell(player, interact_prompt(player)) + +## One blow (from Actions.harvest_hit). +func hit(damage: float, _at: Vector3, _from: Vector3) -> Dictionary: + hp = maxf(hp - damage, 0.0) + treasure.mound_hit(self) + if hp <= 0.0 and not depleted: + depleted = true + return {} diff --git a/scripts/world/treasure_mound.gd.uid b/scripts/world/treasure_mound.gd.uid new file mode 100644 index 0000000..f70a295 --- /dev/null +++ b/scripts/world/treasure_mound.gd.uid @@ -0,0 +1 @@ +uid://dc0iu7ixkjwkv diff --git a/scripts/world/world.gd b/scripts/world/world.gd index 93905cb..2141bdf 100644 --- a/scripts/world/world.gd +++ b/scripts/world/world.gd @@ -56,6 +56,7 @@ var hud: CanvasLayer var pier: Node3D ## the Cove's pier (scripts/world/pier.gd), or null var tunnel: Node3D ## the cliff tunnel (scripts/world/tunnel.gd, J1), or null var wreck: Node3D ## the Cove's wreck (scripts/world/wreck.gd, 0.6.0), or null +var treasure: Node3D ## the pirate treasure (scripts/world/treasure.gd), or null var sea_cave: Node3D ## the Cove's end of it (scripts/world/sea_cave.gd), or null var traveling := false ## on the way to another worldlet (the scene is about to change) var _session_time := 0.0 ## seconds played since the last save @@ -373,6 +374,13 @@ func _on_terrain_ready() -> void: if not menu_mode and wk.place(self): wreck = wk props.keep_clear.append([wk.global_position, 9.0]) + var tr := preload("res://scripts/world/treasure.gd").new() + tr.name = "Treasure" + add_child(tr) + if tr.setup(self): + treasure = tr + else: + tr.free() else: wk.free() # The journey's ways on (J1): the Valley's cliff tunnel and the Cove's sea @@ -701,6 +709,7 @@ func collect_save() -> Dictionary: "stones": stones.save_data() if stones != null else {}, "tunnel": tunnel.to_data() if tunnel != null else {}, "wreck": wreck.to_data() if wreck != null else {}, + "treasure": treasure.to_data() if treasure != null else {}, "map": map_data(), } @@ -718,6 +727,7 @@ func _shared_state(harvested: Dictionary, regrow: Dictionary, taken: Array, drop "stones": stones.save_data() if stones != null else {}, "tunnel": tunnel.to_data() if tunnel != null else {}, "wreck": wreck.to_data() if wreck != null else {}, + "treasure": treasure.to_data() if treasure != null else {}, } ## The live pieces (placed and not being removed). @@ -853,6 +863,8 @@ func _apply_save(s: Dictionary, with_player: bool) -> void: tunnel.load_data(s.get("tunnel", {})) if wreck != null: wreck.load_data(s.get("wreck", {})) + if treasure != null: + treasure.load_data(s.get("treasure", {})) if with_player: load_map_data(String(s.get("map", ""))) # The bear (bear_range.gd): applied when it's set up, after loading. diff --git a/tests/test_net_lands.gd b/tests/test_net_lands.gd index cfdf6d6..327fd28 100644 --- a/tests/test_net_lands.gd +++ b/tests/test_net_lands.gd @@ -157,6 +157,26 @@ func _body() -> void: await _until(func(): return world.wreck.had(world.my_id()), 3.0) check(world.wreck.chest.interact_prompt(player).contains("empty"), "and her game knows the chest's empty for her now") check(world.map_marks().any(func(m): return m.kind == "treasure"), "her X is on her map") + print("digging up the treasure, online") + await _sleep(0.8) + var mound: Node3D = world.treasure.mounds.get(world.my_id()) + check(mound != null, "her mound shows at her X") + if mound != null: + await _tp(mound.global_position + Vector3(1.4, 0.2, 0)) + _hold("antler_pickaxe") + await _sleep(0.3) + for _i in range(12): + if player.inventory.count("ship_compass") > 0: + break + PN.ask("debug_stats", [{"stamina": 100.0}]) + if is_instance_valid(mound): + A.harvest_hit(player, mound, mound.global_position) + await _sleep(0.15) + await _until(func(): return player.inventory.count("ship_compass") == 1, 4.0) + check(player.inventory.count("ship_compass") == 1 and player.inventory.count("cutlass") == 1, + "the server digs it up for her: the compass and the cutlass in her bag") + await _until(func(): return world.my_id() in world.treasure.dug, 3.0) + check(world.my_id() in world.treasure.dug and not world.treasure.mounds.has(world.my_id()), "and her mound's gone") await _across_lands(bob_bed, bob_bed_at) print("back up to the Valley") diff --git a/tests/test_wreck.gd b/tests/test_wreck.gd index c6ad6ec..3bda1bb 100644 --- a/tests/test_wreck.gd +++ b/tests/test_wreck.gd @@ -108,10 +108,76 @@ func _body() -> void: w.explore(wk.global_position) check(w.map_marks().any(func(m): return m.kind == "wreck"), "the wreck's on the map once you've seen it") + print("the dig") + var tr: Node3D = w.treasure + check(tr != null, "the Cove has the pirate treasure") + for _i in range(3): + await process_frame + await create_timer(0.6).timeout + var m: Node3D = tr.mounds.get(me) + check(m != null and m.global_position.distance_to(x) < 0.1, "with your map, a mound of loose earth at your X") + check(tr.mounds.size() == 1, "(only yours: nobody else's shows)") + if m == null: + return + p.inventory.add("stone_pickaxe", 1) + _hold(p, "stone_pickaxe") + tr.force_crew = true + w.day_night.time = 21.0 / 24.0 # night + w.day_night.apply(true) + p.global_position = m.global_position + Vector3(1.4, 0, 0) + p.survival.stamina = 100.0 + A.harvest_hit(p, m, m.global_position) + check(tr.crews.has(me) and tr.crews[me].alive().size() == 3, "at night, the first blow brings up the drowned crew (%d)" % (tr.crews[me].alive().size() if tr.crews.has(me) else 0)) + if tr.crews.has(me): + var g: Node3D = tr.crews[me].alive()[0] + check(g.kind == "drowned" and g.state == "charge", "drowned sailors, coming for you") + g.set_physics_process(false) + for c in tr.crews[me].alive(): + c.queue_free() + tr.crews[me].queue_free() + await process_frame + var blows := 1 + while is_instance_valid(m) and not m.depleted and blows < 40: + p.survival.stamina = 100.0 + A.harvest_hit(p, m, m.global_position) + blows += 1 + check(blows == 18, "18 blows of a stone pickaxe (%d)" % blows) + check(p.inventory.count("ship_compass") == 1 and p.inventory.count("spyglass") == 1 and p.inventory.count("cutlass") == 1 \ + and p.inventory.count("pirate_gold") == 3, "the pirate chest: a ship's compass, a spyglass, a cutlass and 3 pirate gold") + check(p.inventory.count("treasure_map") == 0, "and the map's used up") + check(me in tr.dug and not tr.mounds.has(me), "your treasure's dug: no more mound") + check(not w.map_marks().any(func(mk): return mk.kind == "treasure"), "(and no X)") + _hold(p, "cutlass") + var ms: Dictionary = A.melee_stats(p) + check(bool(ms.armed) and float(ms.damage) >= 30.0, "the cutlass hits hard (%s)" % ms.damage) + var other: Node3D = tr.mound_for("someone-else") + check(other != null and other.global_position.distance_to(x) > 1.0, "someone else's treasure is still in the ground, somewhere else") + check(DB_has("pirate_gold") and A.can_place(p, _slot(p, "pirate_gold")), "pirate gold can be set down as a trophy") + print("saved") w.save_now() await _drop(w) w = await _load() check(w.wreck.had(me) and w.wreck.had("someone-else") and not w.wreck.had("a-stranger"), "who's had their share is kept") + check(me in w.treasure.dug, "and whose treasure's been dug") await _drop(w) _finished = true + +func DB_has(id: String) -> bool: + return not root.get_node("ItemDB").get_item(id).is_empty() + +func _slot(p: Node, id: String) -> int: + for i in range(p.inventory.size()): + var s = p.inventory.get_slot(i) + if s != null and s.id == id: + return i + return -1 + +func _hold(p: Node, id: String) -> void: + var i := _slot(p, id) + if i < 0: + return + var tmp = p.inventory.get_slot(0) + p.inventory.set_slot(0, p.inventory.get_slot(i)) + p.inventory.set_slot(i, tmp) + p.select(0)