Basic stairs and player setup

This commit is contained in:
Vojtěch Struhár 2025-05-13 11:12:06 +02:00
commit dd7c490140
36 changed files with 1681 additions and 0 deletions

4
.editorconfig Normal file
View File

@ -0,0 +1,4 @@
root = true
[*]
charset = utf-8

2
.gitattributes vendored Normal file
View File

@ -0,0 +1,2 @@
# Normalize EOL for all files that Git considers text files.
* text=auto eol=lf

3
.gitignore vendored Normal file
View File

@ -0,0 +1,3 @@
# Godot 4+ specific ignores
.godot/
/android/

BIN
addons/.DS_Store vendored Normal file

Binary file not shown.

BIN
addons/portals/.DS_Store vendored Normal file

Binary file not shown.

View File

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

View File

@ -0,0 +1 @@
uid://pk5ua52g54m1

View File

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

View File

@ -0,0 +1 @@
uid://cacoywhcpn4ja

View File

@ -0,0 +1 @@
<svg clip-rule="evenodd" fill-rule="evenodd" stroke-linejoin="round" viewBox="0 0 67 67" xmlns="http://www.w3.org/2000/svg"><g fill="none" stroke="#fc7f7f" stroke-width="8.33"><path d="m4.167 33.333h37.5l-16.667-16.666m16.667 16.666-16.667 16.667"/><path d="m41.667 58.333h16.666v-50h-16.666" stroke-linecap="round"/></g></svg>

After

Width:  |  Height:  |  Size: 327 B

View File

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

View File

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

View File

@ -0,0 +1 @@
uid://bhdb2skdxehes

View File

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

View File

@ -0,0 +1 @@
uid://cpxsita6rqndc

View File

@ -0,0 +1,7 @@
[plugin]
name="3D Portals"
description="Implements seamless portals in 3D"
author="Vojtech Struhar"
version="0.1"
script="plugin.gd"

39
addons/portals/plugin.gd Normal file
View File

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

View File

@ -0,0 +1 @@
uid://ev8ej7qedyih

View File

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

View File

@ -0,0 +1 @@
uid://d2ufiv5n1dcdr

View File

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

View File

@ -0,0 +1 @@
uid://cw1r4c1d7beyv

View File

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

View File

@ -0,0 +1 @@
uid://bxcel82b180o3

View File

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

View File

@ -0,0 +1 @@
uid://yb4p7f7n5gid

1
icon.svg Normal file
View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="128" height="128"><rect width="124" height="124" x="2" y="2" fill="#363d52" stroke="#212532" stroke-width="4" rx="14"/><g fill="#fff" transform="translate(12.322 12.322)scale(.101)"><path d="M105 673v33q407 354 814 0v-33z"/><path fill="#478cbf" d="m105 673 152 14q12 1 15 14l4 67 132 10 8-61q2-11 15-15h162q13 4 15 15l8 61 132-10 4-67q3-13 15-14l152-14V427q30-39 56-81-35-59-83-108-43 20-82 47-40-37-88-64 7-51 8-102-59-28-123-42-26 43-46 89-49-7-98 0-20-46-46-89-64 14-123 42 1 51 8 102-48 27-88 64-39-27-82-47-48 49-83 108 26 42 56 81zm0 33v39c0 276 813 276 814 0v-39l-134 12-5 69q-2 10-14 13l-162 11q-12 0-16-11l-10-65H446l-10 65q-4 11-16 11l-162-11q-12-3-14-13l-5-69z"/><path d="M483 600c0 34 58 34 58 0v-86c0-34-58-34-58 0z"/><circle cx="725" cy="526" r="90"/><circle cx="299" cy="526" r="90"/></g><g fill="#414042" transform="translate(12.322 12.322)scale(.101)"><circle cx="307" cy="532" r="60"/><circle cx="717" cy="532" r="60"/></g></svg>

After

Width:  |  Height:  |  Size: 994 B

37
icon.svg.import Normal file
View File

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

Binary file not shown.

View File

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

Binary file not shown.

View File

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

56
player.gd Normal file
View File

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

1
player.gd.uid Normal file
View File

@ -0,0 +1 @@
uid://bdclx4q3c332j

19
project.godot Normal file
View File

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

86
world.tscn Normal file

File diff suppressed because one or more lines are too long