import { game } from "../application/application.ts";
import { Rect } from "./../geometries/rectangle.ts";
import type { Color } from "../math/color.ts";
import { ColorMatrix } from "../math/color_matrix.ts";
import { clamp, toBeCloseTo } from "./../math/math.ts";
import { Matrix3d } from "../math/matrix3d.ts";
import { Vector2d, vector2dPool } from "../math/vector2d.ts";
import { Vector3d } from "../math/vector3d.ts";
import { Bounds, boundsPool } from "./../physics/bounds.ts";
import type Container from "./../renderable/container.js";
import Renderable from "./../renderable/renderable.js";
import state from "../state/state.ts";
import {
	CANVAS_ONRESIZE,
	emit,
	GAME_RESET,
	on,
	VIEWPORT_ONCHANGE,
	VIEWPORT_ONRESIZE,
} from "../system/event.ts";
import timer from "../system/timer.ts";
import type Renderer from "./../video/renderer.js";
import ColorMatrixEffect from "./../video/webgl/effects/colorMatrix.js";
import type CameraEffect from "./effects/camera_effect.ts";
import FadeEffect from "./effects/fade_effect.ts";
import ShakeEffect from "./effects/shake_effect.ts";

/**
 * @import Entity from "./../renderable/entity/entity.js";
 * @import Sprite from "./../renderable/sprite.js";
 * @import NineSliceSprite from "./../renderable/nineslicesprite.js";
 */

interface AxisEnum {
	readonly NONE: 0;
	readonly HORIZONTAL: 1;
	readonly VERTICAL: 2;
	readonly BOTH: 3;
}

const targetV = new Vector2d();

/**
 * a 2D orthographic camera
 * @category Camera
 * @example
 * // create a minimap camera in the top-right corner showing the full level
 * const minimap = new Camera2d(0, 0, 180, 100);
 * minimap.name = "minimap";
 * minimap.screenX = app.viewport.width - 190;
 * minimap.screenY = 10;
 * minimap.autoResize = false;
 * minimap.setBounds(0, 0, levelWidth, levelHeight);
 * minimap.zoom = Math.min(180 / levelWidth, 100 / levelHeight);
 *
 * // add the camera to the current stage
 * this.cameras.set("minimap", minimap);
 */
export default class Camera2d extends Renderable {
	/**
	 * Preferred `Container.sortOn` mode for this camera type. Read by
	 * `Application` (and `Stage`) at bootstrap to initialize the world
	 * container's sort. Camera2d uses `"z"` (painter's-algorithm 2D
	 * layering, higher pos.z draws on top); `Camera3d` overrides to
	 * `"depth"` (camera-distance sort required by perspective).
	 * Subclasses can override to declare their own preferred mode.
	 */
	static defaultSortOn: "x" | "y" | "z" | "depth" = "z";

	/**
	 * Axis definition
	 * NONE no axis
	 * HORIZONTAL horizontal axis only
	 * VERTICAL vertical axis only
	 * BOTH both axis
	 */
	AXIS: AxisEnum;

	/**
	 * Camera bounds
	 */
	bounds: Bounds;

	/**
	 * enable or disable damping
	 * @default true
	 */
	smoothFollow: boolean;

	/**
	 * Camera damping for smooth follow `[0 .. 1]`. `1` snaps the camera
	 * to the target every frame (no smoothing); lower values produce a
	 * trailing follow — `0.1` is a soft springy follow, `0.5` is snappy.
	 *
	 * **Frame-rate independent (since 19.7 / #1478).** The value is the
	 * fraction of the gap covered per frame at the engine's target
	 * framerate ({@link timer.maxfps}, default 60). The underlying
	 * implementation switched from `pos.lerp` to `pos.damp` with
	 * `lambda = -ln(1 - damping) * timer.maxfps`, so the legacy
	 * per-frame fraction is recovered exactly at the target rate AND
	 * the wall-clock convergence stays constant when the actual frame
	 * rate drifts (slow machine, throttled tab, high-refresh display).
	 * Existing `damping` tuning carries over with no changes.
	 * @default 1.0
	 */
	damping: number;

