Wrapping World - Like Defender, but both axis!

I am currently trying to achieve a game world that loops / wraps around, when you leave the right you come back in on the left. If you leave at the top you come back in at the bottom. The original arcade game Defender did this on one axis, but I am trying to do this on both.

The problem here is that when you approach the world’s boundary, for a brief time you will need to see what is on the other side of the boundary. If it was just a side scroller like defender I could probably use a second camera, but because it is in both directions I guess I probably need to clone objects from the other side of the world until my camera is within the world boundaries again.

Has anybody had any success with something like this? If so I’d be interested to know how you achieved it.

I have implemented some logic for duplicating prefabs in my game, but it’s not working very well!

Has anyone else tried creating a Wrapping World at all? Is duplicating objects the correct way to go? I am really looking for confirmation that duplicating objects is the best way to go… If so, I will persevere for a bit, failing that I may have to simplify my game!

import { _decorator, Component, Node, Vec3, instantiate, Camera, view, js, RigidBody2D, UITransform, NodePool, Prefab } from 'cc';
import { GameConstants } from './constants';

const { ccclass, property } = _decorator;
import { GameObject } from './GameObject';

@ccclass('BoundaryDuplicator')
export class BoundaryDuplicator extends Component {
    @property(Node)
    player: Node | null = null;

    @property(Camera)
    camera: Camera | null = null;

    @property({ type: [Prefab]})
    prefabs: Prefab[] = [];

    private clones: Map<Node, Node[]> = new Map(); 
    private prefabPools: Map<Prefab, NodePool> = new Map();

    onLoad() {
        for(const prefab of this.prefabs) {
            this.createPoolForPrefab(prefab);
        }
    }

    lateUpdate(deltaTime: number) {
        if (!this.player || !this.camera || !this.node) {
            console.warn('Player, camera, or world node is missing!');
            return;
        }

        const visibleBounds = this.getVisibleBounds();

        const children = this.node.children.filter(child => !child.name.includes('_Clone'));
        for (const child of children) {
            const prefab = this.getPrefabForNode(child);
            if(prefab) {
                this.updateClones(child, visibleBounds, prefab);
            }
        }
    }

    private getVisibleBounds(): { min: Vec3; max: Vec3 } {
        if (!this.camera) {
            throw new Error("Camera is not set.");
        }

        const orthoHeight = this.camera.orthoHeight;
        const aspectRatio = view.getVisibleSize().width / view.getVisibleSize().height;

        const halfWidth = orthoHeight * aspectRatio;
        const halfHeight = orthoHeight;

        const cameraPosition = this.camera.node.position;

        return {
            min: new Vec3(cameraPosition.x - halfWidth, cameraPosition.y - halfHeight, 0),
            max: new Vec3(cameraPosition.x + halfWidth, cameraPosition.y + halfHeight, 0),
        };
    }

