Skip to content

Instantly share code, notes, and snippets.

@benallfree
Last active April 3, 2025 14:34
Show Gist options
  • Select an option

  • Save benallfree/02e06095b29adfd8ff13c15d3be0961a to your computer and use it in GitHub Desktop.

Select an option

Save benallfree/02e06095b29adfd8ff13c15d3be0961a to your computer and use it in GitHub Desktop.

Revisions

  1. benallfree revised this gist Apr 3, 2025. 1 changed file with 3 additions and 1 deletion.
    4 changes: 3 additions & 1 deletion vibeverse.js
    Original file line number Diff line number Diff line change
    @@ -1,8 +1,10 @@
    /**
    * Vibeverse
    * -----------------------
    * The Vibeverse (https://x.com/hashtag/vibejam) is an interconnected network of 3D web games with portals to and from each game.
    * The Vibeverse (x.com/hashtag/vibeverse)is an interconnected network of 3D web games with portals to and from each game.
    * You can even bring your custom player avatar along with you.
    *
    * Credit to @levelsio for the original implementation of this portal system.
    * -----------------------
    * Integration guide for connecting your experience to the Vibeverse metaverse.
    *
  2. benallfree revised this gist Apr 3, 2025. 1 changed file with 1 addition and 1 deletion.
    2 changes: 1 addition & 1 deletion vibeverse.js
    Original file line number Diff line number Diff line change
    @@ -1,7 +1,7 @@
    /**
    * Vibeverse
    * -----------------------
    * The Vibeverse is an interconnected network of 3D web games with portals to and from each game.
    * The Vibeverse (https://x.com/hashtag/vibejam) is an interconnected network of 3D web games with portals to and from each game.
    * You can even bring your custom player avatar along with you.
    * -----------------------
    * Integration guide for connecting your experience to the Vibeverse metaverse.
  3. benallfree renamed this gist Apr 3, 2025. 1 changed file with 320 additions and 5 deletions.
    325 changes: 320 additions & 5 deletions PortalManager.js → vibeverse.js
    Original file line number Diff line number Diff line change
    @@ -1,10 +1,13 @@
    /**
    * Vibeverse Portal Standard
    * Vibeverse
    * -----------------------
    * The Vibeverse is an interconnected network of 3D web games with portals to and from each game.
    * You can even bring your custom player avatar along with you.
    * -----------------------
    * Integration guide for connecting your experience to the Vibeverse metaverse.
    *
    * Quick Start:
    * 1. Import and initialize PortalManager with your Three.js scene, camera and socket
    * 1. Import and initialize Vibeverse with your Three.js scene, camera and socket
    * 2. Call createPortals() to add portal meshes to your scene
    *
    * Portal Behavior:
    @@ -18,6 +21,13 @@
    * - username: Player identifier passed between experiences
    * - color: Avatar color preference
    *
    * Warp Effect:
    * The portal system includes a configurable warp effect when transitioning between portals.
    * You can:
    * - Disable it by passing null as warpConfig in the constructor
    * - Customize it by passing your own warp configuration
    * - Use default settings by not specifying warpConfig
    *
    * Splash Page Bypass:
    * If your experience has a splash/loading page, you can check if the user is coming
    * from Vibeverse by using the isVibeverse() helper:
    @@ -51,11 +61,19 @@
    *
    */

    // Helper to check if user is coming from Vibeverse
    /**
    * Helper to check if user is coming from Vibeverse
    * @returns {boolean} True if user is coming from Vibeverse portal
    */
    export function isVibeverse() {
    return !!refUrl()
    }

    /**
    * Gets the referring URL from URL parameters
    * @returns {string|null} The referring URL with https:// prefix, or null if not present
    * @private
    */
    const refUrl = () => {
    const params = new URLSearchParams(window.location.search)
    const refUrl = params.get('ref')
    @@ -71,8 +89,62 @@ const refUrl = () => {

    import * as THREE from 'three'

    export class PortalManager {
    constructor(scene, camera, socket) {
    /**
    * Default configuration for the warp effect
    * @typedef {Object} WarpConfig
    * @property {number} lineCount - Number of lines in the tunnel
    * @property {number} pointsPerLine - Points making up each line
    * @property {number} tunnelRadius - Initial radius of the tunnel
    * @property {number} tunnelExpansion - How much the tunnel expands per point
    * @property {number} minSpeed - Minimum base speed
    * @property {number} speedVariation - Random variation in speed
    * @property {number} oscillationSpeed - Speed of the sine wave oscillation
    * @property {number} minSpeedFactor - Minimum speed factor during oscillation
    * @property {number} lineLength - Length between points in a line
    * @property {number} resetDistance - Distance at which lines reset
    * @property {number} resetOffset - How far back lines reset to
    * @property {number} cameraSpeed - Speed at which camera moves forward
    * @property {number} cameraAcceleration - How quickly camera speeds up
    */
    export const DEFAULT_WARP_CONFIG = {
    lineCount: 1000,
    pointsPerLine: 8,
    tunnelRadius: 2,
    tunnelExpansion: 0.5,
    minSpeed: 0.8,
    speedVariation: 0.2,
    oscillationSpeed: 2,
    minSpeedFactor: 0.5,
    lineLength: 3,
    resetDistance: 20,
    resetOffset: 20,
    cameraSpeed: 2.0,
    cameraAcceleration: 0.1,
    }

    /**
    * @typedef {Object} VibeverseOptions
    * @property {string} [labelText] - Custom text to display above the portal
    * @property {string} [labelColor] - Color of the portal label text
    * @property {THREE.Euler} [lookAt] - Rotation to apply to the portal
    * @property {Object} [position] - Position coordinates for the portal
    * @property {number} position.x - X coordinate
    * @property {number} position.y - Y coordinate
    * @property {number} position.z - Z coordinate
    */

    /**
    * Vibeverse class for managing interconnected 3D web game portals
    */
    export class Vibeverse {
    /**
    * Creates a new Vibeverse instance
    * @param {THREE.Scene} scene - Three.js scene
    * @param {THREE.Camera} camera - Three.js camera
    * @param {WebSocket} socket - WebSocket connection
    * @param {WarpConfig|null} [warpConfig=DEFAULT_WARP_CONFIG] - Configuration for warp effect
    */
    constructor(scene, camera, socket, warpConfig = DEFAULT_WARP_CONFIG) {
    this.scene = scene
    this.camera = camera
    this.socket = socket
    @@ -81,8 +153,26 @@ export class PortalManager {
    this.startPortalBox = null
    this.exitPortalBox = null
    this.playerCheckInterval = null
    this.enableWarpEffect = warpConfig !== null // Enable warp effect if config is provided
    this.warpEffect = null
    this.isWarping = false

    // Warp effect configuration - use provided config or defaults
    this.warpConfig = warpConfig || DEFAULT_WARP_CONFIG

    // Create a vector to store camera's forward direction
    this.cameraDirection = new THREE.Vector3()
    this.currentCameraSpeed = 0
    }

    /**
    * Creates a portal mesh with specified properties
    * @param {number} [radius=6] - Radius of the portal
    * @param {number} [color=0xff0000] - Color of the portal (hex)
    * @param {VibeverseOptions} [options={}] - Additional portal options
    * @returns {THREE.Group} Portal mesh group
    * @private
    */
    createPortalMesh(radius = 6, color = 0xff0000, options = {}) {
    const portal = new THREE.Group()

    @@ -216,6 +306,15 @@ export class PortalManager {
    return wrapper
    }

    /**
    * Creates a start portal that returns users to their previous location
    * @param {number} [x=0] - X coordinate
    * @param {number} [y=0] - Y coordinate
    * @param {number} [z=0] - Z coordinate
    * @param {number} [radius=6] - Portal radius
    * @param {VibeverseOptions} [options={}] - Additional portal options
    * @returns {THREE.Group} Start portal mesh group
    */
    createStartPortal(x = 0, y = 0, z = 0, radius = 6, options = {}) {
    const portal = this.createPortalMesh(radius, 0xff0000, {
    labelText: options.labelText || 'Go back',
    @@ -232,6 +331,15 @@ export class PortalManager {
    return portal
    }

    /**
    * Creates an exit portal that takes users to Vibeverse
    * @param {number} [x=0] - X coordinate
    * @param {number} [y=0] - Y coordinate
    * @param {number} [z=0] - Z coordinate
    * @param {number} [radius=6] - Portal radius
    * @param {VibeverseOptions} [options={}] - Additional portal options
    * @returns {THREE.Group} Exit portal mesh group
    */
    createExitPortal(x = 0, y = 0, z = 0, radius = 6, options = {}) {
    const portal = this.createPortalMesh(radius, 0x00ff00, {
    labelText: options.labelText || 'To Vibeverse',
    @@ -248,6 +356,10 @@ export class PortalManager {
    return portal
    }

    /**
    * Animates the start portal particles
    * @private
    */
    animateStartPortal() {
    if (!this.startPortal || !this.startPortal.userData) return

    @@ -264,6 +376,10 @@ export class PortalManager {
    requestAnimationFrame(this.animateStartPortal.bind(this))
    }

    /**
    * Animates the exit portal particles
    * @private
    */
    animateExitPortal() {
    if (!this.exitPortal || !this.exitPortal.userData) return

    @@ -280,6 +396,10 @@ export class PortalManager {
    requestAnimationFrame(this.animateExitPortal.bind(this))
    }

    /**
    * Checks for player collisions with portals
    * @param {Object} player - Player object with position property
    */
    checkPortalCollisions(player) {
    if (!player) return

    @@ -302,9 +422,175 @@ export class PortalManager {
    }
    }

    /**
    * Creates the warp effect geometry and materials
    * @returns {THREE.LineSegments} Warp effect mesh
    * @private
    */
    createWarpEffect() {
    const config = this.warpConfig
    const geometry = new THREE.BufferGeometry()
    const positions = new Float32Array(config.lineCount * config.pointsPerLine * 3)
    const velocities = new Float32Array(config.lineCount * 3)
    const colors = new Float32Array(config.lineCount * config.pointsPerLine * 3)
    const timingOffsets = new Float32Array(config.lineCount)

    const matrix = new THREE.Matrix4()
    const basePosition = new THREE.Vector3()
    const transformedPosition = new THREE.Vector3()

    for (let i = 0; i < config.lineCount; i++) {
    const theta = (i / config.lineCount) * Math.PI * 2

    for (let j = 0; j < config.pointsPerLine; j++) {
    const idx = (i * config.pointsPerLine + j) * 3
    const z = j * config.lineLength
    const radius = config.tunnelRadius + j * config.tunnelExpansion

    positions[idx] = Math.cos(theta) * radius
    positions[idx + 1] = Math.sin(theta) * radius
    positions[idx + 2] = z

    const intensity = 1 - (j / (config.pointsPerLine - 1)) * 0.9
    colors[idx] = intensity
    colors[idx + 1] = intensity
    colors[idx + 2] = 1
    }

    const lineIdx = i * 3
    velocities[lineIdx] = 0
    velocities[lineIdx + 1] = 0
    velocities[lineIdx + 2] = config.minSpeed + Math.random() * config.speedVariation // Now positive for opposite direction

    timingOffsets[i] = Math.random() * Math.PI * 2
    }

    geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3))
    geometry.setAttribute('color', new THREE.BufferAttribute(colors, 3))

    const material = new THREE.LineBasicMaterial({
    vertexColors: true,
    transparent: true,
    opacity: 1,
    linewidth: 1.5,
    })

    const lines = new THREE.LineSegments(geometry, material)
    lines.userData.velocities = velocities
    lines.userData.timingOffsets = timingOffsets
    lines.userData.basePosition = basePosition
    lines.userData.transformedPosition = transformedPosition
    lines.userData.matrix = matrix
    lines.userData.startTime = Date.now()
    lines.visible = false

    lines.position.copy(this.camera.position)
    this.scene.add(lines)

    return lines
    }

    /**
    * Starts the warp effect animation
    */
    startWarpEffect() {
    if (!this.enableWarpEffect || this.isWarping) return

    this.isWarping = true
    this.currentCameraSpeed = 0

    if (!this.warpEffect) {
    this.warpEffect = this.createWarpEffect()
    }

    this.warpEffect.visible = true
    this.warpEffect.material.opacity = 1

    const animate = () => {
    if (!this.isWarping) return

    const config = this.warpConfig
    const positions = this.warpEffect.geometry.attributes.position.array
    const velocities = this.warpEffect.userData.velocities
    const timingOffsets = this.warpEffect.userData.timingOffsets
    const basePosition = this.warpEffect.userData.basePosition
    const transformedPosition = this.warpEffect.userData.transformedPosition
    const matrix = this.warpEffect.userData.matrix
    const currentTime = Date.now()
    const elapsedTime = (currentTime - this.warpEffect.userData.startTime) / 1000

    // Update camera position
    // Get camera's current forward direction
    this.camera.getWorldDirection(this.cameraDirection)

    // Accelerate camera movement
    this.currentCameraSpeed = Math.min(config.cameraSpeed, this.currentCameraSpeed + config.cameraAcceleration)

    // Move camera forward in its current direction
    this.camera.position.addScaledVector(this.cameraDirection, this.currentCameraSpeed)

    // Update warp effect position to follow camera
    this.warpEffect.position.copy(this.camera.position)
    matrix.copy(this.camera.matrix)

    for (let i = 0; i < velocities.length / 3; i++) {
    const baseVelocity = velocities[i * 3 + 2]
    const theta = (i / (velocities.length / 3)) * Math.PI * 2

    const timingOffset = timingOffsets[i]
    const movementFactor = (Math.sin(elapsedTime * config.oscillationSpeed + timingOffset) + 1) * 0.5
    const currentVelocity = baseVelocity * (config.minSpeedFactor + movementFactor * (1 - config.minSpeedFactor))

    for (let j = 0; j < config.pointsPerLine; j++) {
    const posIdx = (i * config.pointsPerLine + j) * 3

    basePosition.set(positions[posIdx], positions[posIdx + 1], positions[posIdx + 2])

    positions[posIdx + 2] += currentVelocity

    if (positions[posIdx + 2] > config.resetDistance) {
    for (let k = 0; k < config.pointsPerLine; k++) {
    const resetIdx = (i * config.pointsPerLine + k) * 3
    const z = k * config.lineLength - config.resetOffset
    const radius = config.tunnelRadius + k * config.tunnelExpansion

    positions[resetIdx] = Math.cos(theta) * radius
    positions[resetIdx + 1] = Math.sin(theta) * radius
    positions[resetIdx + 2] = z
    }
    }
    }
    }

    this.warpEffect.quaternion.copy(this.camera.quaternion)
    this.warpEffect.geometry.attributes.position.needsUpdate = true
    requestAnimationFrame(animate)
    }

    requestAnimationFrame(animate)
    }

    /**
    * Stops the warp effect animation
    */
    stopWarpEffect() {
    if (!this.warpEffect || !this.isWarping) return

    this.warpEffect.visible = false
    this.isWarping = false
    this.currentCameraSpeed = 0
    }

    /**
    * Handles player entry into start portal
    * @private
    */
    handleStartPortalEntry() {
    const url = refUrl()
    if (url) {
    if (this.enableWarpEffect) {
    this.startWarpEffect()
    }
    const currentParams = new URLSearchParams(window.location.search)
    const newParams = new URLSearchParams()
    for (const [key, value] of currentParams) {
    @@ -317,6 +603,10 @@ export class PortalManager {
    }
    }

    /**
    * Handles player entry into exit portal
    * @private
    */
    handleExitPortalEntry() {
    const currentParams = new URLSearchParams(window.location.search)
    const newParams = new URLSearchParams()
    @@ -346,9 +636,23 @@ export class PortalManager {
    document.body.appendChild(iframe)
    }

    if (this.enableWarpEffect) {
    this.startWarpEffect()
    }
    window.location.href = nextPage
    }

    /**
    * Creates both start and exit portals
    * @param {number} [x=45] - X coordinate
    * @param {number} [y=0] - Y coordinate
    * @param {number} [z=45] - Z coordinate
    * @param {number} [radius=6] - Portal radius
    * @param {VibeverseOptions} [options={}] - Additional portal options
    * @returns {Object} Object containing both portal references
    * @property {THREE.Group} exitPortal - The exit portal mesh
    * @property {THREE.Group|null} startPortal - The start portal mesh (null if not created)
    */
    createPortals(x = 45, y = 0, z = 45, radius = 6, options = {}) {
    // Create exit portal with default settings
    const exitPortal = this.createExitPortal(x, y, z, radius, {
    @@ -372,7 +676,18 @@ export class PortalManager {
    }
    }

    /**
    * Updates portal state
    */
    update() {
    // No need to update collision boxes every frame, we update them in checkPortalCollisions
    }

    /**
    * Toggles the warp effect on/off
    * @param {boolean} enable - Whether to enable the warp effect
    */
    toggleWarpEffect(enable) {
    this.enableWarpEffect = enable
    }
    }
  4. benallfree renamed this gist Apr 3, 2025. 1 changed file with 139 additions and 163 deletions.
    302 changes: 139 additions & 163 deletions PortalManager.ts → PortalManager.js
    Original file line number Diff line number Diff line change
    @@ -1,34 +1,78 @@
    import * as THREE from 'three'

    interface PortalOptions {
    labelText?: string
    labelColor?: string
    }

    interface PortalUserData {
    particlesGeometry: THREE.BufferGeometry
    type: 'entrance' | 'exit'
    /**
    * Vibeverse Portal Standard
    * -----------------------
    * Integration guide for connecting your experience to the Vibeverse metaverse.
    *
    * Quick Start:
    * 1. Import and initialize PortalManager with your Three.js scene, camera and socket
    * 2. Call createPortals() to add portal meshes to your scene
    *
    * Portal Behavior:
    * - Exit Portal (green): Takes users to Vibeverse
    * - Start Portal (red): Only appears when 'ref' URL param is present
    * - The Start Portal returns users to the referring experience
    *
    * URL Parameters:
    * - ref: The referring URL to return to (e.g. ?ref=yourgame.com)
    * - portal: Set to 'true' when coming from another experience
    * - username: Player identifier passed between experiences
    * - color: Avatar color preference
    *
    * Splash Page Bypass:
    * If your experience has a splash/loading page, you can check if the user is coming
    * from Vibeverse by using the isVibeverse() helper:
    *
    * ```js
    * if (isVibeverse()) {
    * // Skip splash and load directly into experience
    * }
    * ```
    *
    * Audio Handling:
    * When bypassing the splash screen, you should keep audio muted initially and only
    * start it after the first user interaction to comply with browser autoplay policies.
    * Here's an example of how to handle this:
    *
    * ```js
    * // Setup one-time interaction handler
    * const enableAudioOnInteraction = (event) => {
    * // Remove listeners since we only need this once
    * document.removeEventListener('click', enableAudioOnInteraction)
    * document.removeEventListener('touchstart', enableAudioOnInteraction)
    *
    * // Start your audio here
    * // yourAudioSystem.start()
    * }
    *
    * // Add listeners for both mouse and touch events
    * document.addEventListener('click', enableAudioOnInteraction)
    * document.addEventListener('touchstart', enableAudioOnInteraction)
    * ```
    *
    */

    // Helper to check if user is coming from Vibeverse
    export function isVibeverse() {
    return !!refUrl()
    }

    interface PortalWrapper extends THREE.Group {
    userData: {
    portal: THREE.Group
    particlesGeometry: THREE.BufferGeometry
    type: 'entrance' | 'exit'
    const refUrl = () => {
    const params = new URLSearchParams(window.location.search)
    const refUrl = params.get('ref')
    if (refUrl) {
    let url = refUrl
    if (!url.startsWith('http://') && !url.startsWith('https://')) {
    url = 'https://' + url
    }
    return url
    }
    return null
    }

    import * as THREE from 'three'

    export class PortalManager {
    private scene: THREE.Scene
    private camera: THREE.Camera
    private socket: any // TODO: Replace with proper socket type
    private startPortal: PortalWrapper | null
    private exitPortal: PortalWrapper | null
    private startPortalBox: THREE.Box3 | null
    private exitPortalBox: THREE.Box3 | null
    private playerCheckInterval: NodeJS.Timeout | null

    constructor(scene: THREE.Scene, camera: THREE.Camera, socket: any) {
    constructor(scene, camera, socket) {
    this.scene = scene
    this.camera = camera
    this.socket = socket
    @@ -39,13 +83,7 @@ export class PortalManager {
    this.playerCheckInterval = null
    }

    // Create a portal mesh with proper alignment and origin at bottom
    createPortalMesh(
    radius = 6,
    color = 0xff0000,
    options: PortalOptions = {}
    ): PortalWrapper {
    // Create a single container for the portal elements
    createPortalMesh(radius = 6, color = 0xff0000, options = {}) {
    const portal = new THREE.Group()

    // Create the torus ring
    @@ -79,37 +117,26 @@ export class PortalManager {
    const particleColors = new Float32Array(particleCount * 3)

    for (let i = 0; i < particleCount * 3; i += 3) {
    // Create particles in a ring around the portal
    const angle = Math.random() * Math.PI * 2
    const particleRadius = radius + (Math.random() - 0.5) * (radius * 0.15)

    // Position particles in a ring in the same plane as the portal
    particlePositions[i] = Math.cos(angle) * particleRadius // x
    particlePositions[i + 1] = Math.sin(angle) * particleRadius // y
    particlePositions[i + 2] = (Math.random() - 0.5) * (radius * 0.15) // z (small depth variation)
    particlePositions[i] = Math.cos(angle) * particleRadius
    particlePositions[i + 1] = Math.sin(angle) * particleRadius
    particlePositions[i + 2] = (Math.random() - 0.5) * (radius * 0.15)

    // Set color with slight variation
    if (color === 0xff0000) {
    // Red portal
    particleColors[i] = 0.8 + Math.random() * 0.2
    particleColors[i + 1] = 0
    particleColors[i + 2] = 0
    } else {
    // Green portal
    particleColors[i] = 0
    particleColors[i + 1] = 0.8 + Math.random() * 0.2
    particleColors[i + 2] = 0
    }
    }

    particlesGeometry.setAttribute(
    'position',
    new THREE.BufferAttribute(particlePositions, 3)
    )
    particlesGeometry.setAttribute(
    'color',
    new THREE.BufferAttribute(particleColors, 3)
    )
    particlesGeometry.setAttribute('position', new THREE.BufferAttribute(particlePositions, 3))
    particlesGeometry.setAttribute('color', new THREE.BufferAttribute(particleColors, 3))

    const particleMaterial = new THREE.PointsMaterial({
    size: radius * 0.03,
    @@ -121,34 +148,24 @@ export class PortalManager {
    const particles = new THREE.Points(particlesGeometry, particleMaterial)
    portal.add(particles)

    // Store particles for animation
    portal.userData = {
    particlesGeometry: particlesGeometry,
    type: color === 0xff0000 ? 'entrance' : 'exit',
    }

    // Set default label text based on portal type
    const defaultLabelText =
    portal.userData.type === 'entrance' ? 'Go back' : 'To Vibeverse'

    // Get label text from options or use default
    const defaultLabelText = portal.userData.type === 'entrance' ? 'Go back' : 'To Vibeverse'
    const labelText = options.labelText || defaultLabelText
    const wrapper = new THREE.Group()

    // Create a wrapper container with origin at bottom
    const wrapper = new THREE.Group() as PortalWrapper

    // Add label if text is provided
    if (labelText) {
    const canvas = document.createElement('canvas')
    const context = canvas.getContext('2d')
    if (!context) return wrapper // Skip label creation if context is null
    if (!context) return wrapper

    canvas.width = 512
    canvas.height = 64

    // Get label color from options or use portal color
    const labelColor =
    options.labelColor || (color === 0xff0000 ? '#ff0000' : '#00ff00')
    const labelColor = options.labelColor || (color === 0xff0000 ? '#ff0000' : '#00ff00')

    context.fillStyle = labelColor
    context.font = 'bold 32px Arial'
    @@ -164,21 +181,32 @@ export class PortalManager {
    })

    const label = new THREE.Mesh(labelGeometry, labelMaterial)
    label.position.y = radius * 1.5 // Position label above the portal
    label.position.y = radius * 1.5
    portal.add(label)
    }

    // Add portal to wrapper at origin first
    wrapper.add(portal)

    // Compute the portal's bounding box to get actual dimensions
    const bbox = new THREE.Box3().setFromObject(portal)
    portal.position.y = -bbox.min.y

    // Position the wrapper
    const position = options.position || { x: 0, y: 0, z: 0 }
    wrapper.position.set(position.x, position.y, position.z)

    // Apply rotation after positioning
    if (options.lookAt) {
    wrapper.rotation.setFromEuler(options.lookAt)
    } else {
    const targetVector = new THREE.Vector3(0, 0, 0)
    const direction = targetVector.sub(wrapper.position).normalize()
    const matrix = new THREE.Matrix4()
    matrix.lookAt(wrapper.position, targetVector, new THREE.Vector3(0, 1, 0))
    wrapper.quaternion.setFromRotationMatrix(matrix)

    // Rotate 180 degrees around Y axis to face the correct direction
    wrapper.rotateY(Math.PI)
    }

    // Position portal so its bottom is at y=0 of the wrapper
    // This ensures the bottom of the portal sits exactly on the ground
    portal.position.y = -bbox.min.y // Offset to make bottom align with y=0

    // Store reference to portal for animations
    wrapper.userData = {
    portal: portal,
    particlesGeometry: portal.userData.particlesGeometry,
    @@ -188,166 +216,99 @@ export class PortalManager {
    return wrapper
    }

    // Create entrance portal at specified coordinates
    createStartPortal(
    x = 0,
    y = 0,
    z = 0,
    radius = 6,
    options: PortalOptions = {}
    ): PortalWrapper {
    // Create portal mesh with default or custom options
    createStartPortal(x = 0, y = 0, z = 0, radius = 6, options = {}) {
    const portal = this.createPortalMesh(radius, 0xff0000, {
    labelText: options.labelText || 'Go back',
    labelColor: options.labelColor || '#ff0000',
    lookAt: options.lookAt,
    position: { x, y, z },
    })

    // Position the portal
    portal.position.x = x
    portal.position.y = y
    portal.position.z = z

    // Add portal to scene
    this.scene.add(portal)

    // Create portal collision box
    this.startPortalBox = new THREE.Box3().setFromObject(portal)

    // Store portal reference
    this.startPortal = portal

    // Start animation
    this.animateStartPortal()

    return portal
    }

    // Create exit portal at specified coordinates
    createExitPortal(
    x = 0,
    y = 0,
    z = 0,
    radius = 6,
    options: PortalOptions = {}
    ): PortalWrapper {
    // Create portal mesh with default or custom options
    createExitPortal(x = 0, y = 0, z = 0, radius = 6, options = {}) {
    const portal = this.createPortalMesh(radius, 0x00ff00, {
    labelText: options.labelText || 'To Vibeverse',
    labelColor: options.labelColor || '#00ff00',
    lookAt: options.lookAt,
    position: { x, y, z },
    })

    // Position the portal
    portal.position.x = x
    portal.position.y = y
    portal.position.z = z

    // Add portal to scene
    this.scene.add(portal)

    // Create portal collision box
    this.exitPortalBox = new THREE.Box3().setFromObject(portal)

    // Store portal reference
    this.exitPortal = portal

    // Start animation
    this.animateExitPortal()

    return portal
    }

    // Animate entrance portal particles
    animateStartPortal(): void {
    animateStartPortal() {
    if (!this.startPortal || !this.startPortal.userData) return

    // Get the particles geometry from the userData
    const particlesGeometry = this.startPortal.userData.particlesGeometry
    if (!particlesGeometry) return

    const positions = particlesGeometry.attributes.position.array

    for (let i = 0; i < positions.length; i += 3) {
    // Animate particles moving in/out slightly
    positions[i + 2] = Math.sin(Date.now() * 0.002 + i) * 0.3
    }

    particlesGeometry.attributes.position.needsUpdate = true
    requestAnimationFrame(this.animateStartPortal.bind(this))
    }

    // Animate exit portal particles
    animateExitPortal(): void {
    animateExitPortal() {
    if (!this.exitPortal || !this.exitPortal.userData) return

    // Get the particles geometry from the userData
    const particlesGeometry = this.exitPortal.userData.particlesGeometry
    if (!particlesGeometry) return

    const positions = particlesGeometry.attributes.position.array

    for (let i = 0; i < positions.length; i += 3) {
    // Animate particles moving in/out slightly
    positions[i + 2] = Math.sin(Date.now() * 0.002 + i) * 0.3
    }

    particlesGeometry.attributes.position.needsUpdate = true
    requestAnimationFrame(this.animateExitPortal.bind(this))
    }

    // Check if player has entered a portal
    checkPortalCollisions(player: {
    position: { x: number; y: number; z: number }
    }): void {
    checkPortalCollisions(player) {
    if (!player) return

    // Use player camera position for collision detection
    const playerPosition = new THREE.Vector3(
    player.position.x,
    player.position.y,
    player.position.z
    )
    const playerPosition = new THREE.Vector3(player.position.x, player.position.y, player.position.z)

    // Check entrance portal collision
    if (this.startPortalBox && this.startPortal) {
    // Update box to match current position
    this.startPortalBox.setFromObject(this.startPortal)

    // Expand the box slightly for better collision detection
    const expandedStartBox = this.startPortalBox.clone().expandByScalar(1.5)
    if (expandedStartBox.containsPoint(playerPosition)) {
    this.handleStartPortalEntry()
    }
    }

    // Check exit portal collision
    if (this.exitPortalBox && this.exitPortal) {
    // Update box to match current position
    this.exitPortalBox.setFromObject(this.exitPortal)

    // Expand the box slightly for better collision detection
    const expandedExitBox = this.exitPortalBox.clone().expandByScalar(1.5)
    if (expandedExitBox.containsPoint(playerPosition)) {
    this.handleExitPortalEntry()
    }
    }
    }

    // Handle entrance portal interaction
    handleStartPortalEntry(): void {
    // Get ref from URL params
    const urlParams = new URLSearchParams(window.location.search)
    const refUrl = urlParams.get('ref')
    if (refUrl) {
    // Add https if not present and include query params
    let url = refUrl
    if (!url.startsWith('http://') && !url.startsWith('https://')) {
    url = 'https://' + url
    }
    handleStartPortalEntry() {
    const url = refUrl()
    if (url) {
    const currentParams = new URLSearchParams(window.location.search)
    const newParams = new URLSearchParams()
    for (const [key, value] of currentParams) {
    if (key !== 'ref') {
    // Skip ref param since it's in the base URL
    newParams.append(key, value)
    }
    }
    @@ -356,33 +317,27 @@ export class PortalManager {
    }
    }

    // Handle exit portal interaction
    handleExitPortalEntry(): void {
    // Create parameters for the next page
    handleExitPortalEntry() {
    const currentParams = new URLSearchParams(window.location.search)
    const newParams = new URLSearchParams()
    newParams.append('portal', 'true') // Convert boolean to string
    newParams.append('portal', 'true')

    // If socket available, add username
    if (this.socket && this.socket.id) {
    const player = this.socket.id
    newParams.append('username', player)
    }

    newParams.append('color', 'white')

    // Copy other params from current URL
    for (const [key, value] of currentParams) {
    if (!newParams.has(key)) {
    newParams.append(key, value)
    }
    }

    const paramString = newParams.toString()
    const nextPage =
    'https://portal.pieter.com' + (paramString ? '?' + paramString : '')
    const nextPage = 'https://portal.pieter.com' + (paramString ? '?' + paramString : '')

    // Create hidden iframe to preload next page
    if (!document.getElementById('preloadFrame')) {
    const iframe = document.createElement('iframe')
    iframe.id = 'preloadFrame'
    @@ -391,12 +346,33 @@ export class PortalManager {
    document.body.appendChild(iframe)
    }

    // Navigate to the next page
    window.location.href = nextPage
    }

    // Update method to be called each frame
    update(): void {
    createPortals(x = 45, y = 0, z = 45, radius = 6, options = {}) {
    // Create exit portal with default settings
    const exitPortal = this.createExitPortal(x, y, z, radius, {
    labelText: 'To Vibeverse',
    labelColor: '#00ff00',
    lookAt: options.lookAt,
    })

    if (isVibeverse()) {
    // Create entrance portal slightly offset from exit portal
    const entrancePortal = this.createStartPortal(x - radius * 2.5, y, z, radius, {
    labelText: 'Go back',
    labelColor: '#ff0000',
    lookAt: options.lookAt,
    })
    }

    return {
    exitPortal,
    startPortal: this.startPortal,
    }
    }

    update() {
    // No need to update collision boxes every frame, we update them in checkPortalCollisions
    }
    }
  5. benallfree renamed this gist Mar 30, 2025. 1 changed file with 63 additions and 14 deletions.
    77 changes: 63 additions & 14 deletions PortalManager.js → PortalManager.ts
    Original file line number Diff line number Diff line change
    @@ -1,5 +1,34 @@
    import * as THREE from 'three'

    interface PortalOptions {
    labelText?: string
    labelColor?: string
    }

    interface PortalUserData {
    particlesGeometry: THREE.BufferGeometry
    type: 'entrance' | 'exit'
    }

    interface PortalWrapper extends THREE.Group {
    userData: {
    portal: THREE.Group
    particlesGeometry: THREE.BufferGeometry
    type: 'entrance' | 'exit'
    }
    }

    export class PortalManager {
    constructor(scene, camera, socket) {
    private scene: THREE.Scene
    private camera: THREE.Camera
    private socket: any // TODO: Replace with proper socket type
    private startPortal: PortalWrapper | null
    private exitPortal: PortalWrapper | null
    private startPortalBox: THREE.Box3 | null
    private exitPortalBox: THREE.Box3 | null
    private playerCheckInterval: NodeJS.Timeout | null

    constructor(scene: THREE.Scene, camera: THREE.Camera, socket: any) {
    this.scene = scene
    this.camera = camera
    this.socket = socket
    @@ -11,7 +40,11 @@ export class PortalManager {
    }

    // Create a portal mesh with proper alignment and origin at bottom
    createPortalMesh(radius = 6, color = 0xff0000, options = {}) {
    createPortalMesh(
    radius = 6,
    color = 0xff0000,
    options: PortalOptions = {}
    ): PortalWrapper {
    // Create a single container for the portal elements
    const portal = new THREE.Group()

    @@ -101,10 +134,15 @@ export class PortalManager {
    // Get label text from options or use default
    const labelText = options.labelText || defaultLabelText

    // Create a wrapper container with origin at bottom
    const wrapper = new THREE.Group() as PortalWrapper

    // Add label if text is provided
    if (labelText) {
    const canvas = document.createElement('canvas')
    const context = canvas.getContext('2d')
    if (!context) return wrapper // Skip label creation if context is null

    canvas.width = 512
    canvas.height = 64

    @@ -130,9 +168,6 @@ export class PortalManager {
    portal.add(label)
    }

    // Create a wrapper container with origin at bottom
    const wrapper = new THREE.Group()

    // Add portal to wrapper at origin first
    wrapper.add(portal)

    @@ -154,7 +189,13 @@ export class PortalManager {
    }

    // Create entrance portal at specified coordinates
    createStartPortal(x = 0, y = 0, z = 0, radius = 6, options = {}) {
    createStartPortal(
    x = 0,
    y = 0,
    z = 0,
    radius = 6,
    options: PortalOptions = {}
    ): PortalWrapper {
    // Create portal mesh with default or custom options
    const portal = this.createPortalMesh(radius, 0xff0000, {
    labelText: options.labelText || 'Go back',
    @@ -182,7 +223,13 @@ export class PortalManager {
    }

    // Create exit portal at specified coordinates
    createExitPortal(x = 0, y = 0, z = 0, radius = 6, options = {}) {
    createExitPortal(
    x = 0,
    y = 0,
    z = 0,
    radius = 6,
    options: PortalOptions = {}
    ): PortalWrapper {
    // Create portal mesh with default or custom options
    const portal = this.createPortalMesh(radius, 0x00ff00, {
    labelText: options.labelText || 'To Vibeverse',
    @@ -210,7 +257,7 @@ export class PortalManager {
    }

    // Animate entrance portal particles
    animateStartPortal() {
    animateStartPortal(): void {
    if (!this.startPortal || !this.startPortal.userData) return

    // Get the particles geometry from the userData
    @@ -229,7 +276,7 @@ export class PortalManager {
    }

    // Animate exit portal particles
    animateExitPortal() {
    animateExitPortal(): void {
    if (!this.exitPortal || !this.exitPortal.userData) return

    // Get the particles geometry from the userData
    @@ -248,7 +295,9 @@ export class PortalManager {
    }

    // Check if player has entered a portal
    checkPortalCollisions(player) {
    checkPortalCollisions(player: {
    position: { x: number; y: number; z: number }
    }): void {
    if (!player) return

    // Use player camera position for collision detection
    @@ -284,7 +333,7 @@ export class PortalManager {
    }

    // Handle entrance portal interaction
    handleStartPortalEntry() {
    handleStartPortalEntry(): void {
    // Get ref from URL params
    const urlParams = new URLSearchParams(window.location.search)
    const refUrl = urlParams.get('ref')
    @@ -308,11 +357,11 @@ export class PortalManager {
    }

    // Handle exit portal interaction
    handleExitPortalEntry() {
    handleExitPortalEntry(): void {
    // Create parameters for the next page
    const currentParams = new URLSearchParams(window.location.search)
    const newParams = new URLSearchParams()
    newParams.append('portal', true)
    newParams.append('portal', 'true') // Convert boolean to string

    // If socket available, add username
    if (this.socket && this.socket.id) {
    @@ -347,7 +396,7 @@ export class PortalManager {
    }

    // Update method to be called each frame
    update() {
    update(): void {
    // No need to update collision boxes every frame, we update them in checkPortalCollisions
    }
    }
  6. benallfree revised this gist Mar 28, 2025. 1 changed file with 28 additions and 13 deletions.
    41 changes: 28 additions & 13 deletions PortalManager.js
    Original file line number Diff line number Diff line change
    @@ -94,16 +94,28 @@ export class PortalManager {
    type: color === 0xff0000 ? 'entrance' : 'exit',
    }

    // Add label if specified
    if (options.label) {
    // Set default label text based on portal type
    const defaultLabelText =
    portal.userData.type === 'entrance' ? 'Go back' : 'To Vibeverse'

    // Get label text from options or use default
    const labelText = options.labelText || defaultLabelText

    // Add label if text is provided
    if (labelText) {
    const canvas = document.createElement('canvas')
    const context = canvas.getContext('2d')
    canvas.width = 512
    canvas.height = 64
    context.fillStyle = options.labelColor || '#00ff00'

    // Get label color from options or use portal color
    const labelColor =
    options.labelColor || (color === 0xff0000 ? '#ff0000' : '#00ff00')

    context.fillStyle = labelColor
    context.font = 'bold 32px Arial'
    context.textAlign = 'center'
    context.fillText(options.label, canvas.width / 2, canvas.height / 2)
    context.fillText(labelText, canvas.width / 2, canvas.height / 2)

    const texture = new THREE.CanvasTexture(canvas)
    const labelGeometry = new THREE.PlaneGeometry(radius * 1.6, radius * 0.25)
    @@ -142,13 +154,16 @@ export class PortalManager {
    }

    // Create entrance portal at specified coordinates
    createStartPortal(x = 0, y = 0, z = 0, radius = 6) {
    // Create portal mesh
    const portal = this.createPortalMesh(radius, 0xff0000)
    createStartPortal(x = 0, y = 0, z = 0, radius = 6, options = {}) {
    // Create portal mesh with default or custom options
    const portal = this.createPortalMesh(radius, 0xff0000, {
    labelText: options.labelText || 'Go back',
    labelColor: options.labelColor || '#ff0000',
    })

    // Position the portal
    portal.position.x = x
    portal.position.y = y // Will be at y=radius to sit on ground
    portal.position.y = y
    portal.position.z = z

    // Add portal to scene
    @@ -167,16 +182,16 @@ export class PortalManager {
    }

    // Create exit portal at specified coordinates
    createExitPortal(x = 0, y = 0, z = 0, radius = 6) {
    // Create portal mesh with label
    createExitPortal(x = 0, y = 0, z = 0, radius = 6, options = {}) {
    // Create portal mesh with default or custom options
    const portal = this.createPortalMesh(radius, 0x00ff00, {
    label: 'VIBEVERSE PORTAL',
    labelColor: '#00ff00',
    labelText: options.labelText || 'To Vibeverse',
    labelColor: options.labelColor || '#00ff00',
    })

    // Position the portal
    portal.position.x = x
    portal.position.y = y // Will be at y=radius to sit on ground
    portal.position.y = y
    portal.position.z = z

    // Add portal to scene
  7. benallfree created this gist Mar 28, 2025.
    338 changes: 338 additions & 0 deletions PortalManager.js
    Original file line number Diff line number Diff line change
    @@ -0,0 +1,338 @@
    export class PortalManager {
    constructor(scene, camera, socket) {
    this.scene = scene
    this.camera = camera
    this.socket = socket
    this.startPortal = null
    this.exitPortal = null
    this.startPortalBox = null
    this.exitPortalBox = null
    this.playerCheckInterval = null
    }

    // Create a portal mesh with proper alignment and origin at bottom
    createPortalMesh(radius = 6, color = 0xff0000, options = {}) {
    // Create a single container for the portal elements
    const portal = new THREE.Group()

    // Create the torus ring
    const tubeRadius = radius * 0.1
    const ringGeometry = new THREE.TorusGeometry(radius, tubeRadius, 16, 100)
    const ringMaterial = new THREE.MeshPhongMaterial({
    color: color,
    emissive: color,
    transparent: true,
    opacity: 0.8,
    })
    const ring = new THREE.Mesh(ringGeometry, ringMaterial)
    portal.add(ring)

    // Create portal inner surface
    const innerRadius = radius * 0.9
    const innerGeometry = new THREE.CircleGeometry(innerRadius, 32)
    const innerMaterial = new THREE.MeshBasicMaterial({
    color: color,
    transparent: true,
    opacity: 0.5,
    side: THREE.DoubleSide,
    })
    const inner = new THREE.Mesh(innerGeometry, innerMaterial)
    portal.add(inner)

    // Create particle system for portal effect
    const particleCount = 500
    const particlesGeometry = new THREE.BufferGeometry()
    const particlePositions = new Float32Array(particleCount * 3)
    const particleColors = new Float32Array(particleCount * 3)

    for (let i = 0; i < particleCount * 3; i += 3) {
    // Create particles in a ring around the portal
    const angle = Math.random() * Math.PI * 2
    const particleRadius = radius + (Math.random() - 0.5) * (radius * 0.15)

    // Position particles in a ring in the same plane as the portal
    particlePositions[i] = Math.cos(angle) * particleRadius // x
    particlePositions[i + 1] = Math.sin(angle) * particleRadius // y
    particlePositions[i + 2] = (Math.random() - 0.5) * (radius * 0.15) // z (small depth variation)

    // Set color with slight variation
    if (color === 0xff0000) {
    // Red portal
    particleColors[i] = 0.8 + Math.random() * 0.2
    particleColors[i + 1] = 0
    particleColors[i + 2] = 0
    } else {
    // Green portal
    particleColors[i] = 0
    particleColors[i + 1] = 0.8 + Math.random() * 0.2
    particleColors[i + 2] = 0
    }
    }

    particlesGeometry.setAttribute(
    'position',
    new THREE.BufferAttribute(particlePositions, 3)
    )
    particlesGeometry.setAttribute(
    'color',
    new THREE.BufferAttribute(particleColors, 3)
    )

    const particleMaterial = new THREE.PointsMaterial({
    size: radius * 0.03,
    vertexColors: true,
    transparent: true,
    opacity: 0.6,
    })

    const particles = new THREE.Points(particlesGeometry, particleMaterial)
    portal.add(particles)

    // Store particles for animation
    portal.userData = {
    particlesGeometry: particlesGeometry,
    type: color === 0xff0000 ? 'entrance' : 'exit',
    }

    // Add label if specified
    if (options.label) {
    const canvas = document.createElement('canvas')
    const context = canvas.getContext('2d')
    canvas.width = 512
    canvas.height = 64
    context.fillStyle = options.labelColor || '#00ff00'
    context.font = 'bold 32px Arial'
    context.textAlign = 'center'
    context.fillText(options.label, canvas.width / 2, canvas.height / 2)

    const texture = new THREE.CanvasTexture(canvas)
    const labelGeometry = new THREE.PlaneGeometry(radius * 1.6, radius * 0.25)
    const labelMaterial = new THREE.MeshBasicMaterial({
    map: texture,
    transparent: true,
    side: THREE.DoubleSide,
    })

    const label = new THREE.Mesh(labelGeometry, labelMaterial)
    label.position.y = radius * 1.5 // Position label above the portal
    portal.add(label)
    }

    // Create a wrapper container with origin at bottom
    const wrapper = new THREE.Group()

    // Add portal to wrapper at origin first
    wrapper.add(portal)

    // Compute the portal's bounding box to get actual dimensions
    const bbox = new THREE.Box3().setFromObject(portal)

    // Position portal so its bottom is at y=0 of the wrapper
    // This ensures the bottom of the portal sits exactly on the ground
    portal.position.y = -bbox.min.y // Offset to make bottom align with y=0

    // Store reference to portal for animations
    wrapper.userData = {
    portal: portal,
    particlesGeometry: portal.userData.particlesGeometry,
    type: portal.userData.type,
    }

    return wrapper
    }

    // Create entrance portal at specified coordinates
    createStartPortal(x = 0, y = 0, z = 0, radius = 6) {
    // Create portal mesh
    const portal = this.createPortalMesh(radius, 0xff0000)

    // Position the portal
    portal.position.x = x
    portal.position.y = y // Will be at y=radius to sit on ground
    portal.position.z = z

    // Add portal to scene
    this.scene.add(portal)

    // Create portal collision box
    this.startPortalBox = new THREE.Box3().setFromObject(portal)

    // Store portal reference
    this.startPortal = portal

    // Start animation
    this.animateStartPortal()

    return portal
    }

    // Create exit portal at specified coordinates
    createExitPortal(x = 0, y = 0, z = 0, radius = 6) {
    // Create portal mesh with label
    const portal = this.createPortalMesh(radius, 0x00ff00, {
    label: 'VIBEVERSE PORTAL',
    labelColor: '#00ff00',
    })

    // Position the portal
    portal.position.x = x
    portal.position.y = y // Will be at y=radius to sit on ground
    portal.position.z = z

    // Add portal to scene
    this.scene.add(portal)

    // Create portal collision box
    this.exitPortalBox = new THREE.Box3().setFromObject(portal)

    // Store portal reference
    this.exitPortal = portal

    // Start animation
    this.animateExitPortal()

    return portal
    }

    // Animate entrance portal particles
    animateStartPortal() {
    if (!this.startPortal || !this.startPortal.userData) return

    // Get the particles geometry from the userData
    const particlesGeometry = this.startPortal.userData.particlesGeometry
    if (!particlesGeometry) return

    const positions = particlesGeometry.attributes.position.array

    for (let i = 0; i < positions.length; i += 3) {
    // Animate particles moving in/out slightly
    positions[i + 2] = Math.sin(Date.now() * 0.002 + i) * 0.3
    }

    particlesGeometry.attributes.position.needsUpdate = true
    requestAnimationFrame(this.animateStartPortal.bind(this))
    }

    // Animate exit portal particles
    animateExitPortal() {
    if (!this.exitPortal || !this.exitPortal.userData) return

    // Get the particles geometry from the userData
    const particlesGeometry = this.exitPortal.userData.particlesGeometry
    if (!particlesGeometry) return

    const positions = particlesGeometry.attributes.position.array

    for (let i = 0; i < positions.length; i += 3) {
    // Animate particles moving in/out slightly
    positions[i + 2] = Math.sin(Date.now() * 0.002 + i) * 0.3
    }

    particlesGeometry.attributes.position.needsUpdate = true
    requestAnimationFrame(this.animateExitPortal.bind(this))
    }

    // Check if player has entered a portal
    checkPortalCollisions(player) {
    if (!player) return

    // Use player camera position for collision detection
    const playerPosition = new THREE.Vector3(
    player.position.x,
    player.position.y,
    player.position.z
    )

    // Check entrance portal collision
    if (this.startPortalBox && this.startPortal) {
    // Update box to match current position
    this.startPortalBox.setFromObject(this.startPortal)

    // Expand the box slightly for better collision detection
    const expandedStartBox = this.startPortalBox.clone().expandByScalar(1.5)
    if (expandedStartBox.containsPoint(playerPosition)) {
    this.handleStartPortalEntry()
    }
    }

    // Check exit portal collision
    if (this.exitPortalBox && this.exitPortal) {
    // Update box to match current position
    this.exitPortalBox.setFromObject(this.exitPortal)

    // Expand the box slightly for better collision detection
    const expandedExitBox = this.exitPortalBox.clone().expandByScalar(1.5)
    if (expandedExitBox.containsPoint(playerPosition)) {
    this.handleExitPortalEntry()
    }
    }
    }

    // Handle entrance portal interaction
    handleStartPortalEntry() {
    // Get ref from URL params
    const urlParams = new URLSearchParams(window.location.search)
    const refUrl = urlParams.get('ref')
    if (refUrl) {
    // Add https if not present and include query params
    let url = refUrl
    if (!url.startsWith('http://') && !url.startsWith('https://')) {
    url = 'https://' + url
    }
    const currentParams = new URLSearchParams(window.location.search)
    const newParams = new URLSearchParams()
    for (const [key, value] of currentParams) {
    if (key !== 'ref') {
    // Skip ref param since it's in the base URL
    newParams.append(key, value)
    }
    }
    const paramString = newParams.toString()
    window.location.href = url + (paramString ? '?' + paramString : '')
    }
    }

    // Handle exit portal interaction
    handleExitPortalEntry() {
    // Create parameters for the next page
    const currentParams = new URLSearchParams(window.location.search)
    const newParams = new URLSearchParams()
    newParams.append('portal', true)

    // If socket available, add username
    if (this.socket && this.socket.id) {
    const player = this.socket.id
    newParams.append('username', player)
    }

    newParams.append('color', 'white')

    // Copy other params from current URL
    for (const [key, value] of currentParams) {
    if (!newParams.has(key)) {
    newParams.append(key, value)
    }
    }

    const paramString = newParams.toString()
    const nextPage =
    'https://portal.pieter.com' + (paramString ? '?' + paramString : '')

    // Create hidden iframe to preload next page
    if (!document.getElementById('preloadFrame')) {
    const iframe = document.createElement('iframe')
    iframe.id = 'preloadFrame'
    iframe.style.display = 'none'
    iframe.src = nextPage
    document.body.appendChild(iframe)
    }

    // Navigate to the next page
    window.location.href = nextPage
    }

    // Update method to be called each frame
    update() {
    // No need to update collision boxes every frame, we update them in checkPortalCollisions
    }
    }