	/**
	 * the closest point relative to the camera. Widened from `-1000` in
	 * 19.7 to accommodate sprites at large `depth` values now that
	 * `aVertex.z` participates in clip-space (PR A): the default
	 * `Container.autoDepth = true` assigns `pos.z = childCount` so any
	 * container with >1000 children would otherwise clip-cull, and the
	 * common Y-sort pattern `sprite.depth = sprite.pos.y` exceeds 1000
	 * on tall maps. Override per-camera if you need tighter z clipping.
	 * @default -1e6
	 */
	near: number;

	/**
	 * the furthest point relative to the camera. Widened from `1000` in
	 * 19.7 — see {@link Camera2d#near}.
	 * @default 1e6
	 */
	far: number;

	/**
	 * the x position on the screen where this camera viewport is rendered.
	 * @default 0
	 */
	screenX: number;

	/**
	 * the y position on the screen where this camera viewport is rendered.
	 * @default 0
	 */
	screenY: number;

	/**
	 * @ignore
	 */
	_zoom: number;

	/**
	 * the world-space projection matrix for non-default cameras (offset/zoomed).
	 * Maps world coordinates to the camera's screen viewport.
	 */
	worldProjection: Matrix3d;

	/**
	 * the screen-space projection matrix for non-default cameras.
	 * Maps coordinates so that (0,0) aligns with the camera's screenX/screenY position.
	 * Used for rendering floating elements (e.g. background layers) in the correct screen area.
	 */
	screenProjection: Matrix3d;

	/**
	 * Whether this camera should automatically resize when the canvas resizes.
	 * Set to false for non-default cameras with fixed dimensions
	 * (e.g. minimap, split-screen viewports).
	 * @default true
	 */
	autoResize: boolean;

	/**
	 * cached world view bounds
	 * @ignore
	 */
	_worldView: Bounds;

	/**
	 * the default camera projection matrix
	 * (2d cameras use an orthographic projection by default).
	 */
	projectionMatrix: Matrix3d;

	/**
	 * the invert camera transform used to unproject points
	 * @ignore
	 */
	invCurrentTransform: Matrix3d;

	/** offset for shake effect */
	offset: Vector2d;

	/** target to follow */
	target: Vector2d | Vector3d | null;

	/** default value follow */
	follow_axis: number;

	/**
	 * active camera effects (shake, fade, etc.)
	 */
	cameraEffects: CameraEffect[];

	/**
	 * A built-in color transformation matrix applied as the final post-processing pass.
	 * Provides convenient color grading (brightness, contrast, saturation, etc.)
	 * that is always applied after any effects added via {@link addPostEffect}.
	 * When set to identity (default), no effect is applied and there is zero overhead.
	 * @example
	 * // warm HDR-like color grading
	 * camera.colorMatrix.contrast(1.2).saturate(1.1);
	 * // reset to no color grading
	 * camera.colorMatrix.identity();
	 */
	colorMatrix: ColorMatrix;

	/** @ignore */
	_colorMatrixEffect: ColorMatrixEffect | null;

	/** the camera deadzone */
	deadzone: Rect;

	/**
	 * @param minX - start x offset
	 * @param minY - start y offset
	 * @param maxX - end x offset
	 * @param maxY - end y offset
	 */
	constructor(minX: number, minY: number, maxX: number, maxY: number) {
		super(minX, minY, maxX - minX, maxY - minY);

		this.AXIS = {
			NONE: 0,
			HORIZONTAL: 1,
			VERTICAL: 2,
			BOTH: 3,
		};

		this.bounds = boundsPool.get();

		this.smoothFollow = true;

		this.damping = 1.0;

		this.near = -1e6;

		this.far = 1e6;

		this.projectionMatrix = new Matrix3d();

		this.invCurrentTransform = new Matrix3d();

		// offset for shake effect
		this.offset = new Vector2d();

		// target to follow
		this.target = null;

		// default value follow
		this.follow_axis = this.AXIS.NONE;

		// active camera effects
		this.cameraEffects = [];

		// default screen position (top-left of canvas)
		this.screenX = 0;
		this.screenY = 0;
		this.zoom = 1;
		this.worldProjection = new Matrix3d();
		this.screenProjection = new Matrix3d();
		this._worldView = new Bounds();
		this.autoResize = true;

		// default camera name
		this.name = "default";

		// set a default deadzone
		this.setDeadzone(this.width / 6, this.height / 6);

		// for backward "compatibility" (in terms of behavior)
		this.anchorPoint.set(0, 0);

		// enable event detection on the camera
		this.isKinematic = false;

		// camera manages its own FBO lifecycle in draw()
		this._postEffectManaged = true;

		this.colorMatrix = new ColorMatrix();
		this._colorMatrixEffect = null;

		this.bounds.setMinMax(minX, minY, maxX, maxY);

		// update the projection matrix
		this._updateProjectionMatrix();

		// subscribe to the game reset event
		// eslint-disable-next-line @typescript-eslint/unbound-method
		on(GAME_RESET, this.reset, this);
		// subscribe to the canvas resize event
		// eslint-disable-next-line @typescript-eslint/unbound-method
		on(CANVAS_ONRESIZE, this.resize, this);
	}