    private updateClones(node: Node, visibleBounds: { min: Vec3; max: Vec3 }, prefab: Prefab) {
        const basePosition = node.position.clone();

        if(!this.clones.has(node)) {
            this.clones.set(node, []);
        }

        const nodeClones = this.clones.get(node);
    
        const objectSize = this.getObjectSize(node);

        const offsets = [
            { x: -GameConstants.WORLD.WIDTH, y: 0 }, // Left
            { x: GameConstants.WORLD.WIDTH, y: 0 },  // Right
            { x: 0, y: -GameConstants.WORLD.HEIGHT }, // Bottom
            { x: 0, y: GameConstants.WORLD.HEIGHT }, // Top
            { x: -GameConstants.WORLD.WIDTH, y: -GameConstants.WORLD.HEIGHT }, // Bottom-Left
            { x: -GameConstants.WORLD.WIDTH, y: GameConstants.WORLD.HEIGHT }, // Top-Left
            { x: GameConstants.WORLD.WIDTH, y: -GameConstants.WORLD.HEIGHT }, // Bottom-Right
            { x: GameConstants.WORLD.WIDTH, y: GameConstants.WORLD.HEIGHT }, // Top-Right
        ];
    
        offsets.forEach((offset, index) => {
            const clonePosition = new Vec3(
                basePosition.x + offset.x,
                basePosition.y + offset.y,
                basePosition.z
            );
    
            const inView =
                clonePosition.x + objectSize.width / 2 >= visibleBounds.min.x &&
                clonePosition.x - objectSize.width / 2 <= visibleBounds.max.x &&
                clonePosition.y + objectSize.height / 2 >= visibleBounds.min.y &&
                clonePosition.y - objectSize.height / 2 <= visibleBounds.max.y;
    
            if (inView) {
                if (!nodeClones[index]) {
                    
                    const clone = this.getOrCreateClone(prefab, node);
                    clone.name = node.name + '_Clone';
                    //const asteroidComponent = clone.getComponent(Asteroid);
                    //asteroidComponent.destroy();
                    this.node.addChild(clone);
                    
                    clone.setPosition(clonePosition);
    
                    nodeClones[index] = clone;
                } else {
                    nodeClones[index].setPosition(clonePosition);
                }
            } else if (nodeClones[index]) {
                    //nodeClones[index].destroy();
                    this.returnToPool(prefab, nodeClones[index]);
                    nodeClones[index] = null;
            }
        });
    }

    private createPoolForPrefab(prefab: Prefab) {
        if(!this.prefabPools.has(prefab)) {
            this.prefabPools.set(prefab, new NodePool());
        }
    }

    private getOrCreateClone(prefab: Prefab, original: Node): Node {
        const pool = this.prefabPools.get(prefab);
        let clone: Node;
        if(pool && pool.size() > 0) {
            clone = pool.get();
        } else {
            clone = instantiate(prefab);
        }

        this.copyOrRemoveProperties(original, clone);

        const gameObjectComponent = clone.getComponent(GameObject);
        if (gameObjectComponent) {
            gameObjectComponent.originalObject = original;
        }

        return clone;
    }

    private copyOrRemoveProperties(original: Node, clone: Node) {
        clone.setRotation(original.getRotation());
        clone.setScale(original.getScale());

        const originalRigidBody = original.getComponent(RigidBody2D);
        const cloneRigidBody = clone.getComponent(RigidBody2D);

        if(originalRigidBody && cloneRigidBody) {
            cloneRigidBody.linearVelocity = originalRigidBody.linearVelocity.clone();
            cloneRigidBody.angularVelocity = originalRigidBody.angularVelocity;
        }
    }

    private returnToPool(prefab: Prefab, node: Node) {
        const pool = this.prefabPools.get(prefab);
        if(pool) {
            pool.put(node);
        } else {
            node.destroy();
        }
    }

    private getPrefabForNode(node: Node): Prefab | null {
        for (const prefab of this.prefabs) {
            if(node.name.startsWith(prefab.data.name)) {
                return prefab;
            }
        }
        return null;
    }

    private getObjectSize(node: Node): { width: number; height: number } {
        const uiTransform = node.getComponent(UITransform);
        if (uiTransform) {
            return {
                width: uiTransform.contentSize.width,
                height: uiTransform.contentSize.height,
            };
        }
       
        return { width: 0, height: 0 };
    }
    
}
import { _decorator, Component, Node } from 'cc';
const { ccclass, property } = _decorator;

@ccclass('GameObject')
export class GameObject extends Component {
    originalObject: Node | null = null;

    onDisable() {
        if (this.  originalObject) {
            this.originalObject.removeFromParent();
            this.originalObject.destroy();
            this.originalObject = null;
        }
    }

    onDestroy(): void {
        if (this.originalObject) {
            this.originalObject.removeFromParent();
            this.originalObject.destroy();
            this.originalObject = null;
        }
    }
}

Maybe you should load another game scene when the player goes out the bound of world.