diff --git a/game/assets/materials/kenney_prototype_gateway_orange.tres b/game/assets/materials/kenney_prototype_gateway_orange.tres new file mode 100644 index 0000000..f2caac3 --- /dev/null +++ b/game/assets/materials/kenney_prototype_gateway_orange.tres @@ -0,0 +1,8 @@ +[gd_resource type="StandardMaterial3D" load_steps=2 format=3] + +[ext_resource type="Texture2D" path="res://assets/textures/kenney_prototype/PNG/Orange/texture_08.png" id="1_gateway"] + +[resource] +albedo_texture = ExtResource("1_gateway") +uv1_triplanar = true +uv1_scale = Vector3(1, 1, 1) diff --git a/game/scenes/Characters/location_player.gd b/game/scenes/Characters/location_player.gd new file mode 100644 index 0000000..36ad8df --- /dev/null +++ b/game/scenes/Characters/location_player.gd @@ -0,0 +1,249 @@ +extends RigidBody3D +# Initially I used a CharacterBody3D, however, I wanted the player to bounce off +# other objects in the environment and that would have required manual handling +# of collisions. So that's why we're using a RigidBody3D instead. +signal vehicle_entered(vehicle: Node) +signal vehicle_exited(vehicle: Node) + +const MOVE_SPEED := 8.0 +const SPRINT_MOVE_SPEED :=13 +const ACCELLERATION := 30.0 +const DECELLERATION := 40.0 +const JUMP_SPEED := 4.0 +const MAX_NUMBER_OF_JUMPS := 2 +const MIN_FOV := 10.0 +const MAX_FOV := 179.0 +const ZOOM_FACTOR := 1.1 # Zoom out when >1, in when < 1 +var mouse_sensitivity := 0.005 +var rotation_x := 0.0 +var rotation_y := 0.0 +var cameraMoveMode := false +var current_number_of_jumps := 0 +var _pending_mouse_delta := Vector2.ZERO +var _last_move_forward := Vector3(0, 0, 1) +var _last_move_right := Vector3(1, 0, 0) +var _camera_offset_local := Vector3.ZERO +var _camera_yaw := 0.0 +var _camera_pitch := 0.0 +var _in_vehicle := false +var _vehicle_collision_layer := 0 +var _vehicle_collision_mask := 0 +var _vehicle_original_parent: Node = null +var _light_was_on := false +var _jump_triggered := false +@onready var _flashlight: SpotLight3D = $SpotLight3D +@onready var _anim_player: AnimationPlayer = find_child("AnimationPlayer", true, false) as AnimationPlayer +@onready var _anim_tree: AnimationTree = find_child("AnimationTree", true, false) as AnimationTree +@onready var _model_root: Node3D = find_child("TestCharAnimated", true, false) as Node3D + +@export var camera_follow_speed := 10.0 +@export var anim_idle_name := "Idle" +@export var anim_walk_name := "Walk" +@export var anim_jump_name := "Jump" +@export var anim_run_name := "Run" +@export var anim_walk_speed_threshold := 0.25 +@export var anim_sprint_speed_threshold := 10.0 + +var jump_sound = preload("res://assets/audio/jump.ogg") +var audio_player = AudioStreamPlayer.new() + +@export var camera_path: NodePath +@onready var cam: Camera3D = get_node(camera_path) if camera_path != NodePath("") else null +@export var phone_path: NodePath +@onready var phone: CanvasLayer = get_node(phone_path) if phone_path != NodePath("") else null +var phone_visible := false + +func _ready() -> void: + add_to_group("player") + if _anim_tree: + _anim_tree.active = false + axis_lock_angular_x = true + axis_lock_angular_z = true + angular_damp = 6.0 + contact_monitor = true + max_contacts_reported = 4 + add_child(audio_player) + audio_player.stream = jump_sound + audio_player.volume_db = -20 + if cam: + _camera_offset_local = cam.transform.origin + _camera_pitch = cam.rotation.x + _camera_yaw = global_transform.basis.get_euler().y + cam.set_as_top_level(true) + cam.global_position = global_position + (Basis(Vector3.UP, _camera_yaw) * _camera_offset_local) + cam.global_rotation = Vector3(_camera_pitch, _camera_yaw, 0.0) + var move_basis := cam.global_transform.basis if cam else global_transform.basis + var forward := move_basis.z + var right := move_basis.x + forward.y = 0.0 + right.y = 0.0 + if forward.length() > 0.0001: + _last_move_forward = forward.normalized() + if right.length() > 0.0001: + _last_move_right = right.normalized() + _vehicle_collision_layer = collision_layer + _vehicle_collision_mask = collision_mask + +func _integrate_forces(state): + if _in_vehicle: + linear_velocity = Vector3.ZERO + return + if cameraMoveMode and _pending_mouse_delta != Vector2.ZERO: + rotation_x -= _pending_mouse_delta.y * mouse_sensitivity + rotation_y -= _pending_mouse_delta.x * mouse_sensitivity + rotation_x = clamp(rotation_x, deg_to_rad(-90), deg_to_rad(90)) + _camera_pitch = rotation_x + rotation.y = rotation_y + _pending_mouse_delta = Vector2.ZERO + + var input2v := Input.get_vector("ui_left", "ui_right", "ui_up", "ui_down") + var forward := Vector3.FORWARD * -1.0 + var right := Vector3.RIGHT + if cam: + forward = cam.global_transform.basis.z + right = cam.global_transform.basis.x + forward.y = 0.0 + right.y = 0.0 + if forward.length() > 0.0001: + forward = forward.normalized() + _last_move_forward = forward + else: + forward = _last_move_forward + if right.length() > 0.0001: + right = right.normalized() + _last_move_right = right + else: + right = _last_move_right + + var dir := (right * input2v.x + forward * input2v.y).normalized() + var target_v := dir * MOVE_SPEED + if Input.is_key_pressed(KEY_SHIFT): + target_v = dir * SPRINT_MOVE_SPEED + + var ax := ACCELLERATION if dir != Vector3.ZERO else DECELLERATION + linear_velocity.x = move_toward(linear_velocity.x, target_v.x, ax * state.step) + linear_velocity.z = move_toward(linear_velocity.z, target_v.z, ax * state.step) + + var on_floor = false + for i in state.get_contact_count(): + var normal = state.get_contact_local_normal(i) + if normal.y > 0.5: + on_floor = true + break + + if Input.is_action_just_pressed("ui_accept") and (on_floor or current_number_of_jumps == 1): + current_number_of_jumps = (current_number_of_jumps + 1) % 2 + linear_velocity.y = JUMP_SPEED + audio_player.play() + _jump_triggered = true + + if cam: + var target_yaw := global_transform.basis.get_euler().y + _camera_yaw = lerp_angle(_camera_yaw, target_yaw, camera_follow_speed * state.step) + var target_basis := Basis(Vector3.UP, _camera_yaw) + var target_pos := global_position + (target_basis * _camera_offset_local) + cam.global_position = cam.global_position.lerp(target_pos, camera_follow_speed * state.step) + cam.global_rotation = Vector3(_camera_pitch, _camera_yaw, 0.0) + + _update_animation(on_floor, state.linear_velocity) + _jump_triggered = false + +func _input(event): + if event.is_action_pressed("player_phone"): + phone_visible = !phone_visible + if phone: + phone.visible = phone_visible + return + + if _in_vehicle: + return + if event is InputEventMouseButton: + if event.button_index == MOUSE_BUTTON_MIDDLE: + if event.pressed: + cameraMoveMode = true + Input.set_mouse_mode(Input.MOUSE_MODE_CAPTURED) + else: + cameraMoveMode = false + Input.set_mouse_mode(Input.MOUSE_MODE_VISIBLE) + + if event is InputEventMouseMotion and cameraMoveMode: + _pending_mouse_delta += event.relative + + if event is InputEventMouseButton and event.pressed: + if event.button_index == MOUSE_BUTTON_WHEEL_UP: + zoom_camera(1.0 / ZOOM_FACTOR) + elif event.button_index == MOUSE_BUTTON_WHEEL_DOWN: + zoom_camera(ZOOM_FACTOR) + + if event.is_action_pressed("player_light"): + _flashlight.visible = !_flashlight.visible + +func zoom_camera(factor): + if cam == null: + return + var new_fov = cam.fov * factor + cam.fov = clamp(new_fov, MIN_FOV, MAX_FOV) + +func _update_animation(on_floor: bool, velocity: Vector3) -> void: + if _anim_player == null: + return + var horizontal_speed := Vector3(velocity.x, 0.0, velocity.z).length() + if _jump_triggered and _anim_player.has_animation(anim_jump_name): + if _anim_player.current_animation != anim_jump_name: + _anim_player.play(anim_jump_name) + return + if not on_floor and _anim_player.has_animation(anim_jump_name): + if _anim_player.current_animation != anim_jump_name: + _anim_player.play(anim_jump_name) + return + if on_floor and horizontal_speed > anim_sprint_speed_threshold and _anim_player.has_animation(anim_run_name): + if _anim_player.current_animation != anim_run_name: + _anim_player.play(anim_run_name) + return + if horizontal_speed > anim_walk_speed_threshold and _anim_player.has_animation(anim_walk_name): + if _anim_player.current_animation != anim_walk_name: + _anim_player.play(anim_walk_name) + return + if _anim_player.has_animation(anim_idle_name): + if _anim_player.current_animation != anim_idle_name: + _anim_player.play(anim_idle_name) + +func enter_vehicle(_vehicle: Node, seat: Node3D, vehicle_camera: Camera3D) -> void: + _in_vehicle = true + freeze = true + sleeping = true + collision_layer = 0 + collision_mask = 0 + _vehicle_original_parent = get_parent() + _light_was_on = _flashlight.visible + _flashlight.visible = false + if _model_root: + _model_root.visible = false + if seat: + reparent(seat, true) + global_transform = seat.global_transform + if cam: + cam.current = false + if vehicle_camera: + vehicle_camera.current = true + vehicle_entered.emit(_vehicle) + +func exit_vehicle(exit_point: Node3D, vehicle_camera: Camera3D) -> void: + _in_vehicle = false + freeze = false + sleeping = false + collision_layer = _vehicle_collision_layer + collision_mask = _vehicle_collision_mask + if _vehicle_original_parent: + reparent(_vehicle_original_parent, true) + _vehicle_original_parent = null + _flashlight.visible = _light_was_on + if _model_root: + _model_root.visible = true + if exit_point: + global_transform = exit_point.global_transform + if vehicle_camera: + vehicle_camera.current = false + if cam: + cam.current = true + vehicle_exited.emit(null) diff --git a/game/scenes/Characters/location_player.gd.uid b/game/scenes/Characters/location_player.gd.uid new file mode 100644 index 0000000..df1190e --- /dev/null +++ b/game/scenes/Characters/location_player.gd.uid @@ -0,0 +1 @@ +uid://kgqaeqappow3 diff --git a/game/scenes/Characters/location_player.tscn b/game/scenes/Characters/location_player.tscn new file mode 100644 index 0000000..84e4288 --- /dev/null +++ b/game/scenes/Characters/location_player.tscn @@ -0,0 +1,28 @@ +[gd_scene load_steps=4 format=3] + +[ext_resource type="Script" path="res://scenes/Characters/location_player.gd" id="1_player_script"] +[ext_resource type="PackedScene" path="res://assets/models/TestCharAnimated.glb" id="2_model"] + +[sub_resource type="SphereShape3D" id="SphereShape3D_player"] + +[node name="LocationPlayer" type="RigidBody3D"] +script = ExtResource("1_player_script") +camera_path = NodePath("Camera3D") + +[node name="CollisionShape3D" type="CollisionShape3D" parent="."] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0.5, 0) +shape = SubResource("SphereShape3D_player") + +[node name="TestCharAnimated" parent="." instance=ExtResource("2_model")] +transform = Transform3D(-0.9998549, 0, 0.01703362, 0, 1, 0, -0.01703362, 0, -0.9998549, 0, 0, 0) + +[node name="Camera3D" type="Camera3D" parent="."] +transform = Transform3D(0.9998477, 0, -0.017452406, 0.0066714617, 0.9238795, 0.38262552, 0.016124869, -0.38268343, 0.92373866, 0, 6, 10) +current = true +fov = 49.0 + +[node name="SpotLight3D" type="SpotLight3D" parent="."] +transform = Transform3D(1, 0, 0, 0, 0.906308, -0.422618, 0, 0.422618, 0.906308, 0, 1.7, -0.35) +visible = false +spot_range = 30.0 +spot_angle = 25.0 diff --git a/game/scenes/Interaction/prototype_gateway.gd b/game/scenes/Interaction/prototype_gateway.gd new file mode 100644 index 0000000..c9fb601 --- /dev/null +++ b/game/scenes/Interaction/prototype_gateway.gd @@ -0,0 +1,31 @@ +@tool +extends Node3D + +@export_file("*.tscn") var target_scene_path := "res://scenes/Levels/transportation_level.tscn": + set(value): + target_scene_path = value + _sync_teleporter() + +@export var target_group: StringName = &"player": + set(value): + target_group = value + _sync_teleporter() + +@export var one_shot := true: + set(value): + one_shot = value + _sync_teleporter() + +@onready var teleporter: Area3D = $Teleporter + + +func _ready() -> void: + _sync_teleporter() + + +func _sync_teleporter() -> void: + if teleporter == null: + return + teleporter.set("target_scene_path", target_scene_path) + teleporter.set("target_group", target_group) + teleporter.set("one_shot", one_shot) diff --git a/game/scenes/Interaction/prototype_gateway.gd.uid b/game/scenes/Interaction/prototype_gateway.gd.uid new file mode 100644 index 0000000..5c8c348 --- /dev/null +++ b/game/scenes/Interaction/prototype_gateway.gd.uid @@ -0,0 +1 @@ +uid://1c0reto6vt6m diff --git a/game/scenes/Interaction/prototype_gateway.tscn b/game/scenes/Interaction/prototype_gateway.tscn new file mode 100644 index 0000000..e7e9be5 --- /dev/null +++ b/game/scenes/Interaction/prototype_gateway.tscn @@ -0,0 +1,117 @@ +[gd_scene load_steps=6 format=3] + +[ext_resource type="Script" path="res://scenes/Interaction/prototype_gateway.gd" id="1_gateway_script"] +[ext_resource type="PackedScene" path="res://scenes/Interaction/scene_teleporter.tscn" id="2_teleporter"] +[ext_resource type="Material" path="res://assets/materials/kenney_prototype_gateway_orange.tres" id="3_gateway_mat"] + +[sub_resource type="BoxShape3D" id="BoxShape3D_gateway"] +size = Vector3(1, 1, 1) + +[sub_resource type="BoxMesh" id="BoxMesh_gateway"] +material = ExtResource("3_gateway_mat") +size = Vector3(1, 1, 1) + +[node name="PrototypeGateway" type="Node3D"] +script = ExtResource("1_gateway_script") + +[node name="LeftBase" type="StaticBody3D" parent="."] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -2, 0.5, 0) + +[node name="CollisionShape3D" type="CollisionShape3D" parent="LeftBase"] +shape = SubResource("BoxShape3D_gateway") + +[node name="MeshInstance3D" type="MeshInstance3D" parent="LeftBase"] +mesh = SubResource("BoxMesh_gateway") + +[node name="LeftMidLow" type="StaticBody3D" parent="."] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -2, 1.5, 0) + +[node name="CollisionShape3D" type="CollisionShape3D" parent="LeftMidLow"] +shape = SubResource("BoxShape3D_gateway") + +[node name="MeshInstance3D" type="MeshInstance3D" parent="LeftMidLow"] +mesh = SubResource("BoxMesh_gateway") + +[node name="LeftMidHigh" type="StaticBody3D" parent="."] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -2, 2.5, 0) + +[node name="CollisionShape3D" type="CollisionShape3D" parent="LeftMidHigh"] +shape = SubResource("BoxShape3D_gateway") + +[node name="MeshInstance3D" type="MeshInstance3D" parent="LeftMidHigh"] +mesh = SubResource("BoxMesh_gateway") + +[node name="LeftTop" type="StaticBody3D" parent="."] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -2, 3.5, 0) + +[node name="CollisionShape3D" type="CollisionShape3D" parent="LeftTop"] +shape = SubResource("BoxShape3D_gateway") + +[node name="MeshInstance3D" type="MeshInstance3D" parent="LeftTop"] +mesh = SubResource("BoxMesh_gateway") + +[node name="RightBase" type="StaticBody3D" parent="."] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 2, 0.5, 0) + +[node name="CollisionShape3D" type="CollisionShape3D" parent="RightBase"] +shape = SubResource("BoxShape3D_gateway") + +[node name="MeshInstance3D" type="MeshInstance3D" parent="RightBase"] +mesh = SubResource("BoxMesh_gateway") + +[node name="RightMidLow" type="StaticBody3D" parent="."] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 2, 1.5, 0) + +[node name="CollisionShape3D" type="CollisionShape3D" parent="RightMidLow"] +shape = SubResource("BoxShape3D_gateway") + +[node name="MeshInstance3D" type="MeshInstance3D" parent="RightMidLow"] +mesh = SubResource("BoxMesh_gateway") + +[node name="RightMidHigh" type="StaticBody3D" parent="."] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 2, 2.5, 0) + +[node name="CollisionShape3D" type="CollisionShape3D" parent="RightMidHigh"] +shape = SubResource("BoxShape3D_gateway") + +[node name="MeshInstance3D" type="MeshInstance3D" parent="RightMidHigh"] +mesh = SubResource("BoxMesh_gateway") + +[node name="RightTop" type="StaticBody3D" parent="."] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 2, 3.5, 0) + +[node name="CollisionShape3D" type="CollisionShape3D" parent="RightTop"] +shape = SubResource("BoxShape3D_gateway") + +[node name="MeshInstance3D" type="MeshInstance3D" parent="RightTop"] +mesh = SubResource("BoxMesh_gateway") + +[node name="TopLeft" type="StaticBody3D" parent="."] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -1, 4.5, 0) + +[node name="CollisionShape3D" type="CollisionShape3D" parent="TopLeft"] +shape = SubResource("BoxShape3D_gateway") + +[node name="MeshInstance3D" type="MeshInstance3D" parent="TopLeft"] +mesh = SubResource("BoxMesh_gateway") + +[node name="TopCenter" type="StaticBody3D" parent="."] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 4.5, 0) + +[node name="CollisionShape3D" type="CollisionShape3D" parent="TopCenter"] +shape = SubResource("BoxShape3D_gateway") + +[node name="MeshInstance3D" type="MeshInstance3D" parent="TopCenter"] +mesh = SubResource("BoxMesh_gateway") + +[node name="TopRight" type="StaticBody3D" parent="."] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 1, 4.5, 0) + +[node name="CollisionShape3D" type="CollisionShape3D" parent="TopRight"] +shape = SubResource("BoxShape3D_gateway") + +[node name="MeshInstance3D" type="MeshInstance3D" parent="TopRight"] +mesh = SubResource("BoxMesh_gateway") + +[node name="Teleporter" parent="." instance=ExtResource("2_teleporter")] +transform = Transform3D(1.15, 0, 0, 0, 1.33, 0, 0, 0, 0.7, 0, 1.5, 0) diff --git a/game/scenes/Levels/location_level.gd b/game/scenes/Levels/location_level.gd new file mode 100644 index 0000000..38858af --- /dev/null +++ b/game/scenes/Levels/location_level.gd @@ -0,0 +1,258 @@ +extends Node3D + +const CHARACTER_API_URL := "https://pchar.ranaze.com/api/Characters" + +@export var tile_size := 4.0 +@export var block_height := 1.0 +@export_range(1, 8, 1) var tile_radius := 3 +@export var tracked_node_path: NodePath +@export var player_spawn_height := 2.0 +@export var border_color: Color = Color(0.05, 0.05, 0.05, 1.0) +@export var border_height_bias := 0.005 +@export var show_tile_labels := true +@export var tile_label_height := 0.01 +@export var tile_label_color: Color = Color(1, 1, 1, 1) + +@onready var _block: MeshInstance3D = $TerrainBlock +@onready var _player: RigidBody3D = $Player +@onready var _camera: Camera3D = $Player/Camera3D + +var _center_coord := Vector2i.ZERO +var _tiles_root: Node3D +var _tracked_node: Node3D +var _tile_nodes: Dictionary = {} +var _camera_start_offset := Vector3(0.0, 6.0, 10.0) +var _border_material: StandardMaterial3D +var _known_locations: Dictionary = {} +var _locations_loaded := false + + +func _ready() -> void: + _tiles_root = Node3D.new() + _tiles_root.name = "GeneratedTiles" + add_child(_tiles_root) + + if _camera: + _camera_start_offset = _camera.position + + _tracked_node = get_node_or_null(tracked_node_path) as Node3D + if _tracked_node == null: + _tracked_node = _player + + var start_coord := SelectedCharacter.get_coord() + _center_coord = Vector2i(roundi(start_coord.x), roundi(start_coord.y)) + + _block.visible = false + await _load_existing_locations() + _ensure_selected_location_exists(_center_coord) + _rebuild_tiles(_center_coord) + _move_player_to_coord(_center_coord) + + +func _process(_delta: float) -> void: + if not _locations_loaded: + return + var target_world_pos := _get_stream_position() + var target_coord := _world_to_coord(target_world_pos) + if target_coord == _center_coord: + return + _center_coord = target_coord + _rebuild_tiles(_center_coord) + + +func _get_stream_position() -> Vector3: + if _tracked_node: + return _tracked_node.global_position + return _coord_to_world(_center_coord) + + +func _world_to_coord(world_pos: Vector3) -> Vector2i: + return Vector2i( + roundi(world_pos.x / tile_size), + roundi(world_pos.z / tile_size) + ) + + +func _coord_to_world(coord: Vector2i) -> Vector3: + return Vector3(coord.x * tile_size, block_height * 0.5, coord.y * tile_size) + + +func _move_player_to_coord(coord: Vector2i) -> void: + if _player == null: + return + _player.global_position = Vector3(coord.x * tile_size, player_spawn_height, coord.y * tile_size) + _player.linear_velocity = Vector3.ZERO + _player.angular_velocity = Vector3.ZERO + + +func _rebuild_tiles(center: Vector2i) -> void: + var wanted_keys: Dictionary = {} + for x in range(center.x - tile_radius, center.x + tile_radius + 1): + for y in range(center.y - tile_radius, center.y + tile_radius + 1): + var coord := Vector2i(x, y) + if not _known_locations.has(coord): + continue + wanted_keys[coord] = true + if _tile_nodes.has(coord): + continue + _spawn_tile(coord, String(_known_locations[coord])) + + for key in _tile_nodes.keys(): + if wanted_keys.has(key): + continue + var tile_node := _tile_nodes[key] as Node3D + if tile_node: + tile_node.queue_free() + _tile_nodes.erase(key) + + +func _spawn_tile(coord: Vector2i, location_name: String) -> void: + var tile_root := Node3D.new() + tile_root.name = "Tile_%d_%d" % [coord.x, coord.y] + tile_root.position = _coord_to_world(coord) + _tiles_root.add_child(tile_root) + + var tile_body := StaticBody3D.new() + tile_body.name = "TileBody" + tile_body.scale = Vector3(tile_size, block_height, tile_size) + tile_root.add_child(tile_body) + + var collision_shape := CollisionShape3D.new() + collision_shape.name = "CollisionShape3D" + collision_shape.shape = BoxShape3D.new() + tile_body.add_child(collision_shape) + + var tile := _block.duplicate() as MeshInstance3D + tile.name = "TileMesh" + tile.visible = true + tile_body.add_child(tile) + + tile.add_child(_create_tile_border()) + if show_tile_labels: + tile_root.add_child(_create_tile_label(location_name)) + + _tile_nodes[coord] = tile_root + + +func _create_tile_border() -> MeshInstance3D: + var top_y := 0.5 + border_height_bias + var corners := [ + Vector3(-0.5, top_y, -0.5), + Vector3(0.5, top_y, -0.5), + Vector3(0.5, top_y, 0.5), + Vector3(-0.5, top_y, 0.5), + ] + + var border_mesh := ImmediateMesh.new() + border_mesh.surface_begin(Mesh.PRIMITIVE_LINES, _get_border_material()) + for idx in range(corners.size()): + var current: Vector3 = corners[idx] + var next: Vector3 = corners[(idx + 1) % corners.size()] + border_mesh.surface_add_vertex(current) + border_mesh.surface_add_vertex(next) + border_mesh.surface_end() + + var border := MeshInstance3D.new() + border.name = "TileBorder" + border.mesh = border_mesh + return border + + +func _get_border_material() -> StandardMaterial3D: + if _border_material: + return _border_material + + _border_material = StandardMaterial3D.new() + _border_material.albedo_color = border_color + _border_material.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED + _border_material.disable_receive_shadows = true + _border_material.no_depth_test = true + return _border_material + + +func _create_tile_label(location_name: String) -> Label3D: + var label := Label3D.new() + label.name = "LocationNameLabel" + label.text = location_name + label.position = Vector3(0.0, (block_height * 0.5) + border_height_bias + tile_label_height, 0.0) + label.rotation_degrees = Vector3(-90.0, 0.0, 0.0) + label.billboard = BaseMaterial3D.BILLBOARD_DISABLED + label.modulate = tile_label_color + label.pixel_size = 0.01 + label.outline_size = 12 + label.no_depth_test = false + return label + + +func _ensure_selected_location_exists(coord: Vector2i) -> void: + if _known_locations.has(coord): + return + _known_locations[coord] = _selected_location_name(coord) + + +func _selected_location_name(coord: Vector2i) -> String: + var selected_name := String(SelectedCharacter.character.get("locationName", "")).strip_edges() + if not selected_name.is_empty(): + return selected_name + var character_name := String(SelectedCharacter.character.get("name", "")).strip_edges() + if not character_name.is_empty(): + return "%s's Location" % character_name + return "Location %d,%d" % [coord.x, coord.y] + + +func _load_existing_locations() -> void: + _locations_loaded = false + _known_locations.clear() + + var character_id := String(SelectedCharacter.character.get("id", SelectedCharacter.character.get("Id", ""))).strip_edges() + if character_id.is_empty(): + push_warning("Selected character is missing an id; cannot load visible locations.") + _locations_loaded = true + return + + var request := HTTPRequest.new() + add_child(request) + + var headers := PackedStringArray() + if not AuthState.access_token.is_empty(): + headers.append("Authorization: Bearer %s" % AuthState.access_token) + + var err := request.request("%s/%s/visible-locations" % [CHARACTER_API_URL, character_id], headers, HTTPClient.METHOD_GET) + if err != OK: + push_warning("Failed to request visible locations: %s" % err) + request.queue_free() + _locations_loaded = true + return + + var result: Array = await request.request_completed + request.queue_free() + + var result_code: int = result[0] + var response_code: int = result[1] + var response_body: String = result[3].get_string_from_utf8() + if result_code != HTTPRequest.RESULT_SUCCESS or response_code < 200 or response_code >= 300: + push_warning("Failed to load visible locations (%s/%s): %s" % [result_code, response_code, response_body]) + _locations_loaded = true + return + + var parsed: Variant = JSON.parse_string(response_body) + if typeof(parsed) != TYPE_ARRAY: + push_warning("Visible locations response was not an array.") + _locations_loaded = true + return + + for item in parsed: + if typeof(item) != TYPE_DICTIONARY: + continue + var location := item as Dictionary + var coord_variant: Variant = location.get("coord", {}) + if typeof(coord_variant) != TYPE_DICTIONARY: + continue + var coord_dict := coord_variant as Dictionary + var coord := Vector2i(int(coord_dict.get("x", 0)), int(coord_dict.get("y", 0))) + var location_name := String(location.get("name", "")).strip_edges() + if location_name.is_empty(): + location_name = "Location %d,%d" % [coord.x, coord.y] + _known_locations[coord] = location_name + + _locations_loaded = true diff --git a/game/scenes/Levels/location_level.gd.uid b/game/scenes/Levels/location_level.gd.uid new file mode 100644 index 0000000..b39fb3a --- /dev/null +++ b/game/scenes/Levels/location_level.gd.uid @@ -0,0 +1 @@ +uid://ctbyn1gws2ahj diff --git a/game/scenes/Levels/location_level.tscn b/game/scenes/Levels/location_level.tscn new file mode 100644 index 0000000..acba1dd --- /dev/null +++ b/game/scenes/Levels/location_level.tscn @@ -0,0 +1,32 @@ +[gd_scene load_steps=8 format=3] + +[ext_resource type="Script" path="res://scenes/Levels/location_level.gd" id="1_level_script"] +[ext_resource type="PackedScene" path="res://scenes/Characters/location_player.tscn" id="2_player_scene"] +[ext_resource type="Material" path="res://assets/materials/kenney_prototype_block_dark.tres" id="3_block_mat"] + +[sub_resource type="BoxMesh" id="BoxMesh_tile"] +material = ExtResource("3_block_mat") +size = Vector3(1, 1, 1) + +[sub_resource type="Environment" id="Environment_location"] +background_mode = 1 +background_color = Color(0.55, 0.72, 0.92, 1) +ambient_light_source = 2 +ambient_light_color = Color(1, 1, 1, 1) +ambient_light_energy = 0.8 + +[node name="LocationLevel" type="Node3D"] +script = ExtResource("1_level_script") +tracked_node_path = NodePath("Player") + +[node name="TerrainBlock" type="MeshInstance3D" parent="."] +mesh = SubResource("BoxMesh_tile") + +[node name="Player" parent="." instance=ExtResource("2_player_scene")] + +[node name="DirectionalLight3D" type="DirectionalLight3D" parent="."] +transform = Transform3D(1, 0, 0, 0, 0.819152, 0.573576, 0, -0.573576, 0.819152, 0, 6, 0) +shadow_enabled = true + +[node name="WorldEnvironment" type="WorldEnvironment" parent="."] +environment = SubResource("Environment_location") diff --git a/microservices/CharacterApi/Controllers/CharactersController.cs b/microservices/CharacterApi/Controllers/CharactersController.cs index 060edc4..a28eeb9 100644 --- a/microservices/CharacterApi/Controllers/CharactersController.cs +++ b/microservices/CharacterApi/Controllers/CharactersController.cs @@ -19,10 +19,10 @@ public class CharactersController : ControllerBase [HttpPost] [Authorize(Roles = "USER,SUPER")] - public async Task Create([FromBody] CreateCharacterRequest req) - { - if (string.IsNullOrWhiteSpace(req.Name)) - return BadRequest("Name required"); + public async Task Create([FromBody] CreateCharacterRequest req) + { + if (string.IsNullOrWhiteSpace(req.Name)) + return BadRequest("Name required"); var userId = User.FindFirstValue(ClaimTypes.NameIdentifier); if (string.IsNullOrWhiteSpace(userId)) @@ -33,6 +33,7 @@ public class CharactersController : ControllerBase OwnerUserId = userId, Name = req.Name.Trim(), Coord = new Coord { X = 0, Y = 0 }, + VisionRadius = 3, CreatedUtc = DateTime.UtcNow }; @@ -42,19 +43,36 @@ public class CharactersController : ControllerBase [HttpGet] [Authorize(Roles = "USER,SUPER")] - public async Task ListMine() - { - var userId = User.FindFirstValue(ClaimTypes.NameIdentifier); - if (string.IsNullOrWhiteSpace(userId)) - return Unauthorized(); + public async Task ListMine() + { + var userId = User.FindFirstValue(ClaimTypes.NameIdentifier); + if (string.IsNullOrWhiteSpace(userId)) + return Unauthorized(); - var characters = await _characters.GetForOwnerAsync(userId); - return Ok(characters); - } - - [HttpDelete("{id}")] - [Authorize(Roles = "USER,SUPER")] - public async Task Delete(string id) + var characters = await _characters.GetForOwnerAsync(userId); + return Ok(characters); + } + + [HttpGet("{id}/visible-locations")] + [Authorize(Roles = "USER,SUPER")] + public async Task VisibleLocations(string id) + { + var userId = User.FindFirstValue(ClaimTypes.NameIdentifier); + if (string.IsNullOrWhiteSpace(userId)) + return Unauthorized(); + + var allowAnyOwner = User.IsInRole("SUPER"); + var character = await _characters.GetForOwnerByIdAsync(id, userId, allowAnyOwner); + if (character is null) + return NotFound(); + + var locations = await _characters.GetVisibleLocationsAsync(character); + return Ok(locations); + } + + [HttpDelete("{id}")] + [Authorize(Roles = "USER,SUPER")] + public async Task Delete(string id) { var userId = User.FindFirstValue(ClaimTypes.NameIdentifier); if (string.IsNullOrWhiteSpace(userId)) diff --git a/microservices/CharacterApi/DOCUMENTS.md b/microservices/CharacterApi/DOCUMENTS.md index 55315cf..9a1ea5e 100644 --- a/microservices/CharacterApi/DOCUMENTS.md +++ b/microservices/CharacterApi/DOCUMENTS.md @@ -12,16 +12,32 @@ Inbound JSON documents ``` Stored documents (MongoDB) -- Character - ```json - { - "id": "string (ObjectId)", - "ownerUserId": "string", - "name": "string", - "coord": { - "x": "number", - "y": "number" - }, - "createdUtc": "string (ISO-8601 datetime)" - } - ``` +- Character + ```json + { + "id": "string (ObjectId)", + "ownerUserId": "string", + "name": "string", + "coord": { + "x": "number", + "y": "number" + }, + "visionRadius": "number", + "createdUtc": "string (ISO-8601 datetime)" + } + ``` + +Outbound JSON documents +- VisibleLocation (`GET /api/characters/{id}/visible-locations`) + ```json + [ + { + "id": "string (ObjectId)", + "name": "string", + "coord": { + "x": "number", + "y": "number" + } + } + ] + ``` diff --git a/microservices/CharacterApi/Models/Character.cs b/microservices/CharacterApi/Models/Character.cs index d5ade03..2055e4a 100644 --- a/microservices/CharacterApi/Models/Character.cs +++ b/microservices/CharacterApi/Models/Character.cs @@ -15,5 +15,7 @@ public class Character public Coord Coord { get; set; } = new(); + public int VisionRadius { get; set; } = 3; + public DateTime CreatedUtc { get; set; } = DateTime.UtcNow; } diff --git a/microservices/CharacterApi/Models/VisibleLocation.cs b/microservices/CharacterApi/Models/VisibleLocation.cs new file mode 100644 index 0000000..36d1b26 --- /dev/null +++ b/microservices/CharacterApi/Models/VisibleLocation.cs @@ -0,0 +1,17 @@ +using MongoDB.Bson; +using MongoDB.Bson.Serialization.Attributes; + +namespace CharacterApi.Models; + +public class VisibleLocation +{ + [BsonId] + [BsonRepresentation(BsonType.ObjectId)] + public string? Id { get; set; } + + [BsonElement("name")] + public string Name { get; set; } = string.Empty; + + [BsonElement("coord")] + public Coord Coord { get; set; } = new(); +} diff --git a/microservices/CharacterApi/README.md b/microservices/CharacterApi/README.md index 4c9db47..0cea884 100644 --- a/microservices/CharacterApi/README.md +++ b/microservices/CharacterApi/README.md @@ -6,4 +6,5 @@ See `DOCUMENTS.md` for request payloads and stored document shapes. ## Endpoints - `POST /api/characters` Create a character. - `GET /api/characters` List characters for the current user. +- `GET /api/characters/{id}/visible-locations` List locations visible to that owned character. - `DELETE /api/characters/{id}` Delete a character owned by the current user. diff --git a/microservices/CharacterApi/Services/CharacterStore.cs b/microservices/CharacterApi/Services/CharacterStore.cs index 433b438..d159452 100644 --- a/microservices/CharacterApi/Services/CharacterStore.cs +++ b/microservices/CharacterApi/Services/CharacterStore.cs @@ -3,31 +3,65 @@ using MongoDB.Driver; namespace CharacterApi.Services; -public class CharacterStore -{ - private readonly IMongoCollection _col; - - public CharacterStore(IConfiguration cfg) - { - var cs = cfg["MongoDB:ConnectionString"] ?? "mongodb://127.0.0.1:27017"; - var dbName = cfg["MongoDB:DatabaseName"] ?? "GameDb"; - var client = new MongoClient(cs); - var db = client.GetDatabase(dbName); - _col = db.GetCollection("Characters"); - - var ownerIndex = Builders.IndexKeys.Ascending(c => c.OwnerUserId); - _col.Indexes.CreateOne(new CreateIndexModel(ownerIndex)); - } +public class CharacterStore +{ + private readonly IMongoCollection _col; + private readonly IMongoCollection _locations; + + public CharacterStore(IConfiguration cfg) + { + var cs = cfg["MongoDB:ConnectionString"] ?? "mongodb://127.0.0.1:27017"; + var dbName = cfg["MongoDB:DatabaseName"] ?? "GameDb"; + var client = new MongoClient(cs); + var db = client.GetDatabase(dbName); + _col = db.GetCollection("Characters"); + _locations = db.GetCollection("Locations"); + + var ownerIndex = Builders.IndexKeys.Ascending(c => c.OwnerUserId); + _col.Indexes.CreateOne(new CreateIndexModel(ownerIndex)); + } public Task CreateAsync(Character character) => _col.InsertOneAsync(character); - public Task> GetForOwnerAsync(string ownerUserId) => - _col.Find(c => c.OwnerUserId == ownerUserId).ToListAsync(); - - public async Task DeleteForOwnerAsync(string id, string ownerUserId, bool allowAnyOwner) - { - var filter = Builders.Filter.Eq(c => c.Id, id); - if (!allowAnyOwner) + public Task> GetForOwnerAsync(string ownerUserId) => + _col.Find(c => c.OwnerUserId == ownerUserId).ToListAsync(); + + public async Task GetForOwnerByIdAsync(string id, string ownerUserId, bool allowAnyOwner) + { + var filter = Builders.Filter.Eq(c => c.Id, id); + if (!allowAnyOwner) + { + filter = Builders.Filter.And( + filter, + Builders.Filter.Eq(c => c.OwnerUserId, ownerUserId) + ); + } + + return await _col.Find(filter).FirstOrDefaultAsync(); + } + + public Task> GetVisibleLocationsAsync(Character character) + { + var radius = character.VisionRadius > 0 ? character.VisionRadius : 3; + var minX = character.Coord.X - radius; + var maxX = character.Coord.X + radius; + var minY = character.Coord.Y - radius; + var maxY = character.Coord.Y + radius; + + var filter = Builders.Filter.And( + Builders.Filter.Gte(l => l.Coord.X, minX), + Builders.Filter.Lte(l => l.Coord.X, maxX), + Builders.Filter.Gte(l => l.Coord.Y, minY), + Builders.Filter.Lte(l => l.Coord.Y, maxY) + ); + + return _locations.Find(filter).ToListAsync(); + } + + public async Task DeleteForOwnerAsync(string id, string ownerUserId, bool allowAnyOwner) + { + var filter = Builders.Filter.Eq(c => c.Id, id); + if (!allowAnyOwner) { filter = Builders.Filter.And( filter,