	// -- some private function ---

	/** @ignore */
	// update the projection matrix based on the projection frame (a rectangle)
	_updateProjectionMatrix(): void {
		this.projectionMatrix.ortho(
			0,
			this.width,
			this.height,
			0,
			this.near,
			this.far,
		);
		// Mirror the screen ortho into `screenProjection` so default-
		// camera floating renderables can swap to it in `Container.draw`
		// without a no-op risk. Non-default cameras overwrite this in
		// `Camera2d.draw` (line ~946) with a screen-relative ortho;
		// Camera3d overrides this method and sets `screenProjection`
		// to a screen-space ortho separate from its perspective
		// `projectionMatrix`.
		this.screenProjection.copy(this.projectionMatrix);
	}

	/** @ignore */
	_followH(target: Vector2d | Vector3d): number {
		let targetX = this.pos.x;
		if (target.x - this.pos.x > this.deadzone.right) {
			targetX = Math.min(
				target.x - this.deadzone.right,
				this.bounds.width - this.width,
			);
		} else if (target.x - this.pos.x < this.deadzone.pos.x) {
			targetX = Math.max(target.x - this.deadzone.pos.x, this.bounds.left);
		}
		return targetX;
	}

	/** @ignore */
	_followV(target: Vector2d | Vector3d): number {
		let targetY = this.pos.y;
		if (target.y - this.pos.y > this.deadzone.bottom) {
			targetY = Math.min(
				target.y - this.deadzone.bottom,
				this.bounds.height - this.height,
			);
		} else if (target.y - this.pos.y < this.deadzone.pos.y) {
			targetY = Math.max(target.y - this.deadzone.pos.y, this.bounds.top);
		}
		return targetY;
	}

	// -- public function ---

	/**
	 * the zoom level of this camera.
	 * Values less than 1 zoom out (show more of the world),
	 * values greater than 1 zoom in (show less of the world).
	 * @default 1
	 * @example
	 * // zoom out to show the full level in a 180x100 minimap
	 * camera.zoom = Math.min(180 / levelWidth, 100 / levelHeight);
	 */
	get zoom(): number {
		return this._zoom;
	}

	set zoom(value: number) {
		this._zoom = value > 0 ? value : 1;
	}

	/**
	 * Whether this camera is using default settings (no screen offset, no zoom).
	 * Non-default cameras use custom projections for viewport positioning and scaling.
	 * @returns true if this camera has no screen offset and zoom is 1
	 */
	get isDefault(): boolean {
		return this.screenX === 0 && this.screenY === 0 && this.zoom === 1;
	}

	/**
	 * The world-space bounds currently visible through this camera,
	 * taking into account position and zoom level.
	 * @returns the visible world area
	 */
	get worldView(): Bounds {
		this._worldView.setMinMax(
			this.pos.x,
			this.pos.y,
			this.pos.x + this.width / this.zoom,
			this.pos.y + this.height / this.zoom,
		);
		return this._worldView;
	}

	/**
	 * Set the camera screen position and size in a single call.
	 * @param x - x position on screen
	 * @param y - y position on screen
	 * @param [w] - width (defaults to current width)
	 * @param [h] - height (defaults to current height)
	 * @returns this camera for chaining
	 */
	setViewport(x: number, y: number, w?: number, h?: number): this {
		this.screenX = x;
		this.screenY = y;
		if (typeof w !== "undefined" && typeof h !== "undefined") {
			super.resize(w, h);
			this._updateProjectionMatrix();
		}
		return this;
	}

