commit dd7c49014066ea5b3fbb73c8ee6a5068ef38b43d Author: Vojtěch Struhár Date: Tue May 13 11:12:06 2025 +0200 Basic stairs and player setup diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..f28239b --- /dev/null +++ b/.editorconfig @@ -0,0 +1,4 @@ +root = true + +[*] +charset = utf-8 diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..8ad74f7 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +# Normalize EOL for all files that Git considers text files. +* text=auto eol=lf diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0af181c --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +# Godot 4+ specific ignores +.godot/ +/android/ diff --git a/addons/.DS_Store b/addons/.DS_Store new file mode 100644 index 0000000..f352d4a Binary files /dev/null and b/addons/.DS_Store differ diff --git a/addons/portals/.DS_Store b/addons/portals/.DS_Store new file mode 100644 index 0000000..42c1637 Binary files /dev/null and b/addons/portals/.DS_Store differ diff --git a/addons/portals/gizmos/portal_exit_outline.gd b/addons/portals/gizmos/portal_exit_outline.gd new file mode 100644 index 0000000..2a1f5ac --- /dev/null +++ b/addons/portals/gizmos/portal_exit_outline.gd @@ -0,0 +1,58 @@ +extends EditorNode3DGizmoPlugin + +func _init() -> void: + var exit_outline_color = PortalSettings.get_setting("gizmo_exit_outline_color") + create_material("outline", exit_outline_color, false, true, false) + +func _get_gizmo_name() -> String: + return "PortalExitOutlineGizmo" + +func _has_gizmo(for_node_3d: Node3D) -> bool: + return for_node_3d is Portal3D + +func _redraw(gizmo: EditorNode3DGizmo) -> void: + var portal = gizmo.get_node_3d() as Portal3D + assert(portal != null, "This gizmo works only for Portal3D") + gizmo.clear() + + if portal not in EditorInterface.get_selection().get_selected_nodes(): + return + + var ep: Portal3D = portal.exit_portal + if ep == null: + return + + + var extents = Vector3(ep.portal_size.x, ep.portal_size.y, ep._portal_thickness) / 2 + + var lines: Array[Vector3] = [ + # Front rect + extents, extents * Vector3(1, -1, 1), + extents, extents * Vector3(-1, 1, 1), + extents * Vector3(1, -1, 1), extents * Vector3(-1, -1, 1), + extents * Vector3(-1, 1, 1), extents * Vector3(-1, -1, 1), + + # Back rect + - extents, -extents * Vector3(1, -1, 1), + - extents, -extents * Vector3(-1, 1, 1), + - extents * Vector3(1, -1, 1), -extents * Vector3(-1, -1, 1), + - extents * Vector3(-1, 1, 1), -extents * Vector3(-1, -1, 1), + + # Short Z connections + extents * Vector3(1, 1, 1), extents * Vector3(1, 1, -1), + extents * Vector3(1, -1, 1), extents * Vector3(1, -1, -1), + extents * Vector3(-1, 1, 1), extents * Vector3(-1, 1, -1), + extents * Vector3(-1, -1, 1), extents * Vector3(-1, -1, -1), + ] + + # Double each line for visual thickness + #for i in range(lines.size()): + #lines.append(lines[i] + (lines[i].normalized() * 0.005)) + + for i in range(lines.size()): + lines[i] = portal.to_local(ep.to_global(lines[i])) + + gizmo.add_lines( + PackedVector3Array(lines), + get_material("outline", gizmo) + ) diff --git a/addons/portals/gizmos/portal_exit_outline.gd.uid b/addons/portals/gizmos/portal_exit_outline.gd.uid new file mode 100644 index 0000000..6998dc9 --- /dev/null +++ b/addons/portals/gizmos/portal_exit_outline.gd.uid @@ -0,0 +1 @@ +uid://pk5ua52g54m1 diff --git a/addons/portals/gizmos/portal_forward_direction.gd b/addons/portals/gizmos/portal_forward_direction.gd new file mode 100644 index 0000000..0f44864 --- /dev/null +++ b/addons/portals/gizmos/portal_forward_direction.gd @@ -0,0 +1,39 @@ +extends EditorNode3DGizmoPlugin + +func _init() -> void: + var forward_color = PortalSettings.get_setting("gizmo_forward_color") + create_material("forward", forward_color, false, false, false) + + +func _get_gizmo_name() -> String: + return "PortalForwardDirectionGizmo" + +func _has_gizmo(for_node_3d: Node3D) -> bool: + return for_node_3d is Portal3D + +func _redraw(gizmo: EditorNode3DGizmo) -> void: + var portal = gizmo.get_node_3d() as Portal3D + assert(portal != null, "This gizmo works only for Portal3D") + var active: bool = portal in EditorInterface.get_selection().get_selected_nodes() + + gizmo.clear() + + var lines: Array[Vector3] = [ + Vector3.ZERO, Vector3(0, 0, 1) + ] + if active: + var arrow_spread = 0.05 + lines.append_array([ + Vector3(0, 0, 1), Vector3(arrow_spread, -arrow_spread, 0.9), + Vector3(0, 0, 1), Vector3(-arrow_spread, arrow_spread, 0.9), + ]) + + var offset = 0.005 + for i in range(lines.size()): + var p = lines[i] + lines.append(Vector3(p.x + offset, p.y + offset, p.z)) + + gizmo.add_lines( + PackedVector3Array(lines), + get_material("forward", gizmo) + ) diff --git a/addons/portals/gizmos/portal_forward_direction.gd.uid b/addons/portals/gizmos/portal_forward_direction.gd.uid new file mode 100644 index 0000000..e74b08a --- /dev/null +++ b/addons/portals/gizmos/portal_forward_direction.gd.uid @@ -0,0 +1 @@ +uid://cacoywhcpn4ja diff --git a/addons/portals/materials/portal3d-icon.svg b/addons/portals/materials/portal3d-icon.svg new file mode 100644 index 0000000..adfba84 --- /dev/null +++ b/addons/portals/materials/portal3d-icon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/addons/portals/materials/portal3d-icon.svg.import b/addons/portals/materials/portal3d-icon.svg.import new file mode 100644 index 0000000..9a2d3dc --- /dev/null +++ b/addons/portals/materials/portal3d-icon.svg.import @@ -0,0 +1,37 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://ct62bsuel5hyc" +path="res://.godot/imported/portal3d-icon.svg-a34538e0e6bdf86bd50ffe0190100a72.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://addons/portals/materials/portal3d-icon.svg" +dest_files=["res://.godot/imported/portal3d-icon.svg-a34538e0e6bdf86bd50ffe0190100a72.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 +svg/scale=1.0 +editor/scale_with_editor_scale=false +editor/convert_colors_with_editor_theme=false diff --git a/addons/portals/materials/portal_shader.gdshader b/addons/portals/materials/portal_shader.gdshader new file mode 100644 index 0000000..85ffd4a --- /dev/null +++ b/addons/portals/materials/portal_shader.gdshader @@ -0,0 +1,16 @@ +shader_type spatial; +render_mode unshaded; + +uniform sampler2D albedo: hint_default_black, source_color; + +void fragment() { + // The portal color is simply the screen-space color of the exit camera render target. + // This is because the exit camera views the exit portal from the perspective of the player watching + // the entrance portal, meaning the exit portal will occupy the same screen-space as the entrance portal. + vec3 portal_color = texture(albedo, SCREEN_UV).rgb; + + ALBEDO = portal_color; + + // FOR DEBUG - make portals slightly red + // ALBEDO = mix(portal_color, vec3(1, 0, 0), 0.1); +} \ No newline at end of file diff --git a/addons/portals/materials/portal_shader.gdshader.uid b/addons/portals/materials/portal_shader.gdshader.uid new file mode 100644 index 0000000..ff15d2f --- /dev/null +++ b/addons/portals/materials/portal_shader.gdshader.uid @@ -0,0 +1 @@ +uid://bhdb2skdxehes diff --git a/addons/portals/materials/portalclip_mesh.gdshaderinc b/addons/portals/materials/portalclip_mesh.gdshaderinc new file mode 100644 index 0000000..1c93304 --- /dev/null +++ b/addons/portals/materials/portalclip_mesh.gdshaderinc @@ -0,0 +1,11 @@ +#define PORTALCLIP_UNIFORMS \ +instance uniform bool portal_clip_active = false;\ +instance uniform vec3 portal_clip_point = vec3(0, 0, 0);\ +instance uniform vec3 portal_clip_normal = vec3(0, 1, 0);\ +varying vec3 portal_clip_vertex_position;\ + +#define PORTALCLIP_VERTEX \ +portal_clip_vertex_position = (MODEL_MATRIX * vec4(VERTEX, 1.0)).xyz;\ + +#define PORTALCLIP_FRAGMENT \ +if (portal_clip_active && dot(portal_clip_vertex_position - portal_clip_point, portal_clip_normal) < 0.0) ALPHA = 0.0; diff --git a/addons/portals/materials/portalclip_mesh.gdshaderinc.uid b/addons/portals/materials/portalclip_mesh.gdshaderinc.uid new file mode 100644 index 0000000..46672ba --- /dev/null +++ b/addons/portals/materials/portalclip_mesh.gdshaderinc.uid @@ -0,0 +1 @@ +uid://cpxsita6rqndc diff --git a/addons/portals/plugin.cfg b/addons/portals/plugin.cfg new file mode 100644 index 0000000..810fee3 --- /dev/null +++ b/addons/portals/plugin.cfg @@ -0,0 +1,7 @@ +[plugin] + +name="3D Portals" +description="Implements seamless portals in 3D" +author="Vojtech Struhar" +version="0.1" +script="plugin.gd" diff --git a/addons/portals/plugin.gd b/addons/portals/plugin.gd new file mode 100644 index 0000000..808806e --- /dev/null +++ b/addons/portals/plugin.gd @@ -0,0 +1,39 @@ +@tool +extends EditorPlugin + +const ExitOutlinesGizmo = preload("uid://pk5ua52g54m1") # gizmos/portal_exit_outline.gd +var exit_outline_gizmo + +const ForwardDirGizmo = preload("uid://cacoywhcpn4ja") # gizmos/portal_forward_direction.gd +var forward_dir_gizmo + +func _enter_tree() -> void: + + PortalSettings.init_setting("gizmo_exit_outline_active", true, true) + PortalSettings.add_info(AtExport.bool_("gizmo_exit_outline_active")) + + PortalSettings.init_setting("gizmo_exit_outline_color", Color.DEEP_SKY_BLUE, true) + PortalSettings.add_info(AtExport.color_no_alpha("gizmo_exit_outline_color")) + + PortalSettings.init_setting("gizmo_forward_active", true, true) + PortalSettings.add_info(AtExport.bool_("gizmo_forward_active")) + + PortalSettings.init_setting("gizmo_forward_color", Color.HOT_PINK, true) + PortalSettings.add_info(AtExport.color_no_alpha("gizmo_forward_color")) + + PortalSettings.init_setting("portals_group_name", "portals") + PortalSettings.add_info(AtExport.string("portals_group_name")) + + if PortalSettings.get_setting("gizmo_exit_outline_active"): + exit_outline_gizmo = ExitOutlinesGizmo.new() + add_node_3d_gizmo_plugin(exit_outline_gizmo) + + if PortalSettings.get_setting("gizmo_forward_active"): + forward_dir_gizmo = ForwardDirGizmo.new() + add_node_3d_gizmo_plugin(forward_dir_gizmo) + +func _exit_tree() -> void: + if exit_outline_gizmo: + remove_node_3d_gizmo_plugin(exit_outline_gizmo) + if forward_dir_gizmo: + remove_node_3d_gizmo_plugin(forward_dir_gizmo) diff --git a/addons/portals/plugin.gd.uid b/addons/portals/plugin.gd.uid new file mode 100644 index 0000000..266d979 --- /dev/null +++ b/addons/portals/plugin.gd.uid @@ -0,0 +1 @@ +uid://ev8ej7qedyih diff --git a/addons/portals/scripts/at_export.gd b/addons/portals/scripts/at_export.gd new file mode 100644 index 0000000..f9b5afb --- /dev/null +++ b/addons/portals/scripts/at_export.gd @@ -0,0 +1,130 @@ +class_name AtExport + +## Helper class for defining custom export inspector. +## +## Instead of [code]@export var foo: int = 0[/code] you could return +## [code]AtExport.int_("foo")[/code] in your [method Object._get_property_list] + +static func _base(propname: String, type: int) -> Dictionary: + return { + "name": propname, + "type": type, + "usage": PROPERTY_USAGE_DEFAULT | PROPERTY_USAGE_SCRIPT_VARIABLE + } + +static func button(propname: String, button_text: String, button_icon: String = "Callable") -> Dictionary: + var result := _base(propname, TYPE_CALLABLE) + + assert(not button_text.contains(","), "Button text cannot contain a comma") + + result["hint"] = PROPERTY_HINT_TOOL_BUTTON + result["hint_string"] = button_text + "," + button_icon + + return result + +static func bool_(propname: String) -> Dictionary: + return _base(propname, TYPE_BOOL) + +static func color(propname: String) -> Dictionary: + return _base(propname, TYPE_COLOR) + +static func color_no_alpha(propname: String) -> Dictionary: + var result := _base(propname, TYPE_COLOR) + result["hint"] = PROPERTY_HINT_COLOR_NO_ALPHA + return result + +## Following two lines are equivalent: [br] +## [codeblock] +## @export var height: float +## AtExport.float_("height") +## [/codeblock] +static func float_(propname: String) -> Dictionary: + return _base(propname, TYPE_FLOAT) + +static func float_range(propname: String, min: float, max: float, step: float = 0.01, extra_hints: Array[String] = []) -> Dictionary: + var result := float_(propname) + var hint_string = "%f,%f,%f" % [min, max, step] + + if extra_hints.size() > 0: + for h in extra_hints: + hint_string += ("," + h) + + result["hint"] = PROPERTY_HINT_RANGE + result["hint_string"] = hint_string + + return result + +static func int_(propname: String) -> Dictionary: + return _base(propname, TYPE_INT) + +static func int_flags(propname: String, options: Array) -> Dictionary: + var result := int_(propname) + result["hint"] = PROPERTY_HINT_FLAGS + result["hint_string"] = ",".join(options) + return result + +static func int_physics_3d(propname: String) -> Dictionary: + var result := int_(propname) + result["hint"] = PROPERTY_HINT_LAYERS_3D_PHYSICS + return result + + +static func int_range(propname: String, min: int, max: int, step: int = 1, extra_hints: Array[String] = []) -> Dictionary: + var result := float_range(propname, min, max, step, extra_hints) + result["type"] = TYPE_INT + return result + + +static func int_render_3d(propname: String) -> Dictionary: + var result := int_(propname) + result["hint"] = PROPERTY_HINT_LAYERS_3D_RENDER + return result + + + +static func enum_(propname: String, parent_and_enum: StringName, enum_class: Variant) -> Dictionary: + var result := int_(propname) + + result["class_name"] = parent_and_enum + result["hint"] = PROPERTY_HINT_ENUM + result["hint_string"] = ",".join(enum_class.keys()) + result["usage"] |= PROPERTY_USAGE_CLASS_IS_ENUM + + return result + + +static func group(group_name: String, prefix: String = "") -> Dictionary: + var result := _base(group_name, TYPE_NIL) + # Overwrite the usage! + result["usage"] = PROPERTY_USAGE_GROUP + result["hint_string"] = prefix + return result + +static func group_end() -> Dictionary: + return group("") + +static func node(propname: String, node_class: StringName) -> Dictionary: + var result = _base(propname, TYPE_OBJECT) + result["hint"] = PROPERTY_HINT_NODE_TYPE + result["class_name"] = node_class + result["hint_string"] = node_class + return result + +static func string(propname: String) -> Dictionary: + return _base(propname, TYPE_STRING) + +static func subgroup(subgroup_name: String, prefix: String = "") -> Dictionary: + var result := _base(subgroup_name, TYPE_NIL) + # Overwrite the usage! + result["usage"] = PROPERTY_USAGE_SUBGROUP + result["hint_string"] = prefix + return result + +static func subgroup_end() -> Dictionary: + return subgroup("") + +static func vector2(propname: String) -> Dictionary: + return _base(propname, TYPE_VECTOR2) + +static func vector3(propname: String) -> Dictionary: + return _base(propname, TYPE_VECTOR3) diff --git a/addons/portals/scripts/at_export.gd.uid b/addons/portals/scripts/at_export.gd.uid new file mode 100644 index 0000000..3dab157 --- /dev/null +++ b/addons/portals/scripts/at_export.gd.uid @@ -0,0 +1 @@ +uid://d2ufiv5n1dcdr diff --git a/addons/portals/scripts/portal_3d.gd b/addons/portals/scripts/portal_3d.gd new file mode 100644 index 0000000..9a8ee30 --- /dev/null +++ b/addons/portals/scripts/portal_3d.gd @@ -0,0 +1,921 @@ +@tool +@icon("uid://ct62bsuel5hyc") +class_name Portal3D extends Node3D + +## Seamless 3D portal +## +## This node is a tool script that provides configuration options for portal setup. The portal +## can be visual-only or also teleporting. + +#region Public API + +## Emitted when this portal teleports something. Also see [signal on_teleport_receive] +signal on_teleport(node: Node3D) + +## Emitted when this portal [i]receives[/i] a teleported node. Whoever had [b]this[/b] portal as +## its [member exit_portal] triggered a teleport! +signal on_teleport_receive(node: Node3D) + +## The portal starts rendering again, [member portal_mesh] becomes visible and teleport +## activates (if the portal is teleporting).[br][br] +## Also see [method deactivate] +func activate() -> void: + process_mode = Node.PROCESS_MODE_INHERIT + + # Viewports have been destroyed + if portal_viewport == null: + _setup_cameras() + + show() + + +## Disables processing and hides the portal. Optionally destroys the viewports, freeing memory. +## Set [member start_deactivated] to [code]true[/code] to avoid viewport allocation at the start of +## the game. [br][br] +## Also see [method activate] +func deactivate(destroy_viewports: bool = false) -> void: + hide() + _watchlist_teleportables.clear() + + if destroy_viewports: + if portal_viewport: + print("[%s] freeing viewport" % name) + portal_viewport.queue_free() + portal_viewport = null + portal_camera = null + + process_mode = Node.PROCESS_MODE_DISABLED + +## If your [RayCast3D] node hits a portal that it was meant to go through, pass it to this function +## and it will get you the next collider behind the portal. +## Uses [method PhysicsDirectSpaceState3D.intersect_ray] under the hood.[br][br] +## Also see [method forward_raycast_query]. +func forward_raycast(raycast: RayCast3D) -> Dictionary: + var start := to_exit_position(raycast.get_collision_point()) + var goal := to_exit_position(raycast.to_global(raycast.target_position)) + + var query = PhysicsRayQueryParameters3D.create( + start, + goal, + raycast.collision_mask, + [self.teleport_area, exit_portal.teleport_area] + ) + query.collide_with_areas = raycast.collide_with_areas + query.collide_with_bodies = raycast.collide_with_bodies + query.hit_back_faces = raycast.hit_back_faces + query.hit_from_inside = raycast.hit_from_inside + + return get_world_3d().direct_space_state.intersect_ray(query) + +## When doing raycasts with [method PhysicsDirectSpaceState3D.intersect_ray] and you hit a portal +## that you want to go through, pass the existing [PhysicsRayQueryParameters3D] to this function. +## It will take over the parameters and calculate the ray's continuation. [br][br] +## See [method forward_raycast] for usage with [RayCast3D]. +func forward_raycast_query(params: PhysicsRayQueryParameters3D) -> Dictionary: + var start := to_exit_position(params.from) + var end := to_exit_position(params.to) + start = exit_portal.line_intersection(start, end) + + var excludes = [self.teleport_area, exit_portal.teleport_area] + excludes.append_array(params.exclude) + + var query = PhysicsRayQueryParameters3D.create( + start, end, params.collision_mask, excludes + ) + query.collide_with_areas = params.collide_with_areas + query.collide_with_bodies = params.collide_with_bodies + query.hit_back_faces = params.hit_back_faces + query.hit_from_inside = params.hit_from_inside + + return get_world_3d().direct_space_state.intersect_ray(query) + +#endregion + +## Size of the portal rectangle. [br][br] +## Detph of the portal is an implementation detail and is set automatically. +var portal_size: Vector2 = Vector2(2.0, 2.5): + set(v): + portal_size = v + if caused_by_user_interaction(): + _on_portal_size_changed() + update_configuration_warnings() + if exit_portal: + exit_portal.update_configuration_warnings() + +## The exit of this particular portal. Portal camera renders what it sees through this +## [member exit_portal]. Teleports take you here. +## [br][br] +## Two portals commonly have each other set as their exit portals, which allows you to +## travel back and forth. But this does not have to be the case! +var exit_portal: Portal3D: + set(v): + exit_portal = v + update_configuration_warnings() + notify_property_list_changed() + +var _tb_pair_portals: Callable = _editor_pair_portals.bind() +var _tb_sync_portal_sizes: Callable = _editor_sync_portal_sizes.bind() + +## Manually specify the main camera. By default it's inferred as the camera rendering the +## parent viewport of the portal. You might have to specify this, if your game uses multiple +## [SubViewport]s. +var player_camera: Camera3D + +## [member VisualInstance3D.layers] settging for [member portal_mesh]. So that the portal cameras +## don't see other portals.[br][br] +## You can set the default in [i]Project settings > Addons > Portals[/i]. +var portal_render_layer: int = 1 << 19: + set(v): + portal_render_layer = v + if caused_by_user_interaction(): + portal_mesh.layers = v + + +## The portal camera sets its [member Camera3D.near] as close to the portal as possible, to +## hopefully cull objects close behind the portal. This value offsets the [member portal_camera]'s +## near clip plane. Might be useful, if the portal has a thick frame around it. +var portal_frame_width: float = 0 + +## Determines how big the internal portal viewports are. It helps to reduce the memory usage +## by not rendering the portals at full resolution. Viewports are resized on window resize. +enum PortalViewportSizeMode { + ## Render at full window resolution. + FULL, + ## Portal viewport max width. Height is calculated from window aspect ratio. + MAX_WIDTH_ABSOLUTE, + ## Portal viewport will be a fraction of full window size. + FRACTIONAL +} +## Size mode to use for the portal viewport size. +var viewport_size_mode: PortalViewportSizeMode = PortalViewportSizeMode.FULL: + set(v): + viewport_size_mode = v + notify_property_list_changed() +var _viewport_size_max_width_absolute: int = ProjectSettings.get_setting("display/window/size/viewport_width") +var _viewport_size_fractional: float = 0.5 + + +## Hints the direction from which you expect the portal to be viewed. Makes sense to restrict on +## one-way portals or visual-only portals (with [member is_teleport] set to [code]false[/code]). +enum ViewDirection { + FRONT_AND_BACK, + ## Corresponds to portal's FORWARD direction (-Z) + ONLY_FRONT, + ## Corresponds to portal's BACK direction (+Z) + ONLY_BACK, +} + +## The direction from which you expect the portal to be viewed. Restricting this restricts the +## way the portal mesh is shifted around when player looks at the portal from different sides.[br] +## Restrict this if the portal can be seen from the sides and has no portal frame around it to +## cover the shifting mesh.[br][br] +## Also see [member teleport_direction] +var view_direction: ViewDirection = ViewDirection.FRONT_AND_BACK + +## If [code]true[/code], the portal is also a teleport. +## [br][br] +## You are expected to toggle this in the editor. For runtime teleport toggling, see +## [method activate] and [method deactivate]. +var is_teleport: bool = true: + set(v): + is_teleport = v + if caused_by_user_interaction(): + _setup_teleport() + notify_property_list_changed() + +## Dictates from which direction an object has to enter the portal to be teleported. +enum TeleportDirection { + ## Corresponds to portal's FORWARD direction (-Z) + FRONT, + ## Corresponds to portal's BACK direction (+Z) + BACK, + ## Teleports stuff coming from either side. + FRONT_AND_BACK +} + +## If the portal is also a teleport, it will only teleport things coming from +## this direction. +var teleport_direction: TeleportDirection = TeleportDirection.FRONT_AND_BACK + +## When a [RigidBody3D] goes through the portal, give its new normalized velocity a +## little boost. Makes stuff flying out of portals more fun. [br][br] +## Recommended values: 1 to 3 +var rigidbody_boost: float = 0.0 + +## [CollisionObject3D]s detected by this mask will be registered by the portal and teleported. +var teleport_collision_mask: int = 1 << 15 + +## When teleporting, the portal checks if the teleported object is less than [b]this[/b] near. +## Prevents false negatives when multiple portals are on top of each other. +var teleport_tolerance: float = 0.5 + +## Flags for everything that happens when a something is teleported. +enum TeleportInteractions { + ## The portal will try to call [constant ON_TELEPORT_CALLBACK] method on the teleported + ## node. You need to implement this function with a script. + CALLBACK = 1 << 0, + ## When the player is teleported, his X and Z rotations are tweened to zero. Resets unwanted + ## from going through a tilted portal. If checked, this will happen BEFORE the callback. + PLAYER_UPRIGHT = 1 << 1, + ## Duplicate meshes present on the teleported object, resulting in a [i]smooth teleport[/i] + ## from a 3rd point of view. [br] + ## This option is quite involved, requires a method named [constant DUPLICATE_MESHES_CALLBACK] + ## implemented on the teleported body, which returns an array of mesh instances that should be + ## duplicated. Every one of those meshes also needs to implement a special shader to clip it + ## along the portal plane. + DUPLICATE_MESHES = 1 << 2 +} + +## This method will be called on a teleported node if [member TeleportInteractions.CALLBACK] +## is checked in [member teleport_interactions] +const ON_TELEPORT_CALLBACK: StringName = &"on_teleport" + +## This method will be called on a node that will get into close proximity of a portal that has +## [member TeleportInteractions.DUPLICATE_MESHES] turned on. The method is expected to return an +## array of [MeshInstance3D]s. +const DUPLICATE_MESHES_CALLBACK: StringName = &"get_teleportable_meshes" + +## When a [CollisionObject3D] should be teleported, the portal check for a [NodePath] for an +## alternative node to teleport. For example it's useful when the [Area3D] that's triggering the +## teleport isn't the root of a player or object. +const TELEPORT_ROOT_META: StringName = &"teleport_root" + + +## See [enum TeleportInteractions] for options. +var teleport_interactions: int = TeleportInteractions.CALLBACK \ + | TeleportInteractions.PLAYER_UPRIGHT + + +## If the portal is not immediately visible on scene start, you can start it in [i]disabled +## mode[/i]. This just means it will not create the appropriate subviewports, saving memory. +## It will also not be processed.[br][br] +## You have to call [method activate] on it to wake it up! Also see [method disable] +var start_deactivated: bool = false + +#region INTERNALS + +@export_storage var _portal_thickness: float = 0.05: + set(v): + _portal_thickness = v + if caused_by_user_interaction(): _on_portal_size_changed() + +@export_storage var _portal_mesh_path: NodePath +## Mesh used to visualize the portal surface. Created when the portal is added to the scene +## [b]in the editor[/b]. +var portal_mesh: MeshInstance3D: + get(): + return get_node(_portal_mesh_path) if _portal_mesh_path else null + set(v): assert(false, "Proxy variable, use '_portal_mesh_path' instead") + +@export_storage var _teleport_area_path: NodePath +## When a teleportable object comes near the portal, it's registered by this area and watched +## every frame to trigger the teleport. [br][br] Created by toggling [member is_teleport] in editor. +var teleport_area: Area3D: + get(): + return get_node(_teleport_area_path) if _teleport_area_path else null + set(v): assert(false, "Proxy variable, use '_teleport_area_path' instead") + +@export_storage var _teleport_collider_path: NodePath +## Collider for [member teleport_area]. +var teleport_collider: CollisionShape3D: + get(): + return get_node(_teleport_collider_path) if _teleport_collider_path else null + set(v): assert(false, "Proxy variable, use '_teleport_collider_path' instead") + + +## Camera that looks through the exit portal and renders to [member portal_viewport]. +## Created in [code]_ready[/code] +var portal_camera: Camera3D = null + +## Viewport that supplies the albedo texture to portal mesh. Rendered by [member portal_camera]. +## Created in [code]_ready[/code] +var portal_viewport: SubViewport = null + +## Metadata kept about the teleportable objects watched by the portal. +class TeleportableMeta: + ## Forward distance from the portal + var forward: float = 0 + ## Meshes that the object gave for duplication. Retrieved by the + ## [constant Portal3D.DUPLICATE_MESHES_CALLBACK] callback. + var meshes: Array[MeshInstance3D] = [] + ## Cloned [member Portal3D.TeleportableMeta.meshes] with [method Node.duplicate] + var mesh_clones: Array[MeshInstance3D] = [] + +# These physics bodies are being watched by the portal. They are registered under their instance ID +# as the keys of the dictionary. Registering them by their object references was unreliable when +# freeing object for some reason. +var _watchlist_teleportables: Dictionary[int, TeleportableMeta] = {} + +#endregion + +#region Editor Configuration Stuff + +const _PORTAL_SHADER = preload("uid://bhdb2skdxehes") + +# _ready(), but only in editor. +func _editor_ready() -> void: + add_to_group(PortalSettings.get_setting("portals_group_name"), true) + set_notify_transform(true) + + process_priority = 100 + process_physics_priority = 100 + + _setup_mesh() + _setup_teleport() + + self.group_node(self) + +func _notification(what: int) -> void: + match what: + NOTIFICATION_TRANSFORM_CHANGED: + update_gizmos() + +func _editor_pair_portals() -> void: + assert(exit_portal != null, "My own exit has to be set!") + exit_portal.exit_portal = self + notify_property_list_changed() + +func _editor_sync_portal_sizes() -> void: + assert(exit_portal != null, "My own exit has to be set!") + portal_size = exit_portal.portal_size + notify_property_list_changed() + +func _setup_teleport(): + if is_teleport == false: + if teleport_area: + teleport_area.queue_free() + _teleport_area_path = NodePath("") + if teleport_collider: + teleport_collider.queue_free() + _teleport_collider_path = NodePath("") + return + + # Teleport is already set up + if teleport_area and teleport_collider: + return + + var area = Area3D.new() + area.name = "TeleportArea" + + add_child_in_editor(self, area) + _teleport_area_path = get_path_to(area) + + var collider = CollisionShape3D.new() + collider.name = "Collider" + var box = BoxShape3D.new() + box.size.x = portal_size.x + box.size.y = portal_size.y + collider.shape = box + + add_child_in_editor(teleport_area, collider) + _teleport_collider_path = get_path_to(collider) + + +func _on_portal_size_changed() -> void: + if portal_mesh == null: + push_error("Failed to update portal size, portal has no mesh") + return + + var p: PortalBoxMesh = portal_mesh.mesh + p.size = Vector3(portal_size.x, portal_size.y, 1) + portal_mesh.scale.z = _portal_thickness + + if is_teleport and teleport_collider: + var box: BoxShape3D = teleport_collider.shape + box.size.x = portal_size.x + box.size.y = portal_size.y + +#endregion + +#region GAMEPLAY RUNTIME STUFF + +func _ready() -> void: + if Engine.is_editor_hint(): + _editor_ready.call_deferred() + return + + if player_camera == null: + player_camera = get_viewport().get_camera_3d() + assert(player_camera != null, "Player camera is missing!") + + + var mat: ShaderMaterial = ShaderMaterial.new() + mat.shader = _PORTAL_SHADER + portal_mesh.material_override = mat + + if not start_deactivated: + _setup_cameras() + get_viewport().size_changed.connect(_on_window_resize) + else: + deactivate.call_deferred(true) + + if is_teleport: + assert(teleport_area, "Teleport area should be already set up from editor") + teleport_area.area_entered.connect(self._on_teleport_area_entered) + teleport_area.area_exited.connect(self._on_teleport_area_exited) + teleport_area.body_entered.connect(self._on_teleport_body_entered) + teleport_area.body_exited.connect(self._on_teleport_body_exited) + teleport_area.collision_mask = teleport_collision_mask + + +func _process(delta: float) -> void: + if Engine.is_editor_hint(): + return + + if is_teleport: + _process_teleports() + + _process_cameras() + + +func _process_cameras() -> void: + + if portal_camera == null: + push_error("%s: No portal camera" % name) + return + if player_camera == null: + push_error("%s: No player camera" % name) + return + if exit_portal == null: + push_error("%s: No exit portal" % name) + return + + # Update camera + portal_camera.global_transform = self.to_exit_transform(player_camera.global_transform) + portal_camera.near = _calculate_near_plane() + portal_camera.fov = player_camera.fov + + # Prevent flickering + var pv_size: Vector2i = portal_viewport.size + var half_height: float = player_camera.near * tan(deg_to_rad(player_camera.fov * 0.5)) + var half_width: float = half_height * pv_size.x / float(pv_size.y) + var near_diagonal: float = Vector3(half_width, half_height, player_camera.near).length() + portal_mesh.scale.z = near_diagonal + + var player_in_front_of_portal: bool = forward_distance(player_camera) > 0 + var portal_shift: float = 0 + match view_direction: + ViewDirection.ONLY_FRONT: + portal_shift = 1 + ViewDirection.ONLY_BACK: + portal_shift = -1 + ViewDirection.FRONT_AND_BACK: + portal_shift = 1 if player_in_front_of_portal else -1 + + portal_mesh.scale.z *= signf(portal_shift) # Turn the portal towards the player + + +func _process_teleports() -> void: + for body_id: int in _watchlist_teleportables.keys(): + if not is_instance_id_valid(body_id): # Watched body has been freed + _erase_tp_metadata(body_id) + continue + + var tp_meta: TeleportableMeta = _watchlist_teleportables.get(body_id) + var body = instance_from_id(body_id) as Node3D + var last_fw_angle: float = tp_meta.forward + var current_fw_angle: float = forward_distance(body) + + var should_teleport: bool = false + match teleport_direction: + TeleportDirection.FRONT: + should_teleport = last_fw_angle > 0 and current_fw_angle <= 0 + TeleportDirection.BACK: + should_teleport = last_fw_angle < 0 and current_fw_angle >= 0 + TeleportDirection.FRONT_AND_BACK: + should_teleport = sign(last_fw_angle) != sign(current_fw_angle) + _: + assert(false, "This match statement should be exhaustive") + + if should_teleport and abs(current_fw_angle) < teleport_tolerance: + var teleportable_path = body.get_meta(TELEPORT_ROOT_META, ".") + var teleportable: Node3D = body.get_node(teleportable_path) + + teleportable.global_transform = self.to_exit_transform(teleportable.global_transform) + + if teleportable is RigidBody3D: + teleportable.linear_velocity = to_exit_direction(teleportable.linear_velocity) + teleportable.apply_central_impulse( + teleportable.linear_velocity.normalized() * rigidbody_boost + ) + + + on_teleport.emit(teleportable) + exit_portal.on_teleport_receive.emit(teleportable) + + # Force the cameras to refresh if we just teleported a player + var was_player := not str(teleportable.get_path_to(player_camera)).begins_with(".") + if was_player: + _process_cameras() + exit_portal._process_cameras() + + # Resolve teleport interactions + if was_player and check_tp_interaction(TeleportInteractions.PLAYER_UPRIGHT): + get_tree().create_tween().tween_property(teleportable, "rotation:x", 0, 0.3) + get_tree().create_tween().tween_property(teleportable, "rotation:z", 0, 0.3) + + if check_tp_interaction(TeleportInteractions.CALLBACK): + if teleportable.has_method(ON_TELEPORT_CALLBACK): + teleportable.call(ON_TELEPORT_CALLBACK, self) + + # transfer the thing to exit portal + _transfer_tp_metadata_to_exit(body) + else: + tp_meta.forward = current_fw_angle + for i in tp_meta.mesh_clones.size(): + var mesh = tp_meta.meshes[i] + var clone = tp_meta.mesh_clones[i] + clone.global_transform = to_exit_transform(mesh.global_transform) + +func _calculate_near_plane() -> float: + # Adjustment for cube portals. This AABB is basically a plane. + var _aabb: AABB = AABB( + Vector3(-exit_portal.portal_size.x / 2, -exit_portal.portal_size.y / 2, 0), + Vector3(exit_portal.portal_size.x, exit_portal.portal_size.y, 0) + ) + var _pos := _aabb.position + var _size := _aabb.size + + var corner_1: Vector3 = exit_portal.to_global(Vector3(_pos.x, _pos.y, 0)) + var corner_2: Vector3 = exit_portal.to_global(Vector3(_pos.x + _size.x, _pos.y, 0)) + var corner_3: Vector3 = exit_portal.to_global(Vector3(_pos.x + _size.x, _pos.y + _size.y, 0)) + var corner_4: Vector3 = exit_portal.to_global(Vector3(_pos.x, _pos.y + _size.y, 0)) + + # Calculate the distance along the exit camera forward vector at which each of the portal + # corners projects + var camera_forward: Vector3 = - portal_camera.global_transform.basis.z.normalized() + + var d_1: float = (corner_1 - portal_camera.global_position).dot(camera_forward) + var d_2: float = (corner_2 - portal_camera.global_position).dot(camera_forward) + var d_3: float = (corner_3 - portal_camera.global_position).dot(camera_forward) + var d_4: float = (corner_4 - portal_camera.global_position).dot(camera_forward) + + # The near clip distance is the shortest distance which still contains all the corners + return max(0.01, min(d_1, d_2, d_3, d_4) - exit_portal.portal_frame_width) + +func _setup_mesh() -> void: + if portal_mesh: + return + + var mi = MeshInstance3D.new() + + mi = MeshInstance3D.new() + mi.name = self.name + "_Mesh" + mi.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF + mi.layers = portal_render_layer + + var p := PortalBoxMesh.new() + p.size = Vector3(portal_size.x, portal_size.y, 1) + mi.mesh = p + mi.scale.z = _portal_thickness + + add_child_in_editor(self, mi) + _portal_mesh_path = get_path_to(mi) + +func _setup_cameras() -> void: + assert(not Engine.is_editor_hint(), "This should never run in editor") + assert(portal_camera == null) + assert(portal_viewport == null) + + if exit_portal != null: + portal_viewport = SubViewport.new() + portal_viewport.name = self.name + "_SubViewport" + portal_viewport.size = get_desired_viewport_size() + self.add_child(portal_viewport, true) + + # Disable tonemapping on portal cameras + var adjusted_env: Environment = player_camera.environment.duplicate() \ + if player_camera.environment \ + else player_camera.get_world_3d().environment.duplicate() + + adjusted_env.tonemap_mode = Environment.TONE_MAPPER_LINEAR + adjusted_env.tonemap_exposure = 1 + + portal_camera = player_camera.duplicate(0) + portal_camera.name = self.name + "_Camera3D" + portal_camera.environment = adjusted_env + + # Ensure that portals don't see other portals. + portal_camera.cull_mask = portal_camera.cull_mask ^ portal_render_layer + + portal_viewport.add_child(portal_camera, true) + portal_camera.global_position = exit_portal.global_position + + # Connect the viewport to the mesh. Mesh material setup has to run BEFORE this + portal_mesh.material_override.set_shader_parameter("albedo", portal_viewport.get_texture()) + else: + push_error("%s has no exit_portal! Failed to setup cameras." % name) + +#endregion + +#region Event handlers + +func _on_teleport_area_entered(area: Area3D) -> void: + if _watchlist_teleportables.has(area.get_instance_id()): + # Already on watchlist + return + + _construct_tp_metadata(area) + +func _on_teleport_body_entered(body: Node3D) -> void: + if _watchlist_teleportables.has(body.get_instance_id()): + # Already on watchlist + return + + _construct_tp_metadata(body) + +func _on_teleport_area_exited(area: Area3D) -> void: + _erase_tp_metadata(area.get_instance_id()) + +func _on_teleport_body_exited(body: Node3D) -> void: + _erase_tp_metadata(body.get_instance_id()) + +func _on_window_resize() -> void: + if portal_viewport: + portal_viewport.size = get_desired_viewport_size() + +#endregion + +#region UTILS + +func _construct_tp_metadata(node: Node3D) -> void: + var meta = TeleportableMeta.new() + meta.forward = forward_distance(node) + + if check_tp_interaction(TeleportInteractions.DUPLICATE_MESHES) and \ + node.has_method(DUPLICATE_MESHES_CALLBACK): + meta.meshes = node.call(DUPLICATE_MESHES_CALLBACK) + for m: MeshInstance3D in meta.meshes: + var dupe = m.duplicate(0) + dupe.name = m.name + "_Clone" + meta.mesh_clones.append(dupe) + self.add_child(dupe, true) + + enable_mesh_clipping(meta, self) + + _watchlist_teleportables.set(node.get_instance_id(), meta) + +func _erase_tp_metadata(node_id: int) -> void: + var meta = _watchlist_teleportables.get(node_id) + if meta != null: + meta = meta as TeleportableMeta + for m in meta.meshes: disable_mesh_clipping(m) + for c in meta.mesh_clones: c.queue_free() + + _watchlist_teleportables.erase(node_id) + +func enable_mesh_clipping(meta: TeleportableMeta, along_portal: Portal3D) -> void: + for mi: MeshInstance3D in meta.meshes: + var clip_normal = signf(meta.forward) * along_portal.global_basis.z + mi.set_instance_shader_parameter("portal_clip_active", true) + mi.set_instance_shader_parameter("portal_clip_point", along_portal.global_position) + mi.set_instance_shader_parameter("portal_clip_normal", clip_normal) + + var exit = along_portal.exit_portal + for clone: MeshInstance3D in meta.mesh_clones: + var clip_normal = signf(meta.forward) * exit.global_basis.z + clone.set_instance_shader_parameter("portal_clip_active", true) + clone.set_instance_shader_parameter("portal_clip_point", exit.global_position) + clone.set_instance_shader_parameter("portal_clip_normal", clip_normal) + +func disable_mesh_clipping(mi: MeshInstance3D) -> void: + mi.set_instance_shader_parameter("portal_clip_active", false) + +func _transfer_tp_metadata_to_exit(for_body: Node3D) -> void: + if not exit_portal.is_teleport: + return # One-way teleport scenario + + var body_id = for_body.get_instance_id() + var tp_meta = _watchlist_teleportables[body_id] + if tp_meta == null: + push_error("Attempted to trasfer teleport metadata for a node that is not being watched.") + return + + tp_meta.forward = exit_portal.forward_distance(for_body) + enable_mesh_clipping(tp_meta, exit_portal) # Switch, the main mesh is clipped by exit portal! + + exit_portal._watchlist_teleportables.set(body_id, tp_meta) + # NOTE: Not using '_erase_tp_metadata' here, as it also frees the cloned meshes! + _watchlist_teleportables.erase(body_id) + +## [b]Crucial[/b] piece of a portal - transforming where objects should appear +## on the other side. Used for both cameras and teleports. +func to_exit_transform(g_transform: Transform3D) -> Transform3D: + var relative_to_portal: Transform3D = global_transform.affine_inverse() * g_transform + var flipped: Transform3D = relative_to_portal.rotated(Vector3.UP, PI) + var relative_to_target = exit_portal.global_transform * flipped + return relative_to_target + + +## Similar to [method to_exit_transform], but this one uses [member global_basis] for calculations, +## so it [b]only transforms rotation[/b], since portal scale should aways be 1. Use for transforming +## directions. +func to_exit_direction(real: Vector3) -> Vector3: + var relative_to_portal: Vector3 = global_basis.inverse() * real + var flipped: Vector3 = relative_to_portal.rotated(Vector3.UP, PI) + var relative_to_target: Vector3 = exit_portal.global_basis * flipped + return relative_to_target + + +## Similar to [method to_exit_transform], but expects a global position. +func to_exit_position(g_pos: Vector3) -> Vector3: + var local: Vector3 = global_transform.affine_inverse() * g_pos + var rotated = local.rotated(Vector3.UP, PI) + var local_at_exit: Vector3 = exit_portal.global_transform * rotated + return local_at_exit + + +## Calculates the dot product of portal's forward vector with the global +## position of [param node] relative to the portal. Used for detecting teleports. +## [br] +## The result is positive when the node is in front of the portal. The value measures how far in +## front (or behind) the other node is compared to the portal. +func forward_distance(node: Node3D) -> float: + var portal_front: Vector3 = self.global_transform.basis.z.normalized() + var node_relative: Vector3 = (node.global_transform.origin - self.global_transform.origin) + return portal_front.dot(node_relative) + +## Helper function meant to be used in editor. Adds [param node] as a child to +## [param parent]. Forces a readable name and sets the child's owner to the same +## as parent's. +func add_child_in_editor(parent: Node, node: Node) -> void: + parent.add_child(node, true) + # self.owner is null if this node is the scene root. Supply self. + node.owner = self if self.owner == null else self.owner + +## Used to conditionally run property setters. +## [br] +## Setters fire both on editor set and when the scene starts up (the engine is +## assigning exported members). This should prevent the second case. +func caused_by_user_interaction() -> bool: + return Engine.is_editor_hint() and is_node_ready() + +## Editor helper function. Locks node in 3D editor view. +static func lock_node(node: Node3D) -> void: + node.set_meta("_edit_lock_", true) + + +## Editor helper function. Groups nodes in 3D editor view. +static func group_node(node: Node) -> void: + node.set_meta("_edit_group_", true) + + +func get_desired_viewport_size() -> Vector2i: + var vp_size: Vector2i = get_viewport().size + var aspect_ratio: float = float(vp_size.x) / float(vp_size.y) + + match viewport_size_mode: + PortalViewportSizeMode.FULL: + return vp_size + PortalViewportSizeMode.MAX_WIDTH_ABSOLUTE: + var width = min(_viewport_size_max_width_absolute, vp_size.x) + return Vector2i(width, int(width / aspect_ratio)) + PortalViewportSizeMode.FRACTIONAL: + return Vector2i(vp_size * _viewport_size_fractional) + + push_error("Failed to determine desired viewport size") + return Vector2i( + ProjectSettings.get_setting("display/window/size/viewport_width"), + ProjectSettings.get_setting("display/window/size/viewport_height") + ) + +func check_tp_interaction(flag: int) -> bool: + return (teleport_interactions & flag) > 0 + +## Get a point where the portal plane intersects a line. Line ends [param start] and [param end] +## are in global coordinates and so is the result. Used for forwarding raycast queries. +func line_intersection(start: Vector3, end: Vector3) -> Vector3: + var plane_normal = - global_basis.z + var plane_point = global_position + + var line_dir = end - start + var denom = plane_normal.dot(line_dir) + + if abs(denom) < 1e-6: + return Vector3.ZERO # No intersection, line is parallel to the plane + + var t = plane_normal.dot(plane_point - start) / denom + return start + line_dir * t + +#endregion + +#region GODOT ENGINE INTEGRATIONS + +func _get_configuration_warnings() -> PackedStringArray: + var warnings: Array[String] = [] + + var global_scale = global_basis.get_scale() + if not global_scale.is_equal_approx(Vector3.ONE): + warnings.append( + ("Portals should NOT be scaled. Global portal scale is %v, " % global_scale) + + "but should be (1.0, 1.0, 1.0). Make sure the portal and any of portal parents " + + "aren't scaled." + ) + + if exit_portal == null: + warnings.append("Exit portal is null") + + if exit_portal != null: + if not portal_size.is_equal_approx(exit_portal.portal_size): + warnings.append( + "Portal size should be the same as exit portal's (it's %s, but should be %s)" % + [portal_size, exit_portal.portal_size] + ) + + return PackedStringArray(warnings) + +func _get_property_list() -> Array[Dictionary]: + var config: Array[Dictionary] = [] + + config.append(AtExport.vector2("portal_size")) + + if exit_portal != null and not portal_size.is_equal_approx(exit_portal.portal_size): + config.append( + AtExport.button("_tb_sync_portal_sizes", "Take Exit Portal's Size", "Vector2")) + + config.append(AtExport.node("exit_portal", "Portal3D")) + + if exit_portal != null and exit_portal.exit_portal == null: + config.append(AtExport.button("_tb_pair_portals", "Pair Portals", "SliderJoint3D")) + + + config.append(AtExport.group("Rendering")) + config.append(AtExport.node("player_camera", "Camera3D")) + config.append(AtExport.int_render_3d("portal_render_layer")) + config.append(AtExport.float_range("portal_frame_width", 0.0, 10.0, 0.01)) + + config.append(AtExport.enum_( + "viewport_size_mode", &"Portal3D.PortalViewportSizeMode", PortalViewportSizeMode)) + + if viewport_size_mode == PortalViewportSizeMode.MAX_WIDTH_ABSOLUTE: + config.append(AtExport.int_range("_viewport_size_max_width_absolute", 2, 4096)) + elif viewport_size_mode == PortalViewportSizeMode.FRACTIONAL: + config.append(AtExport.float_range("_viewport_size_fractional", 0, 1)) + + config.append(AtExport.enum_("view_direction", &"Portal3D.ViewDirection", ViewDirection)) + + config.append(AtExport.group_end()) + + + config.append(AtExport.bool_("is_teleport")) + + if is_teleport: + config.append(AtExport.group("Teleport")) + + config.append( + AtExport.enum_("teleport_direction", &"Portal3D.TeleportDirection", TeleportDirection)) + config.append(AtExport.float_range("rigidbody_boost", 0, 5, 0.1, ["or_greater"])) + config.append(AtExport.int_physics_3d("teleport_collision_mask")) + config.append(AtExport.float_range("teleport_tolerance", 0.0, 5.0, 0.1, ["or_greater"])) + var opts: Array = TeleportInteractions.keys().map(func(s): return s.capitalize()) + config.append(AtExport.int_flags("teleport_interactions", opts)) + config.append(AtExport.group_end()) + + config.append(AtExport.group("Advanced")) + config.append(AtExport.bool_("start_deactivated")) + + return config + +func _property_can_revert(property: StringName) -> bool: + return property in [ + &"portal_size", + &"player_camera", + &"portal_render_layer", + &"portal_frame_width", + &"_viewport_size_max_width_absolute", + &"view_direction", + &"teleport_direction", + &"rigidbody_boost", + &"teleport_collision_mask", + &"teleport_tolerance", + &"teleport_interactions", + &"start_deactivated", + ] + +func _property_get_revert(property: StringName) -> Variant: + match property: + &"portal_size": + return Vector2(2, 2.5) + &"portal_render_layer": + return 1 << 19 + &"portal_frame_width": + return 0.0 + &"_viewport_size_max_width_absolute": + return ProjectSettings.get_setting("display/window/size/viewport_width") + &"view_direction": + return ViewDirection.FRONT_AND_BACK + &"teleport_direction": + return TeleportDirection.FRONT_AND_BACK + &"rigidbody_boost": + return 0.0 + &"teleport_collision_mask": + return 1 << 15 + &"teleport_tolerance": + return 0.5 + &"teleport_interactions": + return TeleportInteractions.CALLBACK | TeleportInteractions.PLAYER_UPRIGHT + &"start_deactivated": + return false + return null + +#endregion diff --git a/addons/portals/scripts/portal_3d.gd.uid b/addons/portals/scripts/portal_3d.gd.uid new file mode 100644 index 0000000..6d527df --- /dev/null +++ b/addons/portals/scripts/portal_3d.gd.uid @@ -0,0 +1 @@ +uid://cw1r4c1d7beyv diff --git a/addons/portals/scripts/portal_boxmesh.gd b/addons/portals/scripts/portal_boxmesh.gd new file mode 100644 index 0000000..037e809 --- /dev/null +++ b/addons/portals/scripts/portal_boxmesh.gd @@ -0,0 +1,87 @@ +@tool +extends ArrayMesh +class_name PortalBoxMesh + +@export var size: Vector3 = Vector3(1, 1, 1): + set(v): + size = v + generate_portal_mesh() + +func _init() -> void: + if Engine.is_editor_hint(): + generate_portal_mesh() + +func generate_portal_mesh() -> void: + var _start_time: int = Time.get_ticks_usec() + clear_surfaces() # Reset + + var surface_array: Array = [] + surface_array.resize(Mesh.ARRAY_MAX) + + var verts: PackedVector3Array = PackedVector3Array() + var uvs: PackedVector2Array = PackedVector2Array() + var normals: PackedVector3Array = PackedVector3Array() + var indices: PackedInt32Array = PackedInt32Array() + + # Just to save some chars + var w: float = size.x / 2 + var h: float = size.y / 2 + var depth: Vector3 = Vector3(0, 0, -size.z) + + # Outside rect + var TOP_LEFT: Vector3 = Vector3(-w, h, 0) + var TOP_RIGHT: Vector3 = Vector3(w, h, 0) + var BOTTOM_LEFT: Vector3 = Vector3(-w, -h, 0) + var BOTTOM_RIGHT: Vector3 = Vector3(w, -h, 0) + + + verts.append_array([ + TOP_LEFT, TOP_RIGHT, BOTTOM_LEFT, BOTTOM_RIGHT, + TOP_LEFT + depth, TOP_RIGHT + depth, BOTTOM_LEFT + depth, BOTTOM_RIGHT + depth, + ]) + uvs.append_array([ + Vector2(0, 0), Vector2(1, 0), Vector2(0, 1), Vector2(1, 1), # Front UVs + Vector2(0, 0), Vector2(1, 0), Vector2(0, 1), Vector2(1, 1), # Back UVs (the same) + ]) + + # We are going for a flat-surface look here. Portals should be unshaded anyways. + normals.append_array([ + Vector3.BACK, Vector3.BACK, Vector3.BACK, Vector3.BACK, + Vector3.BACK, Vector3.BACK, Vector3.BACK, Vector3.BACK + ]) + + # 0 ----------- 1 + # | \ / | + # | 4-------5 | + # | | | | + # | | | | + # | 6-------7 | + # | / \ | + # 2 ----------- 3 + + # Triangles are clockwise! + + indices.append_array([ + 0, 1, 4, + 4, 1, 5, # Top section done + 1, 3, 5, + 5, 3, 7, # right section done + 3, 2, 7, + 7, 2, 6, # bottom section done + 2, 0, 6, + 6, 0, 4, # left section done + + 4, 5, 6, + 6, 5, 7, # back section done + + 0, 1, 2, + 2, 1, 3, # front section done + ]) + + surface_array[Mesh.ARRAY_VERTEX] = verts + surface_array[Mesh.ARRAY_TEX_UV] = uvs + surface_array[Mesh.ARRAY_NORMAL] = normals + surface_array[Mesh.ARRAY_INDEX] = indices + + add_surface_from_arrays(Mesh.PRIMITIVE_TRIANGLES, surface_array) + diff --git a/addons/portals/scripts/portal_boxmesh.gd.uid b/addons/portals/scripts/portal_boxmesh.gd.uid new file mode 100644 index 0000000..699f53c --- /dev/null +++ b/addons/portals/scripts/portal_boxmesh.gd.uid @@ -0,0 +1 @@ +uid://bxcel82b180o3 diff --git a/addons/portals/scripts/portal_settings.gd b/addons/portals/scripts/portal_settings.gd new file mode 100644 index 0000000..7305aef --- /dev/null +++ b/addons/portals/scripts/portal_settings.gd @@ -0,0 +1,36 @@ +class_name PortalSettings + +## Static helper class for portal project settings. +## +## Features helper methods for inserting addon-related settings into [ProjectSettings]. +## Used mainly in plugin initialization and for getting defaults in [Portal3D] + +static func _qual_name(setting: String) -> String: + return "addons/portals/" + setting + +static func init_setting(setting: String, + default_value: Variant, + requires_restart: bool = false) -> void: + setting = _qual_name(setting) + + # This would mean the setting is already overriden + if not ProjectSettings.has_setting(setting): + ProjectSettings.set_setting(setting, default_value) + + ProjectSettings.set_initial_value(setting, default_value) + ProjectSettings.set_restart_if_changed(setting, requires_restart) + ProjectSettings.set_as_basic(setting, true) + +## See companion class [class AtExport], it has some utilities which might be helpful! +static func add_info(config: Dictionary) -> void: + var qual_name = _qual_name(config["name"]) + + config["name"] = qual_name + # In case this is coming from AtExport, which is geared towards inspector properties + config.erase("usage") + + ProjectSettings.add_property_info(config) + +static func get_setting(setting: String) -> Variant: + setting = _qual_name(setting) + return ProjectSettings.get_setting(setting) diff --git a/addons/portals/scripts/portal_settings.gd.uid b/addons/portals/scripts/portal_settings.gd.uid new file mode 100644 index 0000000..3a04f94 --- /dev/null +++ b/addons/portals/scripts/portal_settings.gd.uid @@ -0,0 +1 @@ +uid://yb4p7f7n5gid diff --git a/icon.svg b/icon.svg new file mode 100644 index 0000000..9d8b7fa --- /dev/null +++ b/icon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/icon.svg.import b/icon.svg.import new file mode 100644 index 0000000..a086556 --- /dev/null +++ b/icon.svg.import @@ -0,0 +1,37 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://bpocdq157jeiu" +path="res://.godot/imported/icon.svg-218a8f2b3041327d8a5756f3a245f83b.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://icon.svg" +dest_files=["res://.godot/imported/icon.svg-218a8f2b3041327d8a5756f3a245f83b.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 +svg/scale=1.0 +editor/scale_with_editor_scale=false +editor/convert_colors_with_editor_theme=false diff --git a/meshes/antichamber-stairs-collider.glb b/meshes/antichamber-stairs-collider.glb new file mode 100644 index 0000000..059b972 Binary files /dev/null and b/meshes/antichamber-stairs-collider.glb differ diff --git a/meshes/antichamber-stairs-collider.glb.import b/meshes/antichamber-stairs-collider.glb.import new file mode 100644 index 0000000..9123559 --- /dev/null +++ b/meshes/antichamber-stairs-collider.glb.import @@ -0,0 +1,44 @@ +[remap] + +importer="scene" +importer_version=1 +type="PackedScene" +uid="uid://bcbrwu11sdvwe" +path="res://.godot/imported/antichamber-stairs-collider.glb-cb002f73d73e153e6e213ef59cc4643a.scn" + +[deps] + +source_file="res://meshes/antichamber-stairs-collider.glb" +dest_files=["res://.godot/imported/antichamber-stairs-collider.glb-cb002f73d73e153e6e213ef59cc4643a.scn"] + +[params] + +nodes/root_type="" +nodes/root_name="" +nodes/apply_root_scale=true +nodes/root_scale=1.0 +nodes/import_as_skeleton_bones=false +nodes/use_node_type_suffixes=true +meshes/ensure_tangents=true +meshes/generate_lods=true +meshes/create_shadow_meshes=true +meshes/light_baking=1 +meshes/lightmap_texel_size=0.2 +meshes/force_disable_compression=false +skins/use_named_skins=true +animation/import=true +animation/fps=30 +animation/trimming=false +animation/remove_immutable_tracks=true +animation/import_rest_as_RESET=false +import_script/path="" +_subresources={ +"nodes": { +"PATH:Stairs_Collider": { +"generate/physics": true, +"mesh_instance/layers": 0 +} +} +} +gltf/naming_version=1 +gltf/embedded_image_handling=1 diff --git a/meshes/antichamber-stairs.glb b/meshes/antichamber-stairs.glb new file mode 100644 index 0000000..e449299 Binary files /dev/null and b/meshes/antichamber-stairs.glb differ diff --git a/meshes/antichamber-stairs.glb.import b/meshes/antichamber-stairs.glb.import new file mode 100644 index 0000000..f5f6ac3 --- /dev/null +++ b/meshes/antichamber-stairs.glb.import @@ -0,0 +1,37 @@ +[remap] + +importer="scene" +importer_version=1 +type="PackedScene" +uid="uid://bgodlxp1u7dyk" +path="res://.godot/imported/antichamber-stairs.glb-a7a2c5928b008a0bc9203aac5e95b078.scn" + +[deps] + +source_file="res://meshes/antichamber-stairs.glb" +dest_files=["res://.godot/imported/antichamber-stairs.glb-a7a2c5928b008a0bc9203aac5e95b078.scn"] + +[params] + +nodes/root_type="" +nodes/root_name="" +nodes/apply_root_scale=true +nodes/root_scale=1.0 +nodes/import_as_skeleton_bones=false +nodes/use_node_type_suffixes=true +meshes/ensure_tangents=true +meshes/generate_lods=true +meshes/create_shadow_meshes=true +meshes/light_baking=1 +meshes/lightmap_texel_size=0.2 +meshes/force_disable_compression=false +skins/use_named_skins=true +animation/import=true +animation/fps=30 +animation/trimming=false +animation/remove_immutable_tracks=true +animation/import_rest_as_RESET=false +import_script/path="" +_subresources={} +gltf/naming_version=1 +gltf/embedded_image_handling=1 diff --git a/player.gd b/player.gd new file mode 100644 index 0000000..8f9259d --- /dev/null +++ b/player.gd @@ -0,0 +1,56 @@ +extends CharacterBody3D + +@export var camera: Camera3D + +@export var SPEED = 4.0 +const JUMP_VELOCITY = 4.5 +const MOUSE_SENSITIVITY = 0.004 + +func _ready() -> void: + assert(camera != null, "Forgot to set camera in editor") + Input.mouse_mode = Input.MOUSE_MODE_CAPTURED + +## Implements [member Portal3D.ON_TELEPORT_CALLBACK] +func on_teleport(portal: Portal3D) -> void: + pass + +func _unhandled_input(event: InputEvent) -> void: + if event is InputEventMouseMotion: + if Input.mouse_mode == Input.MOUSE_MODE_CAPTURED: + rotate_y(-event.screen_relative.x * MOUSE_SENSITIVITY) + camera.rotate_x(-event.screen_relative.y * MOUSE_SENSITIVITY) + camera.rotation.x = clamp(camera.rotation.x, deg_to_rad(-80), deg_to_rad(80)) + +func _physics_process(delta: float) -> void: + var right: Vector3 = (global_transform.basis.x * Vector3(1, 0, 1)).normalized() + var forward: Vector3 = (-global_transform.basis.z * Vector3(1, 0, 1)).normalized() + var has_input = false + + velocity.x = 0 + velocity.z = 0 + + if Input.is_key_pressed(KEY_LEFT) or Input.is_key_pressed(KEY_A): + has_input = true + velocity -= right + if Input.is_key_pressed(KEY_RIGHT) or Input.is_key_pressed(KEY_D): + has_input = true + velocity += right + if Input.is_key_pressed(KEY_UP) or Input.is_key_pressed(KEY_W): + has_input = true + velocity += forward + if Input.is_key_pressed(KEY_DOWN) or Input.is_key_pressed(KEY_S): + has_input = true + velocity -= forward + + if has_input: + var normalized_horizontal_velocity = Vector2(velocity.x, velocity.z).normalized() + velocity.x = normalized_horizontal_velocity.x * SPEED + velocity.z = normalized_horizontal_velocity.y * SPEED + + if not is_on_floor(): + velocity += get_gravity() * delta + + if Input.is_action_just_pressed("ui_accept") and is_on_floor(): + velocity.y = JUMP_VELOCITY + + move_and_slide() diff --git a/player.gd.uid b/player.gd.uid new file mode 100644 index 0000000..ff84ecd --- /dev/null +++ b/player.gd.uid @@ -0,0 +1 @@ +uid://bdclx4q3c332j diff --git a/project.godot b/project.godot new file mode 100644 index 0000000..03a95bd --- /dev/null +++ b/project.godot @@ -0,0 +1,19 @@ +; Engine configuration file. +; It's best edited using the editor UI and not directly, +; since the parameters that go here are not all obvious. +; +; Format: +; [section] ; section goes between [] +; param=value ; assign values to parameters + +config_version=5 + +[application] + +config/name="AntichamberStairs" +config/features=PackedStringArray("4.4", "Forward Plus") +config/icon="res://icon.svg" + +[editor_plugins] + +enabled=PackedStringArray("res://addons/portals/plugin.cfg") diff --git a/world.tscn b/world.tscn new file mode 100644 index 0000000..d56c3b4 --- /dev/null +++ b/world.tscn @@ -0,0 +1,86 @@ +[gd_scene load_steps=10 format=4 uid="uid://by4fsuj02uyb3"] + +[ext_resource type="Script" uid="uid://bdclx4q3c332j" path="res://player.gd" id="1_f3sb7"] +[ext_resource type="PackedScene" uid="uid://bcbrwu11sdvwe" path="res://meshes/antichamber-stairs-collider.glb" id="1_fj7yv"] + +[sub_resource type="ProceduralSkyMaterial" id="ProceduralSkyMaterial_f3sb7"] +sky_horizon_color = Color(0.662243, 0.671743, 0.686743, 1) +ground_horizon_color = Color(0.662243, 0.671743, 0.686743, 1) + +[sub_resource type="Sky" id="Sky_fj7yv"] +sky_material = SubResource("ProceduralSkyMaterial_f3sb7") + +[sub_resource type="Environment" id="Environment_tlwt5"] +background_mode = 2 +sky = SubResource("Sky_fj7yv") +tonemap_mode = 2 +glow_enabled = true + +[sub_resource type="StandardMaterial3D" id="StandardMaterial3D_mca7i"] + +[sub_resource type="ArrayMesh" id="ArrayMesh_j0eh1"] +_surfaces = [{ +"aabb": AABB(-0.6, -1.31174e-08, -1, 2.9, 1.9, 4.2), +"format": 34359742465, +"index_count": 660, +"index_data": PackedByteArray("AAABAAIAAAACAAMAAgAEAAMAAgAFAAQAAQAFAAIABgADAAQABQAGAAQAAAADAAcABQAIAAYAAQAIAAUACAAJAAYACAAKAAkACAALAAoACAABAAsABgAJAAwABgANAAMADQAGAAwACwANAAwACwABAA0ADQAHAAMABwANAA4ADwAAAAcAAQAAAA8ADwAHABAABwAOABAAAQAPABEAAQASAA0ADgANABIAAQATABIAEQATAAEADgASABQAEwAUABIAFQAPABAAFQARAA8ADgAWABAAFQAQABYADgAUABcAFgAOABcAEwAYABQAEQAYABMAFwAUABkAGAAZABQAGgAVABYAEQAVABoAFgAXABsAGgAWABsAEQAaABwAEQAcABgAFwAZAB0AHQAbABcAGAAeABkAHgAdABkAHAAeABgAHwAcABoAHwAaABsAHAAgAB4AHgAgAB0AIQAcAB8AHAAhACAAHwAbACIAHQAjABsAGwAjACIAJAAfACIAJAAiACMAIQAfACQAIAAlAB0AIAAmACUAIAAhACYAJwAdACUAJgAnACUAIwAdACcAJgAhACcAKAAkACMAKAAhACQAKAAjACkAJwApACMAKgAoACkAIQAoACoAKQAnACsAIQAsACcAKwAnACwAKgApAC0AKQArAC0AIQAqAC4AIQAvACwALgAvACEAKwAsADAALwAwACwAMQAqAC0AMQAuACoAKwAyAC0AMQAtADIAKwAwADMAMgArADMALwA0ADAALgA0AC8AMwAwADUANAA1ADAANgAxADIALgAxADYAMgAzADcANgAyADcALgA2ADgALgA4ADQAMwA1ADkAOQA3ADMANAA6ADUAOgA5ADUAOAA6ADQAOwA4ADYAOwA2ADcAOAA8ADoAOgA8ADkAPQA4ADsAOAA9ADwAOwA3AD4AOQA/ADcANwA/AD4AQAA7AD4AQAA+AD8APQA7AEAAPABBADkAPABCAEEAPAA9AEIAQwA5AEEAQgBDAEEAPwA5AEMAQgA9AEMARABAAD8ARAA9AEAARAA/AEUAQwBFAD8ARgBEAEUAPQBEAEYARQBDAEcAPQBIAEMARwBDAEgARgBFAEkARQBHAEkAPQBGAEoAPQBLAEgASgBLAD0ARwBIAEwASwBMAEgATQBGAEkATQBKAEYARwBOAEkATQBJAE4ARwBMAE8ATgBHAE8ASwBQAEwASgBQAEsATwBMAFEAUABRAEwAUgBNAE4ASgBNAFIATgBPAFMAUgBOAFMASgBSAFQASgBUAFAATwBRAFUAVQBTAE8AUABWAFEAVgBVAFEAVABWAFAAVwBUAFIAVwBSAFMAVABYAFYAVgBYAFUAWQBUAFcAVABZAFgAVwBTAFoAVQBbAFMAUwBbAFoAXABXAFoAXABaAFsAWQBXAFwAWABdAFUAWABeAF0AWABZAF4AXwBVAF0AXgBfAF0AWwBVAF8AXgBZAF8AYABcAFsAYABZAFwAYABbAGEAXwBhAFsAYgBgAGEAWQBgAGIAYQBfAGMAWQBkAF8AYwBfAGQAYQBjAGUAYgBhAGUAYwBkAGYAZgBlAGMAWQBnAGQAZwBmAGQAaABnAFkAWQBiAGgAaABpAGcAZwBpAGYAYgBpAGgAagBiAGUAaQBrAGYAbABiAGoAbABpAGIAagBlAG0AbABqAG4AZgBvAGUAZQBvAG0AbwBmAGsAaQBwAGsAcABvAGsAaQBsAHAAcABsAG8AbwBxAG0AbABuAHEAbABxAG8A"), +"primitive": 3, +"uv_scale": Vector4(0, 0, 0, 0), +"vertex_count": 114, +"vertex_data": PackedByteArray("mpmZPvLkI7LNzEy/zMzMvgAAAAAAAIA/0MzMPfLkI7LNzEy/zszMPTMz8z/NzEy/zszMPTMz8z8AAIC/0MzMPQAAAAAAAIC/0szMvTMz8z8AAIC/mpmZPjMz8z/NzEy/0MzMvQAAAAAAAIC/mpkZvzMz8z8AAIC/mpkZvwAAAAAAAIC/mpkZvwAAAAAAAIA/mpkZvzMz8z8AAIA/zMzMvjMz8z8AAIA/zcxMvjMz8z8AAIA/mpmZPsxaYbKamRm/mpmZPjMz8z+amRm/zMxMvgAAAAAAAIA/zMzMvjMz8z+amZk/zMzMvvLkIzGamZk/zcxMvjMz8z+amZk/AAAAP8xaYbKamRm/AAAAPzMz8z+amRm/he9PsjMz8z+amZk/zMxMvvLkIzGamZk/zcxMvjMz8z80M7M/AAAAP3IvRbLOzMy+AAAAPzMz8z/OzMy+AAAAALcDxzGamZk/he9PsjMz8z80M7M/zMxMvvLkozE0M7M/NDMzP2vX9THMzMy+AAAAAPLkozE0M7M/0MxMPmvX9THNzMw/NDMzPzMz8z/MzMy+NDMzPzMz8z/NzEy+NDMzP/LkI7HMzEy+he9PsjMz8z/NzMw/AAAAAGvX9THNzMw/z8xMPjMz8z/NzMw/Z2ZmP/LkI7HMzEy+Z2ZmPzMz8z/NzEy+Z2ZmPy3ezLEAAACAzszMPjMz8z/NzMw/z8xMPjMz8z9nZuY/Z2ZmPzMz8z8oM12yzszMPmvX9THNzMw/0MxMPvLkIzJnZuY/zszMPjMz8z9nZuY/zcyMPy3ezLEAAACAzcyMPzMz8z8oM12ynpkZPzMz8z9oZuY/zszMPvLkIzJnZuY/zszMPjMz8z8AAABAzcyMP2vXdTLQzEw+zcyMPzMz8z/PzEw+npkZP+CfwjJoZuY/mpkZPzMz8z8AAABAzszMPi7eTDIAAABAZ2amP2vXdTLQzEw+mpkZP2vXdTIAAABAzsxMP2vXdTLNzAxAZ2amPzMz8z/PzEw+Z2amPzMz8z/OzMw+Z2amP/LkozHOzMw+mpkZPzMz8z/NzAxAmpkZP2vXdTLNzAxAzsxMPzMz8z/NzAxAAADAP/LkozHOzMw+AADAPzMz8z/OzMw+AADAP/jkozCamRk/AACAPzMz8z/NzAxAzsxMPzMz8z+amRlAAADAPzMz8z+amRk/AACAP2vXdTLNzAxAzsxMP1RojzKamRlAAACAPzMz8z+amRlAmpnZP/jkozCamRk/mpnZPzMz8z+amRk/nJmZPzMz8z+amRlAAACAP1RojzKamRlAAACAPzMz8z9mZiZAmpnZP+SfQjHNzEw/mpnZPzMz8z/NzEw/nJmZP8HenzKamRlAmpmZPzMz8z9mZiZAAACAP/LkozJmZiZANDPzP5BhuDLOzEw/mpmZP/LkozJmZiZANDOzP5BhuDI0MzNANDPzPzMz8z/OzEw/NDPzPzMz8z8AAIA/NDPzPy7eTDIAAIA/mpmZPzMz8z80MzNAmpmZP5BhuDI0MzNANDOzPzMz8z80MzNAZmYGQC7eTDIAAIA/ZmYGQDMz8z8AAIA/ZmYGQFRoDzKamZk/zMzMPzMz8z80MzNANDOzPzMz8z8AAEBAZmYGQDMz8z+amZk/zMzMPzMz8z8AAEBANDOzPy/ezDIAAEBAzMzMP5BhuDI0MzNAzMzMPy/ezDIAAEBANDMTQFRoDzKamZk/zMzMPzMz8z/MzExAaGbmP81a4TLMzExANDMTQDMz8z+amZk/NDMTQM1a4TLMzExAaGbmPzMz8z/MzExAzMzMP81a4TLMzExANDMTQDMz8z/MzExA") +}] +blend_shape_mode = 0 + +[sub_resource type="ArrayMesh" id="ArrayMesh_p540j"] +resource_name = "antichamber-stairs_Plane" +_surfaces = [{ +"aabb": AABB(-0.6, -1.31174e-08, -1, 2.9, 1.9, 4.2), +"attribute_data": PackedByteArray("AACAPwAAAD8AAIA/AACAPwAAgD8AAAA/AAAAAAAAgD8AAIA/AABAPwAAAAAAAIA/AAAAAAAAgD8AAAAAAACAPwAAgD8AAIA/AACAPwAAQD8AAIA/AACAPwAAgD8AAEg/AACAPwAAUD8AAIA/AACAPwAAgD8AAIA/AACAPwAAgD8AAIA/AACAPwAAAABmZmY/AACAPwAAgD8AAAAAAACAPwAAAADNzEw/AACAPwAAAAAAAAAAAAAAAAAAgD8AAAA/AACAPwAAAD8AAIA/AABAPwAAgD8AAIA/AACAPwAAQD8AAIA/AACAPwAAgD8AAEg/AACAPwAAUD8AAIA/AACAPwAAgD8AAIA/AACAPwAAgD8AAIA/AACAPwAAgD8AAAA/AACAPwAAgD8AAIA/AAAAPwAAgD8AAAAAAACAPwAAQD8AAAAAzcxMPwAAAAAAAAAAAAAAAGZmZj8AAAAAAACAPwAAgD8AAIA/AACAPwAAQD8AAIA/AACAPwAAgD8AAEg/AACAPwAAUD8AAIA/AACAPwAAgD8AAIA/AACAPwAAgD8AAIA/AACAPwAAAABmZmY/AACAPwAAgD8AAAAAAACAPwAAAADNzEw/AACAPwAAAAAAAAAAAAAAAAAAgD8AAAA/AACAPwAAAD8AAIA/AABAPwAAgD8AAIA/AACAPwAAgD8AAIA/AACAPwAAAAAAAIA/AAAAAAAAgD8AAAAAAACAPwAAAAAAAIA/AACAPwAAgD8AAIA/AAAAPwAAgD8AAAA/AAAAAAAAgD8AAAAAAACAPwAAAAAAAIA/AAAAAAAAgD8AAIA/AACAPwAAgD8AAIA/AACAPwAAQD8AAIA/AACAPwAAgD8AAEA/AACAPwAAUD8AAIA/AABIPwAAgD8AAIA/AACAPwAAgD8AAIA/AACAPwAAgD8AAIA/AAAAAGZmZj8AAAAAAACAPwAAgD8AAAA/AAAAAAAAAAAAAAAAzcxMPwAAgD8AAAAAAACAPwAAAD8AAIA/AACAPwAAgD8AAIA/AACAPwAAQD8AAIA/AACAPwAAgD8AAEA/AACAPwAAUD8AAIA/AABIPwAAgD8AAIA/AACAPwAAgD8AAIA/AACAPwAAgD8AAIA/AACAPwAAAD8AAIA/AAAAPwAAgD8AAIA/AACAPwAAQD8AAAAAZmZmPwAAAAAAAIA/AAAAAAAAAAAAAIA/AAAAAAAAAADNzEw/AACAPwAAgD8AAIA/AABAPwAAgD8AAIA/AACAPwAAUD8AAIA/AABIPwAAgD8AAIA/AACAPwAAgD8AAIA/AACAPwAAgD8AAIA/AAAAAGZmZj8AAAAAAACAPwAAgD8AAAA/AAAAAAAAAAAAAAAAzcxMPwAAgD8AAAAAAACAPwAAAD8AAIA/AACAPwAAgD8AAIA/AACAPwAAQD8AAIA/AACAPwAAAAAAAIA/AAAAAAAAgD8AAAAAAACAPwAAAAAAAIA/AAAAAAAAgD8AAAAAAACAPwAAAAAAAIA/AAAAAAAAgD8AAAAAAACAPwAAAAAAAIA/AACAPwAAgD8AAIA/AACAPwAAgD8AAIA/AACAPwAAgD8AAIA/AACAPwAAgD8AAIA/AACAPwAAgD8AAIA/AACAPwAAgD8AAIA/AAAAAAAAgD8AAIA/AACAPwAAAAAAAIA/AAAAAAAAgD8AAIA/AACAPwAAAAAAAIA/AACAPwAAgD8AAIA/AACAPwAAgD8AAIA/AACAPwAAgD8AAIA/AACAPwAAAABmZmY/AAAAAAAAgD8AAAAAZmZmPwAAAAAAAIA/AACAPwAAgD8AAIA/AACAPwAAgD8AAIA/AACAPwAAgD8AAIA/AAAAPwAAgD8AAAAAAACAPwAAAD8AAIA/AAAAAAAAgD8AAIA/AACAPwAAgD8AAIA/AACAPwAAgD8AAIA/AACAPwAAAAAAAAAAAAAAAAAAgD8AAAAAAAAAAAAAAAAAAIA/AABAPwAAgD8AAEA/AACAPwAAQD8AAIA/AABAPwAAgD8AAEA/AACAPwAAQD8AAIA/AABAPwAAgD8AAEA/AACAPwAAQD8AAIA/AAAAPwAAgD8AAEA/AACAPwAAAD8AAIA/AABIPwAAgD8AAEA/AACAPwAASD8AAIA/AABAPwAAgD8AAEA/AACAPwAAQD8AAIA/AABAPwAAgD8AAEA/AACAPwAASD8AAIA/AABAPwAAgD8AAEg/AACAPwAAQD8AAIA/AACAPwAAgD8AAIA/AACAPwAAgD8AAIA/AACAPwAAgD8AAAA/AACAPwAAAD8AAIA/AAAAPwAAgD8AAAA/AACAPwAAgD8AAIA/AACAPwAAgD8AAIA/AACAPwAAgD8AAIA/AABIPwAAgD8AAEA/AACAPwAASD8AAIA/AABAPwAAgD8AAIA/AACAPwAAgD8AAIA/AACAPwAAgD8AAIA/AAAAAAAAgD8AAIA/AACAPwAAAAAAAIA/AACAPwAAgD8AAAAAZmZmPwAAAAAAAIA/AAAAAGZmZj8AAAAAAACAPwAAgD8AAIA/AACAPwAAgD8AAIA/AACAPwAAgD8AAIA/AACAPwAAgD8AAIA/AACAPwAAgD8AAIA/AACAPwAAgD8AAAAAZmZmPwAAAAAAAIA/AAAAAGZmZj8AAAAAAACAPwAAgD8AAAA/AAAAAAAAgD8AAIA/AAAAPwAAAAAAAIA/AAAAAAAAgD8AAIA/AACAPwAAAAAAAIA/AACAPwAAgD8AAIA/AACAPwAAgD8AAIA/AACAPwAAgD8AAIA/AACAPwAAAAAAAIA/AACAPwAAgD8AAAAAAACAPwAAgD8AAIA/AACAPwAAgD8AAIA/AACAPwAAgD8AAIA/AACAPwAAgD8AAIA/AAAAAAAAAAAAAAAAAACAPwAAAAAAAAAAAAAAAAAAAAAAAIA/AAAAAAAAgD8AAAAAAACAPwAAAAAAAIA/AAAAAAAAgD8AAAAAAACAPwAAgD8AAAA/AACAPwAAAAAAAIA/AAAAPwAAgD8AAAAAAACAPwAAAD8AAIA/AAAAAAAAgD8AAAA/AACAPwAAAAAAAIA/AAAAAAAAAAAAAAAAAACAPwAAAAAAAAAAAAAAAAAAgD8AAIA/AACAPwAAgD8AAIA/AACAPwAAgD8AAIA/AACAPwAAQD8AAIA/AAAAPwAAgD8AAEA/AACAPwAAAD8AAIA/AABAPwAAgD8AAAA/AACAPwAAQD8AAIA/AAAAPwAAgD8AAAA/AACAPwAAAD8AAIA/AAAAPwAAgD8AAAA/AACAPwAAQD8AAIA/AAAAPwAAgD8AAEA/AACAPwAAAD8AAAAAAACAPwAAgD8AAEA/AAAAAAAAgD8AAIA/AABAPwAAgD8AAAA/AACAPwAAAD8AAIA/AAAAPwAAgD8AAAA/AACAPwAAAD8AAIA/AAAAPwAAgD8AAAA/AACAPwAAAD8AAIA/AACAPwAAgD8AAIA/AACAPwAAgD8AAIA/AACAPw=="), +"format": 34359742487, +"index_count": 660, +"index_data": PackedByteArray("AAABAAIAAQADAAIAAQAAAAQAAQAFAAMABQABAAYABQAGAAcAAQAEAAgACQAIAAQACAAKAAEACAAJAAsACAALAAwACAANAAoACAAOAA0ADgAPAA0ADgAQAA8AEQASABMAFAASABEAFQAUABYAEgAUABUAEgAVABcAGAASABcAEgAYABkAEgAZABoAGwAaABkAGgAcABIAGgAbAB0AGgAdAB4AGgAfABwAGgAgAB8AIAAhAB8AIAAiACEAIwAkACUAJAAmACUAJAAjACcAJgAoACkAJAAoACYAKAAkACoAKgAkACsAJAAnACwALQAsACcALAAuACQALAAtAC8ALAAvADAALAAxAC4ALAAyADEAMgAzADEAMgA0ADMANQA2ADcAOAA2ADUAOQA4ADoANgA4ADkANgA5ADsAPAA2ADsANgA8AD0ANgA9AD4APgA/ADYAPgBAAD8APQBAAD4AQQBAAD0AQABBAEIAQQA9AEMAQQBDAEQARQBGAEcASABFAEcASABHAEkARQBIAEoASABLAEoARgBFAEwATABFAE0ARgBMAE4ATABNAE8ATABQAE4AUABMAFEAUABRAFIATABPAFMAUwBPAFQAUwBUAFUAVgBXAFgAWQBXAFYAVwBZAFoAVwBaAFsAWgBZAFwAVgBdAFkAXQBWAF4AXgBWAF8AXQBeAGAAXgBfAGEAXgBiAGAAYgBeAGMAYgBjAGQAXgBhAGUAZQBhAGYAZQBmAGcAaABpAGoAaQBoAGsAaQBrAGwAagBtAGgAaABtAG4AbQBqAG8AbwBqAHAAbQBvAHEAawBoAHIAawBzAGwAawByAHQAcwBrAHUAcwB1AHYAawB0AHcAdwB0AHgAdwB4AHkAegB7AHwAfQB7AHoAewB9AH4AewB+AH8AfgB9AIAAegCBAH0AgQB6AIIAggB6AIMAgQCCAIQAggCDAIUAhQCEAIIAhQCGAIQAhgCFAIcAhACGAIgAhgCJAIgAigCLAIwAigCNAIsAjQCOAIsAjQCPAI4AkACRAJIAkACTAJEAlACVAJYAlACXAJUAmACZAJoAmACbAJkAnACdAJ4AnACfAJ0AoAChAKIAoACjAKEApAClAKYApACnAKUAqACpAKoAqACrAKkArACtAK4ArACvAK0AsACxALIAsACzALEAtAC1ALYAtAC3ALUAuAC5ALoAuAC7ALkAvAC9AL4AvAC/AL0AwADBAMIAwADDAMEAxADFAMYAxADHAMUAyADJAMoAyADLAMkAzADNAM4AzADPAM0A0ADRANIA0ADTANEA1ADVANYA1ADXANUA2ADZANoA2ADbANkA3ADdAN4A3ADfAN0A4ADhAOIA4ADjAOEA5ADlAOYA5ADnAOUA6ADpAOoA6ADrAOkA7ADtAO4A7ADvAO0A8ADxAPIA8ADzAPEA9AD1APYA9AD3APUA+AD5APoA+AD7APkA/AD9AP4A/AD/AP0AAAEBAQIBAAEDAQEBBAEFAQYBBAEHAQUBCAEJAQoBCAELAQkBDAENAQ4BDAEPAQ0BEAERARIBEAETAREBEwEUAREBEwEVARQBFgEXARgBFgEZARcBGgEbARwBGgEdARsBHgEfASABHgEhAR8BIgEjASQBIgElASMBJgEnASgBJgEpAScBKgErASwBKgEtASsBLgEvATABLgExAS8BMgEzATQBMgE1ATMBNgE3ATgBNgE5ATcBOgE7ATwBOgE9ATsBPgE/AUABPgFBAT8BQgFDAUQBQgFFAUMB"), +"material": SubResource("StandardMaterial3D_mca7i"), +"primitive": 3, +"uv_scale": Vector4(0, 0, 0, 0), +"vertex_count": 326, +"vertex_data": PackedByteArray("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Z2ZmPzMz8z8oM12yZ2ZmP/LkI7HMzEy+mpmZPvLkI7LNzEy/zszMPTMz8z/NzEy/mpmZPjMz8z/NzEy/0MzMPfLkI7LNzEy/AADAP/jkozCamRk/AADAPzMz8z/OzMw+AADAPzMz8z+amRk/AADAP/LkozHOzMw+NDMTQFRoDzKamZk/ZmYGQDMz8z+amZk/NDMTQDMz8z+amZk/ZmYGQFRoDzKamZk/Z2ZmP/LkI7HMzEy+NDMzPzMz8z/NzEy+Z2ZmPzMz8z/NzEy+NDMzP/LkI7HMzEy+AADAP/LkozHOzMw+Z2amPzMz8z/OzMw+AADAPzMz8z/OzMw+Z2amP/LkozHOzMw+zMzMvvLkIzGamZk/zcxMvjMz8z+amZk/zMzMvjMz8z+amZk/zMxMvvLkIzGamZk//3///////7//f///Zmb/v/9///9mZv+//3///2Zm/7//f///////v/9///////+//3///////7//f///////v/9///////+//3///////7//f///////v/9///////+//3///////7//f///////v/9///////+//3///////7//f///////v/9///////+//3///////7//f///////v/9///////+//3////7//7//f////v//v/9///////+//3///////7//f///////v/9///////+//3///////7//f///////v/9///////+//3///////7//f///////v/9///////+//3///////7//f///////v/9///////+//3///////7//f///////v/9///////+//3///////7//f///////v/9///////+//3////7//7//f///////v/9///////+//3///////7//f///////v/9///////+//3///////7//f///////v/9///////+//3///////7//f///////v/9////+//+//3////7//7//f///////v/9////+//+//3////7//7//f////v//v/9///////+//3///////7//f///AAD/H/9///////+//3///////7//f///AAD/H/9///8AAP8f/3///////7//f///////v/9///////+//38AAE1v/z//fwAA////v/9/AABNb/8//38AAE1v/z//fwAA////v/9/AAD///+//38AAP///7//fwAA////v/9/AAD///+//38AAP///7//fwAA////v/9/AAD///+//38AAP///7//fwAA////v/9/AAD///+//38AAP///7//fwAA////v/9/AAD//6Ix/38AAP//oC3/fwAA////P/9/AAD//4Eh/38AAP//BCb/fwAA////v/9/AAD+//8//38AAP///7//fwAA////v/9/AAD///+//38AAP///7//fwAA////v/9/AAD///+//38AAP///7//fwAA////v/9/AAD///+//38AAP///7//fwAA////v/9/AAD//6Ix/38AAP///7//fwAA//+BIf9/AAD///+//38AAP///7//fwAA//+gLf9/AAD///8//38AAP//BCb/fwAA////P/9/AAD///+//38AAP///7//fwAA////v/9/AAD///+//38AAP///7//fwAA////v/9/AAD///+//38AAP///7//fwAA////v/9/AAD//6Ix/38AAP//oC3/fwAA////P/9/AAD//4Eh/38AAP//BCb/fwAA////v/9/AAD+//8//38AAP///7//fwAA////v/9/AAD///+//38AAAAA/9//fwAAAAD/3/9/AAAAAP/f/38AAP///7//fwAA////v/9/AAD///+//////////7//////////v/////////+//////////7//////////v/////////+//////////7//////////v/////////+//////////7//////////v/////////+//////////7//////////v////3////+/////f////7////9/////v////3////+//////////7//////////v/////////+//////////7////9/////v////3////+/////f////7////9/////v////3////+/////f////7////9/////v////3////+/////f////7////9/////v////3////+/////f////78AAP9/////vwAA/3////+/AAD/f////78AAP9/////v////3////+/////f////7////9/////v////3////+//3//f////z//f/9/////P/9//3////8//3//f////z//f/9/////P/9//3////8//3//f////z//f/9/////P/9//3////8//3//f////z//f/9/////P/9//3////8/AAD/f////78AAP9/////vwAA/3////+/AAD/f////78AAP9/////vwAA/3////+/AAD/f////78AAP9/////v/9//3////8//3//f////z//f/9/////P/9//3////8/AAD/f////78AAP9/////vwAA/3////+/AAD/f////7////9/////v////3////+/////f////7////9/////v/9//3////8//3//f////z//f/9/////P/9//3////8//////////7//////////v/////////+//////////78AAP9/////vwAA/3////+/AAD/f////78AAP9/////v/////////+//////////7//////////v/////////+//////////7//////////v/////////+//////////7////9/////v////3////+/////f////7////9/////v////3////+/////f////7////9/////v////3////+//////////7//////////v/////////+//////////7////9/////v////3////+/////f////7////9/////vwAA/3////+/AAD/f////78AAP9/////vwAA/3////+//////////7//////////v/////////+//////////7//////////v/////////+//////////7//////////v/////////+//////////7//////////v/////////+/////f////7////9/////v////3////+/////f////7//f/9/////P/9//3////8//3//f////z//f/9/////P/9//3////8//3//f////z//f/9/////P/9//3////8//3//f////z//f/9/////PwAA/3////+/AAD/f////78AAP9/////vwAA/3////+/AAD/f////78AAP9/////vwAA/3////+/AAD/f////7//f/9/////P/9//3////8//3//f////z//f/9/////P////3////+/////f////7////9/////v////3////+/AAD/f////78AAP9/////vwAA/3////+/AAD/f////78AAP9/////vwAA/3////+/AAD/f////78AAP9/////v/9//3////8//3//f////z//f/9/////P/9//3////8/AAD/f////78AAP9/////vwAA/3////+/AAD/f////7//f/9/////P/9//3////8//3//f////z//f/9/////P/9//3////8//3//f////z//f/9/////P/9//3////8//3//f////z//f/9/////P/9//3////8//3//f////z//////////v/////////+//////////7//////////vw==") +}] +blend_shape_mode = 0 +shadow_mesh = SubResource("ArrayMesh_j0eh1") + +[sub_resource type="CapsuleShape3D" id="CapsuleShape3D_f3sb7"] +radius = 0.375 +height = 1.75 + +[node name="World" type="Node3D"] + +[node name="WorldEnvironment" type="WorldEnvironment" parent="."] +environment = SubResource("Environment_tlwt5") + +[node name="DirectionalLight3D" type="DirectionalLight3D" parent="."] +transform = Transform3D(-0.866025, -0.433013, 0.25, 0, 0.5, 0.866025, -0.5, 0.75, -0.433013, 0, 0, 0) +shadow_enabled = true + +[node name="antichamber-stairs" type="Node3D" parent="."] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -0.807172, 0, 1.64452) + +[node name="Stairs" type="MeshInstance3D" parent="antichamber-stairs"] +transform = Transform3D(1, 0, 0, 0, 0, 1, 0, -1, 0, 0, 0, 0) +mesh = SubResource("ArrayMesh_p540j") +skeleton = NodePath("") + +[node name="Collider" parent="antichamber-stairs" instance=ExtResource("1_fj7yv")] + +[node name="CharacterBody3D" type="CharacterBody3D" parent="." node_paths=PackedStringArray("camera")] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -0.867929, 0, 0.757735) +floor_stop_on_slope = false +floor_max_angle = 0.872665 +script = ExtResource("1_f3sb7") +camera = NodePath("Camera3D") + +[node name="CollisionShape3D" type="CollisionShape3D" parent="CharacterBody3D"] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -0.101216, 0.107668, 0) +shape = SubResource("CapsuleShape3D_f3sb7") + +[node name="Camera3D" type="Camera3D" parent="CharacterBody3D"] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0.5, 0)