	/**
	 * reset the camera position to specified coordinates
	 * @param [x=0] - initial position of the camera on the x axis
	 * @param [y=0] - initial position of the camera on the y axis
	 */
	reset(x: number = 0, y: number = 0): void {
		// reset the initial camera position to 0,0
		this.pos.x = x;
		this.pos.y = y;

		// reset the target
		this.unfollow();

		// damping default value
		this.smoothFollow = true;
		this.damping = 1.0;

		// reset the transformation matrix
		this.currentTransform.identity();
		this.invCurrentTransform.identity().invert();

		// reset the projection matrices
		this.worldProjection.identity();
		this.screenProjection.identity();

		// update the projection matrix
		this._updateProjectionMatrix();

		// remove non-persistent camera effects (persistent ones survive state changes)
		for (let i = this.cameraEffects.length - 1; i >= 0; i--) {
			if (!this.cameraEffects[i].isPersistent) {
				this.cameraEffects[i].destroy();
				this.cameraEffects.splice(i, 1);
			}
		}
	}

	/**
	 * change the deadzone settings.
	 * the "deadzone" defines an area within the current camera in which
	 * the followed renderable can move without scrolling the camera.
	 * @see {@link follow}
	 * @param w - deadzone width
	 * @param h - deadzone height
	 */
	setDeadzone(w: number, h: number): void {
		if (typeof this.deadzone === "undefined") {
			this.deadzone = new Rect(0, 0, 0, 0);
		}

		// reusing the old code for now...
		this.deadzone.pos.set(
			~~((this.width - w) / 2),
			~~((this.height - h) / 2 - h * 0.25),
		);
		this.deadzone.resize(w, h);

		this.smoothFollow = false;

		// force a camera update
		this.updateTarget();

		this.smoothFollow = true;
	}

	/**
	 * resize the camera
	 * @param w - new width of the camera
	 * @param h - new height of the camera
	 * @returns this camera
	 */
	override resize(w: number, h: number): this {
		// skip resize for non-auto-resize cameras (e.g. minimap, split-screen)
		if (!this.autoResize) {
			return this;
		}

		// parent consctructor, resize camera rect
		super.resize(w, h);

		// disable damping while resizing
		this.smoothFollow = false;

		// reset everything
		this.setBounds(0, 0, w, h);
		this.setDeadzone(w / 6, h / 6);
		this.update();
		this.smoothFollow = true;

		// update the projection matrix
		this._updateProjectionMatrix();

		// publish the viewport resize event
		emit(VIEWPORT_ONRESIZE, this.width, this.height);

		return this;
	}

	/**
	 * set the camera boundaries (set to the world limit by default).
	 * the camera is bound to the given coordinates and cannot move/be scrolled outside of it.
	 * @param x - world left limit
	 * @param y - world top limit
	 * @param w - world width limit
	 * @param h - world height limit
	 */
	setBounds(x: number, y: number, w: number, h: number): void {
		this.smoothFollow = false;
		this.bounds.setMinMax(x, y, w + x, h + y);
		this.moveTo(this.pos.x, this.pos.y);
		this.update();
		this.smoothFollow = true;
	}

	/**
	 * set the camera to follow the specified renderable. <br>
	 * (this will put the camera center around the given target)
	 * @param target - renderable or position vector to follow
	 * @param [axis=app.viewport.AXIS.BOTH] - Which axis to follow (see {@link Camera2d.AXIS})
	 * @param [damping=1] - default damping value
	 * @example
	 * // set the camera to follow this renderable on both axis, and enable damping
	 * app.viewport.follow(this, app.viewport.AXIS.BOTH, 0.1);
	 */
	follow(
		target: Renderable | Vector2d | Vector3d,
		axis?: number,
		damping?: number,
	): void {
		if (target instanceof Renderable) {
			this.target = target.pos;
		} else if (target instanceof Vector2d || target instanceof Vector3d) {
			this.target = target;
		} else {
			throw new Error("invalid target for me.Camera2d.follow");
		}
		// if axis is null, camera is moved on target center
		this.follow_axis = typeof axis === "undefined" ? this.AXIS.BOTH : axis;

		this.smoothFollow = false;

		if (typeof damping !== "number") {
			this.damping = 1;
		} else {
			this.damping = clamp(damping, 0.0, 1.0);
		}

		// force a camera update
		this.updateTarget();

		this.smoothFollow = true;
	}

	/**
	 * unfollow the current target
	 */
	unfollow(): void {
		this.target = null;
		this.follow_axis = this.AXIS.NONE;
	}

	/**
	 * move the camera upper-left position by the specified offset.
	 * @see {@link focusOn}
	 * @param x - horizontal offset
	 * @param y - vertical offset
	 * @example
	 * // Move the camera up by four pixels
	 * app.viewport.move(0, -4);
	 */
	move(x: number, y: number): void {
		this.moveTo(this.pos.x + x, this.pos.y + y);
	}

	/**
	 * move the camera upper-left position to the specified coordinates
	 * @see {@link focusOn}
	 * @param x - horizontal position
	 * @param y - vertical position
	 */
	moveTo(x: number, y: number): void {
		const _x = this.pos.x;
		const _y = this.pos.y;

		this.pos.x = clamp(x, this.bounds.left, this.bounds.width - this.width);
		this.pos.y = clamp(y, this.bounds.top, this.bounds.height - this.height);

		//publish the VIEWPORT_ONCHANGE event if necessary
		if (_x !== this.pos.x || _y !== this.pos.y) {
			this.isDirty = true;
		}
	}

	/** @ignore */

	updateTarget(dt?: number): void {
		if (this.target) {
			targetV.setV(this.pos);

			switch (this.follow_axis) {
				case this.AXIS.NONE:
					//this.focusOn(this.target);
					break;

				case this.AXIS.HORIZONTAL:
					targetV.x = this._followH(this.target);
					break;

				case this.AXIS.VERTICAL:
					targetV.y = this._followV(this.target);
					break;

				case this.AXIS.BOTH:
					targetV.x = this._followH(this.target);
					targetV.y = this._followV(this.target);
					break;

				default:
					break;
			}

			if (!this.pos.equals(targetV)) {
				// update the camera position
				if (this.smoothFollow && this.damping < 1.0) {
					// account for floating precision and check if we are close "enough"
					if (
						toBeCloseTo(targetV.x, this.pos.x, 2) &&
						toBeCloseTo(targetV.y, this.pos.y, 2)
					) {
						this.pos.setV(targetV);
						return;
					} else {
						// Frame-rate independent follow: re-anchor the parametric
						// `damping` to a damp lambda at the engine's target rate.
						const lambda = -Math.log(1 - this.damping) * timer.maxfps;
						const dts = (dt ?? timer.step) / 1000;
						this.pos.damp(targetV, lambda, dts);
					}
				} else {
					this.pos.setV(targetV);
				}
				this.isDirty = true;
			}
		}
	}

	/** @ignore */
	override update(dt?: number): boolean {
		// update the camera position
		this.updateTarget(dt);

		// update all active camera effects
		if (this.cameraEffects.length > 0) {
			for (const fx of this.cameraEffects) {
				fx.update(dt ?? 0);
			}
			// remove completed effects
			for (let i = this.cameraEffects.length - 1; i >= 0; i--) {
				if (this.cameraEffects[i].isComplete) {
					this.cameraEffects[i].destroy();
					this.cameraEffects.splice(i, 1);
				}
			}
			this.isDirty = true;
		}

		if (this.isDirty) {
			//publish the corresponding message
			emit(VIEWPORT_ONCHANGE, this.pos);
		}

		if (!this.currentTransform.isIdentity()) {
			this.invCurrentTransform.copy(this.currentTransform).invert();
		} else {
			// reset to default
			this.invCurrentTransform.identity();
		}

		return super.update(dt ?? 0);
	}

	/**
	 * shake the camera
	 * @param intensity - maximum offset that the screen can be moved
	 * while shaking
	 * @param duration - expressed in milliseconds
	 * @param [axis=app.viewport.AXIS.BOTH] - specify on which axis to apply the shake effect (see {@link Camera2d.AXIS})
	 * @param [onComplete] - callback once shaking effect is over
	 * @param [force] - if true this will override the current effect
	 * @example
	 * // shake it baby !
	 * app.viewport.shake(10, 500, app.viewport.AXIS.BOTH);
	 */
	shake(
		intensity: number,
		duration: number,
		axis?: number,
		onComplete?: () => void,
		force?: boolean,
	): void {
		// check if a shake is already running
		const existing = this.getCameraEffect(ShakeEffect);
		if (existing && force !== true) {
			return;
		}
		if (existing) {
			this.removeCameraEffect(existing);
		}
		this.addCameraEffect(
			new ShakeEffect(this, {
				intensity,
				duration,
				axis: axis || this.AXIS.BOTH,
				onComplete,
			}),
		);
	}

	/**
	 * fadeOut(flash) effect<p>
	 * screen is filled with the specified color and slowly goes back to normal
	 * @param color - a CSS color value
	 * @param [duration=1000] - expressed in milliseconds
	 * @param [onComplete] - callback once effect is over
	 * @example
	 * // fade the camera to white upon dying, reload the level, and then fade out back
	 * app.viewport.fadeIn("#fff", 150, function() {
	 *     me.audio.play("die", false);
	 *     me.level.reload();
	 *     app.viewport.fadeOut("#fff", 150);
	 * });
	 */
	fadeOut(
		color: Color | string,
		duration: number = 1000,
		onComplete?: () => void,
	): void {
		this.addCameraEffect(
			new FadeEffect(this, {
				color,
				duration,
				direction: "out",
				onComplete,
			}),
		);
	}

	/**
	 * fadeIn effect <p>
	 * fade to the specified color
	 * @param color - a CSS color value
	 * @param [duration=1000] - expressed in milliseconds
	 * @param [onComplete] - callback once effect is over
	 * @example
	 * // flash the camera to white for 75ms
	 * app.viewport.fadeIn("#FFFFFF", 75);
	 */
	fadeIn(
		color: Color | string,
		duration: number = 1000,
		onComplete?: () => void,
	): void {
		this.addCameraEffect(
			new FadeEffect(this, {
				color,
				duration,
				direction: "in",
				onComplete,
			}),
		);
	}

	/**
	 * Add a camera effect to this camera.
	 * @param effect - the camera effect to add
	 * @returns the added effect
	 * @example
	 * camera.addCameraEffect(new ShakeEffect(camera, { intensity: 10, duration: 500 }));
	 */
	addCameraEffect<T extends CameraEffect>(effect: T): T {
		this.cameraEffects.push(effect);
		return effect;
	}

	/**
	 * Get the first camera effect matching the given class.
	 * @param effectClass - the effect class to search for
	 * @returns the first matching effect, or undefined
	 * @example
	 * const shake = camera.getCameraEffect(ShakeEffect);
	 */
	getCameraEffect<T extends CameraEffect>(
		effectClass: new (...args: any[]) => T,
	): T | undefined {
		return this.cameraEffects.find((fx) => fx instanceof effectClass) as
			| T
			| undefined;
	}

	/**
	 * Remove a specific camera effect instance.
	 * @param effect - the effect to remove
	 * @example
	 * const fade = camera.getCameraEffect(FadeEffect);
	 * if (fade) camera.removeCameraEffect(fade);
	 */
	removeCameraEffect(effect: CameraEffect): void {
		const idx = this.cameraEffects.indexOf(effect);
		if (idx !== -1) {
			effect.destroy();
			this.cameraEffects.splice(idx, 1);
		}
	}

	/**
	 * set the camera position around the specified object
	 * @param target - the renderable to focus the camera on
	 */
	focusOn(target: Renderable): void {
		const bounds = target.getBounds() as Bounds;
		this.moveTo(
			bounds.left + bounds.width / 2 - this.width / 2,
			bounds.top + bounds.height / 2 - this.height / 2,
		);
	}

	/**
	 * check if the specified renderable is in the camera
	 * @param obj - to be checked against
	 * @param [floating = obj.floating] - if visibility check should be done against screen coordinates
	 * @returns true if within the viewport
	 */
	isVisible(obj: Renderable, floating: boolean = obj.floating): boolean {
		if (floating || obj.floating) {
			// floating objects use screen coordinates, check against the
			// camera viewport area (0, 0, camera.width, camera.height)
			const objBounds = obj.getBounds();
			if (
				objBounds.left === Infinity &&
				objBounds.top === Infinity &&
				objBounds.right === -Infinity &&
				objBounds.bottom === -Infinity
			) {
				// containers with no explicit size (cleared Infinity bounds) are always visible
				return true;
			}
			return (
				objBounds.left <= this.width &&
				objBounds.right >= 0 &&
				objBounds.top <= this.height &&
				objBounds.bottom >= 0
			);
		}
		// non-floating objects use world coordinates, check against
		// the camera's visible world area (accounts for position and zoom)
		return obj.getBounds().overlaps(this.worldView);
	}

	/**
	 * convert the given "local" (screen) coordinates into world coordinates
	 * @param x - the x coordinate of the local point to be converted
	 * @param y - the y coordinate of the local point to be converted
	 * @param [v] - an optional vector object where to set the converted value
	 * @returns the converted world coordinates as a Vector2d
	 */
	localToWorld(x: number, y: number, v?: Vector2d): Vector2d {
		v = v || vector2dPool.get();
		v.set(x, y).add(this.pos).sub(game.world.pos);
		if (!this.currentTransform.isIdentity()) {
			this.invCurrentTransform.apply(v);
		}
		return v;
	}

	/**
	 * convert the given world coordinates into "local" (screen) coordinates
	 * @param x - the x world coordinate to be converted
	 * @param y - the y world coordinate to be converted
	 * @param [v] - an optional vector object where to set the converted value
	 * @returns a vector with the converted local coordinates
	 */
	worldToLocal(x: number, y: number, v?: Vector2d): Vector2d {
		v = v || vector2dPool.get();
		v.set(x, y);
		if (!this.currentTransform.isIdentity()) {
			this.currentTransform.apply(v);
		}
		return v.sub(this.pos).add(game.world.pos);
	}

	/**
	 * Build and install the world + screen projections used when this
	 * camera is non-default (split-screen, picture-in-picture, etc.).
	 * Default behavior is a shifted ortho that places the camera's
	 * world view at the camera's screen-rect within the full renderer
	 * viewport. Subclasses (e.g. Camera3d) override this to install a
	 * perspective `worldProjection` instead.
	 * @ignore
	 */
	_setupNonDefaultProjection(renderer: Renderer): void {
		const left = -this.screenX / this.zoom;
		const top = -this.screenY / this.zoom;
		const rw = renderer.width / this.zoom;
		const rh = renderer.height / this.zoom;

		this.worldProjection.ortho(
			left,
			left + rw,
			top + rh,
			top,
			this.near,
			this.far,
		);
		renderer.setProjection(this.worldProjection);

		this.screenProjection.ortho(
			-this.screenX,
			-this.screenX + renderer.width,
			-this.screenY + renderer.height,
			-this.screenY,
			this.near,
			this.far,
		);
	}

	/**
	 * render the camera effects
	 * @ignore
	 */
	drawFX(renderer: Renderer): void {
		for (const fx of this.cameraEffects) {
			fx.draw(renderer, this.width, this.height);
		}
	}

	/**
	 * draw all objects visible in this viewport
	 * @ignore
	 */
	override draw(renderer: Renderer, container: Container): void {
		// cast to any to access canvas/webgl renderer-specific methods not on base Renderer
		const r = renderer as any;
		const isNonDefault = !this.isDefault;
		// for non-default cameras, compensate for the world container's
		// centering offset (applied inside container.draw via renderer.translate)
		const containerOffsetX = isNonDefault ? container.pos.x : 0;
		const containerOffsetY = isNonDefault ? container.pos.y : 0;
		const translateX = this.pos.x + this.offset.x + containerOffsetX;
		const translateY = this.pos.y + this.offset.y + containerOffsetY;

		// sync the built-in colorMatrix: append as final pass if non-identity
		if (!this.colorMatrix.isIdentity()) {
			if (!this._colorMatrixEffect) {
				this._colorMatrixEffect = new ColorMatrixEffect(renderer as any);
			}
			this._colorMatrixEffect.reset().multiply(this.colorMatrix);
			this.postEffects.push(this._colorMatrixEffect);
		}

		// post-effect: bind FBO if shader effects are set (WebGL only)
		const usePostEffect = r.beginPostEffect(this);

		// apply the world-to-camera-view transform on the container.
		// Camera2d translates by -camera.pos; Camera3d overrides this
		// hook to additionally apply the camera's rotation (pitch / yaw)
		// in the correct order (R⁻¹ ∘ T(-pos), via post-multiplication).
		this._applyContainerViewTransform(container, translateX, translateY);

		this.preDraw(r);

		// clip to camera viewport on screen (skip when rendering to FBO —
		// the FBO itself defines the render area)
		if (!usePostEffect) {
			r.clipRect(this.screenX, this.screenY, this.width, this.height);
		}

		// set camera projection for non-default cameras
		if (isNonDefault) {
			this._setupNonDefaultProjection(renderer);
		} else {
			renderer.setProjection(this.projectionMatrix);
		}

		// Upload active Light2d instances for the lit sprite pipeline.
		// Done here — after `setProjection()` (which can flush the
		// current batch) and before `container.draw()` walks the world
		// tree — so the lights are guaranteed to apply to every lit quad
		// pushed by the upcoming draw walk. Stage stays renderer-agnostic
		// (just exposes the raw lights set + ambient color); the renderer
		// decides how to encode them. Canvas's `setLightUniforms` is a
		// no-op.
		const stage = state.current();
		renderer.setLightUniforms(
			stage?._activeLights,
			stage?.ambientLightingColor,
			translateX,
			translateY,
		);

		container.preDraw(r);

		// for non-default cameras, temporarily expand the camera rect to the
		// visible world area so TMXLayer draws all tiles within the zoomed view.
		// When zoom is 1, worldView matches camera size — no visible effect.
		if (isNonDefault) {
			const view = this.worldView;
			const savedWidth = this.width;
			const savedHeight = this.height;
			this.width = view.width;
			this.height = view.height;
			container.draw(r, this);
			this.width = savedWidth;
			this.height = savedHeight;
		} else {
			container.draw(r, this);
		}

		// draw the viewport/camera effects
		this.drawFX(renderer);

		container.postDraw(r);

		this.postDraw(r);

		// stage-level lighting overlay — lights themselves render through the
		// world tree as Renderables; the Stage paints the ambient fill with
		// per-light cutouts here. Done inside the FBO bracket so any post-
		// effect on this camera (vignette, ColorMatrix, …) wraps the lighting.
		// duck-type rather than `instanceof Stage` to avoid a circular runtime
		// import between camera2d.ts and stage.ts. Pass the same
		// translateX/translateY the world container was rendered with so
		// non-default cameras (minimap/splitscreen) line up correctly —
		// `camera.pos + camera.offset` alone misses `containerOffsetX/Y`.
		if (stage) {
			stage.drawLighting(renderer, this, translateX, translateY);
		}

		// post-effect: unbind FBO and blit to screen through shader effect
		r.endPostEffect(this);

		// Restore the full-canvas viewport for any non-default camera.
		// Camera2d's non-default path never changes it (the sub-rect
		// is handled via the ortho left/right/top/bottom shift +
		// `clipRect`), so this is a no-op. Camera3d's non-default
		// path DOES change it inside `_setupNonDefaultProjection` to
		// remap NDC into the sub-rect — without this restore, the
		// next camera in the stage's render order would inherit the
		// shrunken WebGL viewport. `endPostEffect` already does this
		// for cameras with post-effects (FBO bracket), but non-FBO
		// non-default Camera3d cameras would otherwise leak.
		if (isNonDefault) {
			renderer.setViewport(0, 0, renderer.width, renderer.height);
		}

		// remove the transient colorMatrix effect so it doesn't persist between frames
		if (this._colorMatrixEffect) {
			const idx = this.postEffects.indexOf(this._colorMatrixEffect);
			if (idx !== -1) {
				this.postEffects.splice(idx, 1);
			}
		}

		// revert the world-to-camera-view transform applied above
		this._revertContainerViewTransform(container, translateX, translateY);
	}

	/**
	 * Apply the world-to-camera-view transform to the container before
	 * its draw walk. Camera2d translates by `-camera.pos` (plus shake
	 * offset); Camera3d overrides this to additionally rotate by
	 * `-camera.pitch` / `-camera.yaw` in the correct order.
	 * @ignore
	 */
	_applyContainerViewTransform(
		container: Container,
		translateX: number,
		translateY: number,
	): void {
		container.translate(-translateX, -translateY);
	}

	/**
	 * Revert the transform applied by {@link Camera2d#_applyContainerViewTransform}.
	 * Must undo each mutation in reverse order. Subclasses overriding
	 * `_applyContainerViewTransform` should override this too.
	 * @ignore
	 */
	_revertContainerViewTransform(
		container: Container,
		translateX: number,
		translateY: number,
	): void {
		container.translate(translateX, translateY);
	}

	/**
	 * @ignore
	 */
	override destroy(): void {
		// clean up the internal colorMatrix effect (may not be in postEffects if identity)
		if (this._colorMatrixEffect) {
			if (typeof this._colorMatrixEffect.destroy === "function") {
				this._colorMatrixEffect.destroy();
			}
			this._colorMatrixEffect = null;
		}
		super.destroy();
	}
}
