import { Workspace } from "@rbxts/services";
import { Component } from "engine/shared/component/Component";
import { Objects } from "engine/shared/fixes/Objects";
import { GameDefinitions } from "shared/data/GameDefinitions";

/** Generates terrain height */
export interface ChunkGenerator {
	getHeight(x: number, z: number): number;
}

/** Generates terrain height */
export interface ChunkRenderer<T = defined> {
	readonly chunkSize: number;
	readonly loadDistanceMultiplier?: number;

	renderChunk(chunkX: number, chunkZ: number): T;
	destroyChunk(chunkX: number, chunkZ: number, chunk: T): void;
	unloadAll(chunks: readonly T[]): void;
	destroy(): void;
}

/** Controls chunk loading and unloading in relation to the player position */
export class ChunkLoader<T = defined> extends Component {
	private loadedChunks: Record<number, Record<number, { chunk?: T }>> = {};
	private radiusLoaded = 0;

	private readonly loadDistance;
	private readonly loadDistancePow;
	private readonly maxVisibleHeight = 3000 + GameDefinitions.HEIGHT_OFFSET;

	constructor(
		private readonly chunkRenderer: ChunkRenderer<T>,
		loadDistance: number,
	) {
		super();

		this.loadDistance =
			(loadDistance / chunkRenderer.chunkSize) * (16 * 4) * (chunkRenderer.loadDistanceMultiplier ?? 1);
		this.loadDistancePow = math.pow(this.loadDistance, 2);

		task.spawn(() => this.createChunkLoader());
		this.onDisable(() => {
			chunkRenderer.unloadAll(
				asMap(this.loadedChunks).flatmap((k, v) =>
					asMap(v)
						.filter((k, c) => c.chunk !== undefined)
						.map((k, c) => c.chunk!),
				),
			);

			this.loadedChunks = {};
		});
		this.onDestroy(() => chunkRenderer.destroy());
	}

	private createChunkLoader() {
		if (!game.IsLoaded()) {
			game.Loaded.Wait();
		}

		let prevPosX = math.huge;
		let prevPosZ = math.huge;

		let c = os.clock() as number | undefined;
		while (true as boolean) {
			task.wait();
			if (this.isDestroyed()) return;
			if (!this.isEnabled()) continue;
			if (!Workspace.CurrentCamera) continue;

			if (this.isTooHigh()) {
				for (const [x, c] of pairs(this.loadedChunks)) {
					for (const [y] of pairs(c)) {
						this.unloadChunk(x, y);
					}

					task.wait();
				}

				do {
					task.wait();
				} while (this.isTooHigh());

				continue;
			}

			let pos = Workspace.CurrentCamera?.Focus?.Position ?? Vector3.zero;
			if (pos.X !== pos.X || pos.Y !== pos.Y || pos.Z !== pos.Z) {
				// nan
				pos = Vector3.zero;
			}

			const chunkX = math.floor(pos.X / this.chunkRenderer.chunkSize);
			const chunkZ = math.floor(pos.Z / this.chunkRenderer.chunkSize);

			if (prevPosX !== chunkX || prevPosZ !== chunkZ) {
				this.unloadChunks(chunkX, chunkZ);
				this.radiusLoaded = 0;

				prevPosX = chunkX;
				prevPosZ = chunkZ;
			}

			if (this.radiusLoaded < this.loadDistance) {
				this.loadChunksNextSingleRadius(chunkX, chunkZ);
				continue;
			} else if (c !== undefined) {
				// print(os.clock() - c);
				c = undefined;
			}
		}
	}

	private generateChunk(chunkX: number, chunkZ: number) {
		return this.chunkRenderer.renderChunk(chunkX, chunkZ);
	}

	private loadChunk(chunkX: number, chunkZ: number) {
		if (this.loadedChunks[chunkX]?.[chunkZ]) {
			return;
		}

		(this.loadedChunks[chunkX] ??= {})[chunkZ] = {};
		this.loadedChunks[chunkX][chunkZ].chunk = this.generateChunk(chunkX, chunkZ);
	}
	private unloadChunk(chunkX: number, chunkZ: number) {
		if (!this.loadedChunks[chunkX]?.[chunkZ]) {
			return;
		}

		const chunk = this.loadedChunks[chunkX][chunkZ].chunk;

		delete this.loadedChunks[chunkX][chunkZ];
		if (Objects.size(this.loadedChunks[chunkX]) === 0) {
			delete this.loadedChunks[chunkX];
		}

		if (chunk !== undefined) {
			this.chunkRenderer.destroyChunk(chunkX, chunkZ, chunk);
		}
	}

	private shouldBeLoaded(chunkX: number, chunkZ: number, centerX: number, centerZ: number) {
		if (math.pow(chunkX - centerX, 2) + math.pow(chunkZ - centerZ, 2) > this.loadDistancePow) {
			return false;
		}

		return true;
	}
	private isTooHigh() {
		return Workspace.CurrentCamera && Workspace.CurrentCamera.Focus.Position.Y >= this.maxVisibleHeight;
	}

	private unloadChunks(centerX: number, centerZ: number) {
		for (const [chunkX, data] of pairs(this.loadedChunks)) {
			for (const [chunkZ, _] of pairs(data)) {
				if (this.loadedChunks[chunkX]?.[chunkZ] && this.loadedChunks[chunkX][chunkZ].chunk === undefined) {
					continue;
				}
				if (this.shouldBeLoaded(chunkX, chunkZ, centerX, centerZ)) {
					continue;
				}

				this.unloadChunk(chunkX, chunkZ);
			}
		}
	}

	private loadChunksNextSingleRadius(centerX: number, centerZ: number) {
		const size = this.radiusLoaded++;

		for (let num = -size; num <= size; num++) {
			for (const [x, z] of [
				[num, -size],
				[-size, num],
				[num, size],
				[size, num],
			]) {
				const chunkX = centerX + x;
				const chunkZ = centerZ + z;

				if (this.loadedChunks[chunkX]?.[chunkZ]) continue;
				if (!this.shouldBeLoaded(chunkX, chunkZ, centerX, centerZ)) continue;

				this.loadChunk(chunkX, chunkZ);
			}
		}
	}
}


import { Workspace } from "@rbxts/services";
import { BB } from "engine/shared/fixes/BB";
import { TerrainDataInfo } from "shared/TerrainDataInfo";
import type { ChunkGenerator } from "client/terrain/ChunkLoader";

const baseplate = Workspace.WaitForChild("Obstacles").WaitForChild("Baseplate") as BasePart;
const bb = BB.fromPart(baseplate);

const offset = bb.center.Position.div(4);
const size = bb.originalSize.div(2);

const slopefunc = (x: number, w: number) => math.max(-math.pow(x / w, -16) + 1, 0);

const terrainData = TerrainDataInfo.data;
const heightData: Record<number, Record<number, number>> = {};

export const DefaultChunkGenerator: ChunkGenerator = {
	getHeight(x: number, z: number): number {
		// if (heightData[x]?.[z] !== undefined) {
		// 	return heightData[x][z];
		// }

		let height = 0;
		for (const data of terrainData.noises) {
			const noise = math.noise(x * data[3], data[1], z * data[3]);
			height += math.clamp(noise, data[4], data[5]) * data[2];
		}

		height *= math.max(slopefunc(x - offset.X, size.X / 2 - 20), slopefunc(z - offset.Z, size.Z / 2 - 20));

		height += terrainData.shift;
		height = math.clamp(height, terrainData.minimumHeight, terrainData.maximumHeight);

		return height;
	},
};


import { Workspace } from "@rbxts/services";
import { initLavaKillPlane } from "client/controller/KillPlane";
import { Element } from "engine/shared/Element";
import { GameDefinitions } from "shared/data/GameDefinitions";
import type { ChunkRenderer } from "client/terrain/ChunkLoader";

const parent = Element.create("Folder", { Name: "Flaterra", Parent: Workspace.WaitForChild("Obstacles") });

type config = {
	readonly snowOnly: boolean;
	readonly isLava: boolean;
	readonly override: TerrainConfiguration["override"];
};
export const FlatTerrainRenderer = (
	height: number,
	chunkSize: number = 1024,
	config?: config,
): ChunkRenderer<Instance> => ({
	chunkSize,
	loadDistanceMultiplier: 4,

	renderChunk(chunkX, chunkZ) {
		if (math.random() > 0.95) {
			task.wait();
		}

		const part = new Instance("Part");
		part.Anchored = true;
		part.CastShadow = false;

		part.Position = new Vector3(
			chunkX * this.chunkSize,
			height + GameDefinitions.HEIGHT_OFFSET,
			chunkZ * this.chunkSize,
		);
		part.Size = new Vector3(this.chunkSize, 2, this.chunkSize);

		if (config?.override?.enabled) {
			part.Material = Enum.Material[config.override.material];
			part.Color = config.override.color.color;
		} else if (config?.isLava) {
			part.Material = Enum.Material.CrackedLava;
			part.Color = Color3.fromRGB(255, 42, 0);
		} else if (config?.snowOnly) {
			part.Material = Enum.Material.Snow;
			part.Color = math.random() > 0.9999 ? new Color3(0.8, 0.8, 0.4) : new Color3(0.8, 0.8, 0.8);
		} else {
			part.Material = Enum.Material.Sand;
			part.Color = Color3.fromRGB(246, 215, 176);
		}

		if (part.Material === Enum.Material.CrackedLava) {
			initLavaKillPlane(part);
		}

		part.Parent = parent;
		return part;
	},
	destroyChunk(chunkX, chunkZ, chunk) {
		chunk.Destroy();
	},
	unloadAll(chunks) {
		for (const chunk of chunks) {
			chunk.Destroy();
		}
	},
	destroy() {},
});


import { ReplicatedStorage, Workspace } from "@rbxts/services";
import { GameDefinitions } from "shared/data/GameDefinitions";
import { TerrainDataInfo } from "shared/TerrainDataInfo";
import type { ChunkGenerator } from "client/terrain/ChunkLoader";

const terrainData = TerrainDataInfo.data;
const materialData: number[][] = [];

const materialEnums: Record<number, Enum.Material> = {};
for (const item of Enum.Material.GetEnumItems()) {
	materialEnums[item.Value] = item;
}

let chunkSize: number = undefined!;
let generator: ChunkGenerator = undefined!;

const infterrainActor = {
	Load: new Instance("BindableEvent") as BindableEvent<
		(chunkX: number, chunkZ: number, loadFoliage: boolean, config?: config) => void
	>,
	Loaded: new Instance("BindableEvent") as BindableEvent<(chunkX: number, chunkZ: number) => void>,
	Unload: new Instance("BindableEvent") as BindableEvent<(chunkX: number, chunkZ: number) => void>,

	initialize: (_chunkSize: number, _generator: ChunkGenerator) => {
		chunkSize = _chunkSize;
		generator = _generator;
	},
} as const;

const terrain = Workspace.Terrain;
type config = {
	readonly snowOnly: boolean;
};
infterrainActor.Load.Event.ConnectParallel((chunkX: number, chunkZ: number, loadFoliage: boolean, config?: config) => {
	const startX = chunkX * chunkSize;
	const startZ = chunkZ * chunkSize;
	const endX = startX + chunkSize - 1;
	const endZ = startZ + chunkSize - 1;
	const heights: number[][] = [];
	const models: { readonly 1: Model; readonly 2: CFrame; readonly 3: Vector3 }[] = [];
	let minimumHeight = math.huge;
	let maximumHeight = -math.huge;

	debug.profilebegin("TERRA1");
	debug.profilebegin("generating");
	for (let x = startX - 1 - 1; x < endX + 1; x++) {
		heights[x] = [];

		for (let z = startZ - 1 - 1; z < endZ + 1; z++) {
			const height = generator.getHeight(x, z);
			minimumHeight = math.min(height, minimumHeight);
			maximumHeight = math.max(height, maximumHeight);
			heights[x][z] = height;
		}
	}
	debug.profileend();

	debug.profilebegin("thickening");
	minimumHeight -= terrainData.thickness;
	maximumHeight = math.max(maximumHeight, terrainData.waterHeight);
	minimumHeight = math.floor(minimumHeight / 4) * 4;
	maximumHeight = math.ceil(maximumHeight / 4) * 4;
	const region = new Region3(
		new Vector3(startX * 4, minimumHeight + GameDefinitions.HEIGHT_OFFSET, startZ * 4),
		new Vector3(endX * 4 + 4, maximumHeight + GameDefinitions.HEIGHT_OFFSET, endZ * 4 + 4),
	);
	debug.profileend();
	debug.profilebegin("reading");
	const [materials, occupancys] = terrain.ReadVoxels(region, 4);
	debug.profileend();

	debug.profilebegin("what");
	const minimumHeightd4 = minimumHeight / 4;
	for (let x = 0; x < materials.Size.X; x++) {
		for (let z = 0; z < materials.Size.Z; z++) {
			const voxelX = startX + x - 1 + 1;
			const voxelZ = startZ + z - 1 + 1;
			const height = heights[voxelX][voxelZ];
			const heightd4 = height / 4;
			let [nMinimumHeight, nMaximumHeight] = [math.huge, -math.huge];

			for (let nx = voxelX - 1 - 1; nx < voxelX + 1; nx++) {
				for (let nz = voxelZ - 1 - 1; nz < voxelZ + 1; nz++) {
					const height = heights[nx][nz];
					nMinimumHeight = math.min(height, nMinimumHeight);
					nMaximumHeight = math.max(height, nMaximumHeight);
				}
			}

			const slope = nMaximumHeight - nMinimumHeight;
			let material: Enum.Material = undefined!;

			if (materialData[voxelX]?.[voxelZ] !== undefined) {
				material = materialEnums[materialData[voxelX][voxelZ]];
			} else {
				for (const materialData of terrainData.materials) {
					if (height < materialData[2] || height >= materialData[3]) {
						continue;
					}
					if (slope < materialData[4] || slope >= materialData[5]) {
						continue;
					}

					material = materialEnums[materialData[1]];
					break;
				}
			}
			if (config?.snowOnly) {
				material = Enum.Material.Snow;
			}

			if (loadFoliage) {
				for (const modelData of terrainData.models) {
					if (math.fmod(voxelX, modelData[2]) !== 0 || math.fmod(voxelZ, modelData[2]) !== 0) {
						continue;
					}
					if (height < modelData[3] || height >= modelData[4]) {
						continue;
					}
					if (slope < modelData[5] || slope >= modelData[6]) {
						continue;
					}
					let load = true;
					let offset = new Vector3(0, 0, 0);
					let scale = new Vector3(1, 1, 1);
					let rotation = new Vector3(0, 0, 0);
					for (const data of modelData[7]) {
						if (data[1] === 1) {
							const noise = math.noise(voxelX * data[3], data[2], voxelZ * data[3]);
							if (noise < data[4] || noise >= data[5]) {
								load = false;
								break;
							}
						} else if (data[1] === 2) {
							offset = offset.add(
								new Vector3(
									data[4] + math.noise(voxelX * data[3], data[2], voxelZ * data[3]) * data[5],
									0,
									0,
								),
							);
						} else if (data[1] === 3) {
							offset = offset.add(
								new Vector3(
									0,
									data[4] + math.noise(voxelX * data[3], data[2], voxelZ * data[3]) * data[5],
									0,
								),
							);
						} else if (data[1] === 4) {
							offset = offset.add(
								new Vector3(
									0,
									0,
									data[4] + math.noise(voxelX * data[3], data[2], voxelZ * data[3]) * data[5],
								),
							);
						} else if (data[1] === 5) {
							scale = scale.mul(
								data[4] + math.noise(voxelX * data[3], data[2], voxelZ * data[3]) * data[5],
							);
						} else if (data[1] === 6) {
							scale = scale.mul(
								new Vector3(
									data[4] + math.noise(voxelX * data[3], data[2], voxelZ * data[3]) * data[5],
									1,
									1,
								),
							);
						} else if (data[1] === 7) {
							scale = scale.mul(
								new Vector3(
									1,
									data[4] + math.noise(voxelX * data[3], data[2], voxelZ * data[3]) * data[5],
									1,
								),
							);
						} else if (data[1] === 8) {
							scale = scale.mul(
								new Vector3(
									1,
									1,
									data[4] + math.noise(voxelX * data[3], data[2], voxelZ * data[3]) * data[5],
								),
							);
						} else if (data[1] === 9) {
							rotation = rotation.add(
								new Vector3(
									data[4] + math.noise(voxelX * data[3], data[2], voxelZ * data[3]) * data[5],
									0,
									0,
								),
							);
						} else if (data[1] === 10) {
							rotation = rotation.add(
								new Vector3(
									0,
									data[4] + math.noise(voxelX * data[3], data[2], voxelZ * data[3]) * data[5],
									0,
								),
							);
						} else if (data[1] === 11) {
							rotation = rotation.add(
								new Vector3(
									0,
									0,
									data[4] + math.noise(voxelX * data[3], data[2], voxelZ * data[3]) * data[5],
								),
							);
						}
					}
					if (!load) {
						continue;
					}
					if (scale.X <= 0 || scale.Y <= 0 || scale.Z <= 0) {
						continue;
					}

					const data = {
						1: ReplicatedStorage.FindFirstChild("TerrainModels")!.FindFirstChild(modelData[1]) as Model,
						2: new CFrame(new Vector3(voxelX * 4, height, voxelZ * 4).add(offset))
							.mul(
								CFrame.fromOrientation(
									math.rad(rotation.X),
									math.rad(rotation.Y),
									math.rad(rotation.Z),
								),
							)
							.add(new Vector3(0, GameDefinitions.HEIGHT_OFFSET, 0)),
						3: scale,
					} as const;
					models.push(data);

					break;
				}
			}

			for (let y = 0; y < materials.Size.Y; y++) {
				const yHeight = minimumHeightd4 + (y + 1);
				const occupancy = heightd4 - yHeight;

				if (occupancy > 0) {
					materials[x][y][z] = material;
					occupancys[x][y][z] = occupancy;
				} else {
					const occupancy = terrainData.waterHeight / 4 - yHeight;
					if (occupancy <= 0) {
						continue;
					}

					materials[x][y][z] = Enum.Material.Water;
					occupancys[x][y][z] = occupancy;
				}
			}
		}
	}
	debug.profileend();
	debug.profileend();
	task.synchronize();

	terrain.WriteVoxels(region, 4, materials, occupancys);
	infterrainActor.Loaded.Fire(chunkX, chunkZ);

	if (models.size() === 0) {
		return;
	}

	const folder = new Instance("Folder");
	folder.Name = chunkX + "," + chunkZ;
	folder.Parent = terrain;

	for (const data of models) {
		const clone = data[1].Clone();
		clone.PivotTo(data[2]);

		for (const descendant of clone.GetDescendants()) {
			if (!descendant.IsA("BasePart")) {
				continue;
			}

			descendant.PivotOffset = descendant.PivotOffset.add(
				descendant.PivotOffset.Position.mul(data[3]).sub(descendant.PivotOffset.Position),
			);
			descendant.Position = data[2].Position.add(descendant.Position.sub(data[2].Position).mul(data[3]));
			descendant.Size = descendant.Size.mul(data[3]);
		}
		clone.Parent = folder;
	}
});

const fastunload = true;
if (fastunload) {
	infterrainActor.Unload.Event.Connect((chunkX: number, chunkZ: number) => {
		const startX = chunkX * chunkSize;
		const startZ = chunkZ * chunkSize;
		const endX = startX + chunkSize - 1;
		const endZ = startZ + chunkSize - 1;

		const region = new Region3(
			new Vector3(startX * 4, -300, startZ * 4),
			new Vector3(endX * 4 + 4, 500, endZ * 4 + 4),
		);

		if (math.min(region.Size.X, region.Size.Y, region.Size.Z) <= 0) {
			return;
		}

		terrain.FillBlock(region.CFrame, region.Size, Enum.Material.Air);
		terrain.FindFirstChild(chunkX + "," + chunkZ)?.Destroy();
	});
} else {
	infterrainActor.Unload.Event.ConnectParallel((chunkX: number, chunkZ: number) => {
		const startX = chunkX * chunkSize;
		const startZ = chunkZ * chunkSize;
		const endX = startX + chunkSize - 1;
		const endZ = startZ + chunkSize - 1;
		let minimumHeight = math.huge;
		let maximumHeight = -math.huge;

		for (let x = startX - 1 - 1; x < endX + 1; x++) {
			for (let z = startZ - 1 - 1; z < endZ + 1; z++) {
				const height = generator.getHeight(x, z);
				minimumHeight = math.min(height, minimumHeight);
				maximumHeight = math.max(height, maximumHeight);
			}
		}

		minimumHeight -= terrainData.thickness;
		maximumHeight = math.max(maximumHeight, terrainData.waterHeight);
		minimumHeight = math.floor(minimumHeight / 4) * 4;
		maximumHeight = math.ceil(maximumHeight / 4) * 4;
		const region = new Region3(
			new Vector3(startX * 4, minimumHeight, startZ * 4),
			new Vector3(endX * 4 + 4, maximumHeight, endZ * 4 + 4),
		);
		const [materials, occupancys] = terrain.ReadVoxels(region, 4);

		for (let x = 0; x < materials.Size.X; x++) {
			for (let z = 0; z < materials.Size.Z; z++) {
				for (let y = 0; y < materials.Size.Y; y++) {
					materials[x][y][z] = Enum.Material.Air;
					occupancys[x][y][z] = 0;
				}
			}
		}
		task.synchronize();

		terrain.WriteVoxels(region, 4, materials, occupancys);
		terrain.FindFirstChild(chunkX + "," + chunkZ)?.Destroy();
	});
}

// uses default as a name in InfiniteTerrain.ts
export default infterrainActor;
export type InfiniteTerrainActor = typeof infterrainActor;


import { ReplicatedFirst, Workspace } from "@rbxts/services";
import { ServiceIntegrityChecker } from "client/integrity/ServiceIntegrityChecker";
import type { ChunkGenerator, ChunkRenderer } from "client/terrain/ChunkLoader";
import type { InfiniteTerrainActor } from "client/terrain/InfiniteTerrainActor";

type config = {
	readonly snowOnly: boolean;
};
export const TerrainChunkRenderer = (
	generator: ChunkGenerator,
	foliage: boolean,
	config?: config,
): ChunkRenderer<true> => {
	const chunkSize = 16;
	const actorAmount = 8;

	const folder = new Instance("Folder", ReplicatedFirst);
	folder.Name = "TerrainActors";
	ServiceIntegrityChecker.whitelistInstance(folder);
	const actors: InfiniteTerrainActor[] = [];
	let selectedActor = 0;

	const clearActors = () => {
		for (const actor of folder.GetChildren()) {
			if (actor.IsA("Actor")) {
				actor.Destroy();
			}
		}
		actors.clear();
	};
	const recreateActors = () => {
		clearActors();

		for (let i = 1; i < actorAmount; i++) {
			const actor = new Instance("Actor");
			actor.Parent = folder;

			const actorScript = (script.Parent!.WaitForChild("InfiniteTerrainActor") as ModuleScript).Clone();
			actorScript.Parent = actor;

			const tactor = (require(actorScript) as { default: InfiniteTerrainActor }).default;
			tactor.initialize(chunkSize, generator);
			tactor.Loaded.Event.Connect(() => actorSemaphore.release());
			actors.push(tactor);
		}
	};
	recreateActors();

	const createSemaphore = (maxCount: number) => {
		const queue: Callback[] = [];
		let currentCount = maxCount;

		const q = {
			wait: () => {
				if (currentCount > 0) {
					currentCount--;
					return;
				}

				let completed = false;
				const resolver = () => (completed = true);
				queue.push(resolver);

				while (!completed) {
					task.wait();
				}
			},
			release: () => {
				if (queue.size() === 0) {
					if (currentCount > maxCount) throw "Trying to release beyond the maximum.";
					currentCount++;
					return;
				}

				queue.remove(0)?.();
			},
		};

		return q;
	};

	const actorSemaphore = createSemaphore(actorAmount);
	const findAvailableActor = () => {
		const actor = actors[++selectedActor];
		if (actor) return actor;

		return actors[(selectedActor = 0)];
	};

	return {
		chunkSize: chunkSize * 4,

		renderChunk(chunkX: number, chunkZ: number): true {
			actorSemaphore.wait();
			findAvailableActor().Load.Fire(chunkX, chunkZ, foliage, config);

			return true;
		},
		destroyChunk(chunkX: number, chunkZ: number): void {
			findAvailableActor().Unload.Fire(chunkX, chunkZ);
		},
		unloadAll(chunks) {
			clearActors();
			Workspace.Terrain.Clear();
			Workspace.Terrain.ClearAllChildren();
		},
		destroy() {
			clearActors();
			folder.Destroy();
		},
	};
};


import { Workspace } from "@rbxts/services";
import { Element } from "engine/shared/Element";
import { GameDefinitions } from "shared/data/GameDefinitions";
import type { ChunkGenerator, ChunkRenderer } from "client/terrain/ChunkLoader";

type config = {
	readonly addSandBelowSeaLevel: boolean;
	readonly snowOnly: boolean;
	readonly override: TerrainConfiguration["override"];
};
const parent = Element.create("Folder", { Name: "Triterra", Parent: Workspace.WaitForChild("Obstacles") });
export const TriangleChunkRenderer = (
	generator: ChunkGenerator,
	chunkResolution: number = 8,
	config?: config,
): ChunkRenderer<Instance> => {
	const chunkSize = 128 * 4;
	const squareSize = chunkSize / chunkResolution;
	const squareHalfSize = squareSize / 2;
	const thickness = 10;
	const half_thickness = thickness / 2;
	const errorAngle = 90;
	const newWedge = (size: Vector3, cframe: CFrame) => {
		const wedge = new Instance("WedgePart");
		wedge.Size = size;
		wedge.CFrame = cframe;
		wedge.Anchored = true;
		wedge.CastShadow = false;
		wedge.Locked = true;

		return wedge;
	};
	const createTriangle = (a: Vector3, b: Vector3, c: Vector3) => {
		let [ab, ac, bc] = [b.sub(a), c.sub(a), c.sub(b)];
		const [abd, acd, bcd] = [ab.Dot(ab), ac.Dot(ac), bc.Dot(bc)];

		if (abd > acd && abd > bcd) {
			[c, a] = [a, c];
		} else if (acd > bcd && acd > abd) {
			[a, b] = [b, a];
		}

		[ab, ac, bc] = [b.sub(a), c.sub(a), c.sub(b)];

		const right = ac.Cross(ab).Unit;
		const up = bc.Cross(right).Unit;
		const back = bc.Unit;

		const height = math.abs(ab.Dot(up));

		const w1CFrame = CFrame.fromMatrix(a.add(b).div(2), right, up, back);
		const w1 = newWedge(
			new Vector3(thickness, height, math.abs(ab.Dot(back))),
			w1CFrame.add(
				right.mul(
					math.floor(math.deg(math.abs(w1CFrame.ToOrientation()[1]))) === errorAngle
						? -half_thickness
						: half_thickness,
				),
			),
		);

		const w2CFrame = CFrame.fromMatrix(a.add(c).div(2), right.mul(-1), up, back.mul(-1));
		const w2 = newWedge(
			new Vector3(thickness, height, math.abs(ac.Dot(back))),
			w2CFrame.add(
				right.mul(
					math.floor(math.deg(math.abs(w2CFrame.ToOrientation()[1]))) === errorAngle
						? -half_thickness
						: half_thickness,
				),
			),
		);

		return $tuple(w1, w2);
	};
	const generateSquare = (x: number, z: number, xpzp: number, xpzn: number, xnzp: number, xnzn: number) => {
		const vpp = new Vector3(x + squareHalfSize, xpzp, z + squareHalfSize);
		const vpn = new Vector3(x + squareHalfSize, xpzn, z - squareHalfSize);
		const vnp = new Vector3(x - squareHalfSize, xnzp, z + squareHalfSize);
		const vnn = new Vector3(x - squareHalfSize, xnzn, z - squareHalfSize);

		const [w11, w12] = createTriangle(vpp, vpn, vnp);
		const [w21, w22] = createTriangle(vnp, vnn, vpn);

		const minHeight = math.min(xpzp, xpzn, xnzp, xnzn) - GameDefinitions.HEIGHT_OFFSET;
		const maxHeight = math.max(xpzp, xpzn, xnzp, xnzn) - GameDefinitions.HEIGHT_OFFSET;
		const heightDiff = maxHeight - minHeight;

		if (config?.override?.enabled) {
			for (const wedge of [w11, w12, w21, w22]) {
				wedge.Material = Enum.Material[config.override.material];
				wedge.Color = config.override.color.color;
			}
		} else if (config?.snowOnly) {
			for (const wedge of [w11, w12, w21, w22]) {
				wedge.Material = Enum.Material.Snow;
				wedge.Color = math.random() > 0.9999 ? new Color3(0.8, 0.8, 0.4) : new Color3(0.8, 0.8, 0.8);
			}
		} else {
			if (heightDiff > 80 / math.sqrt(chunkResolution / 8)) {
				for (const wedge of [w11, w12, w21, w22]) {
					wedge.Material = Enum.Material.Basalt;
					wedge.Color = new Color3(0.2, 0.2, 0.2);
				}
			} else if (maxHeight > 250) {
				for (const wedge of [w11, w12, w21, w22]) {
					wedge.Material = Enum.Material.Ice;
					wedge.Color = new Color3(1, 1, 1);
				}
			} else {
				if (
					config?.addSandBelowSeaLevel &&
					(w11.Position.Y < GameDefinitions.HEIGHT_OFFSET ||
						w12.Position.Y < GameDefinitions.HEIGHT_OFFSET ||
						w21.Position.Y < GameDefinitions.HEIGHT_OFFSET ||
						w22.Position.Y < GameDefinitions.HEIGHT_OFFSET)
				) {
					for (const wedge of [w11, w12, w21, w22]) {
						wedge.Material = Enum.Material.Sand;
						wedge.Color = Color3.fromRGB(246, 215, 176);
					}
				} else {
					for (const wedge of [w11, w12, w21, w22]) {
						wedge.Material = Enum.Material.Grass;
						wedge.Color = Color3.fromRGB(102, 130, 84);
					}
				}
			}
		}

		return $tuple(w11, w12, w21, w22);
	};

	return {
		chunkSize,
		loadDistanceMultiplier: 2,

		renderChunk(chunkx: number, chunkz: number): Instance {
			const chunk = new Instance("Folder", parent);

			for (let iterx = 0; iterx < chunkResolution; iterx++) {
				if (chunkResolution > 1 && math.random() > 0.8) {
					task.wait();
				}

				for (let iterz = 0; iterz < chunkResolution; iterz++) {
					const relx = iterx * squareSize;
					const relz = iterz * squareSize;

					const absx = chunkx * chunkSize + relx;
					const absz = chunkz * chunkSize + relz;

					debug.profilebegin("Generating height");
					const xpzp =
						generator.getHeight((absx + squareHalfSize) / 4, (absz + squareHalfSize) / 4) +
						GameDefinitions.HEIGHT_OFFSET;
					const xpzn =
						generator.getHeight((absx + squareHalfSize) / 4, (absz - squareHalfSize) / 4) +
						GameDefinitions.HEIGHT_OFFSET;
					const xnzp =
						generator.getHeight((absx - squareHalfSize) / 4, (absz + squareHalfSize) / 4) +
						GameDefinitions.HEIGHT_OFFSET;
					const xnzn =
						generator.getHeight((absx - squareHalfSize) / 4, (absz - squareHalfSize) / 4) +
						GameDefinitions.HEIGHT_OFFSET;
					debug.profileend();

					debug.profilebegin("Generating triangles");
					const [w11, w12, w21, w22] = generateSquare(absx, absz, xpzp, xpzn, xnzp, xnzn);
					w11.Parent = chunk;
					w12.Parent = chunk;
					w21.Parent = chunk;
					w22.Parent = chunk;
					debug.profileend();
				}
			}

			return chunk;
		},
		destroyChunk(chunkX: number, chunkZ: number, chunk: Instance): void {
			chunk.Destroy();
		},
		unloadAll(chunks) {
			for (const chunk of chunks) {
				chunk.Destroy();
			}
		},
		destroy() {},
	};
};


import { Workspace } from "@rbxts/services";
import { GameDefinitions } from "shared/data/GameDefinitions";
import type { ChunkRenderer } from "client/terrain/ChunkLoader";

export const WaterTerrainChunkRenderer = (): ChunkRenderer<true> => {
	const chunkSize = 16;
	const height = 400;
	const terrain = Workspace.WaitForChild("Terrain") as Terrain;

	const getRegion = (startX: number, startZ: number) => {
		const endX = startX + chunkSize - 1;
		const endZ = startZ + chunkSize - 1;

		return new Region3(
			new Vector3(startX * 4, -height + GameDefinitions.HEIGHT_OFFSET, startZ * 4),
			new Vector3(endX * 4 + 4, GameDefinitions.HEIGHT_OFFSET, endZ * 4 + 4),
		);
	};

	const [emptyMaterials, emptyOccupancys] = terrain.ReadVoxels(getRegion(0, 0), 4);
	const [waterMaterials, waterOccupancys] = terrain.ReadVoxels(getRegion(0, 0), 4);
	for (let x = 0; x < waterMaterials.size(); x++) {
		for (let y = 0; y < waterMaterials[x].size(); y++) {
			for (let z = 0; z < waterMaterials[x][y].size(); z++) {
				waterMaterials[x][y][z] = Enum.Material.Water;
				waterOccupancys[x][y][z] = 1;
			}
		}
	}
	for (let x = 0; x < waterMaterials.size(); x++) {
		for (let z = 0; z < waterMaterials[x][0].size(); z++) {
			waterMaterials[x][0][z] = Enum.Material.Sand;
		}
	}

	return {
		chunkSize: chunkSize * 4,

		renderChunk(chunkX: number, chunkZ: number): true {
			if (math.random() > 0.9) task.wait();
			const startX = chunkX * chunkSize;
			const startZ = chunkZ * chunkSize;

			const region = getRegion(startX, startZ);
			terrain.WriteVoxels(region, 4, waterMaterials, waterOccupancys);

			return true;
		},
		destroyChunk(chunkX: number, chunkZ: number): void {
			if (math.random() > 0.9) task.wait();
			const startX = chunkX * chunkSize;
			const startZ = chunkZ * chunkSize;

			const region = getRegion(startX, startZ);
			terrain.WriteVoxels(region, 4, emptyMaterials, emptyOccupancys);
		},
		unloadAll(chunks) {
			2;
			Workspace.Terrain.Clear();
			Workspace.Terrain.ClearAllChildren();
		},
		destroy() {},
	};
};


import { Players, RunService, UserInputService } from "@rbxts/services";
import { Control } from "engine/client/gui/Control";
import { Interface } from "engine/client/gui/Interface";
import { InputController } from "engine/client/InputController";
import { Component } from "engine/shared/component/Component";
import { Element } from "engine/shared/Element";
import { PlayerRank } from "engine/shared/PlayerRank";
import { Colors } from "shared/Colors";
import type { DebuggableComponent } from "engine/shared/component/Component";

type TreeControlDefinition = GuiObject & {
	readonly Main: GuiButton;
	readonly Children: Frame;
};
class TreeControl extends Control<TreeControlDefinition> {
	static createChildList(main: GuiButton): TreeControl {
		const createElement = Element.create;
		const gui = createElement(
			"Frame",
			{
				Size: new UDim2(1, 0, 0, 0),
				AutomaticSize: Enum.AutomaticSize.Y,
				BackgroundColor3: Colors.black,
				BackgroundTransparency: 0.9,
			},
			{
				list: createElement("UIListLayout", { SortOrder: Enum.SortOrder.LayoutOrder }),
				Main: main,
				Children: createElement(
					"Frame",
					{
						Size: new UDim2(1, 0, 0, 0),
						AutomaticSize: Enum.AutomaticSize.Y,
						BackgroundColor3: Colors.black,
						BackgroundTransparency: 1,
						LayoutOrder: 99,
					},
					{
						list: createElement("UIListLayout", { SortOrder: Enum.SortOrder.LayoutOrder }),
						padding: createElement("UIPadding", { PaddingLeft: new UDim(0, 20) }),
					},
				),
			},
		);

		return new TreeControl(gui);
	}

	private readonly main: Control<GuiButton>;
	readonly childContainer: Control;

	constructor(gui: TreeControlDefinition) {
		super(gui);
		this.main = this.add(new Control(gui.Main));
		this.childContainer = this.add(new Control(this.gui.Children));

		this.main.instance.BackgroundColor3 = Colors.accent;

		this.main.parent(
			new Control(this.main.instance).addButtonAction(() => {
				if (this.childContainer.isInstanceVisible()) {
					this.main.instance.BackgroundColor3 = Colors.accentDark;
					this.childContainer.hide();
				} else {
					this.main.instance.BackgroundColor3 = Colors.accent;
					this.childContainer.show();
				}
			}),
		);
	}
}

const create = (): TreeControl => {
	const createElement = Element.create;
	const root = createElement(
		"ScrollingFrame",
		{
			Name: "DebugROOT",
			AutoLocalize: false,
			Size: new UDim2(1, 0, 1, 0),
			BackgroundColor3: Colors.black,
			BackgroundTransparency: 1,
			ScrollingDirection: Enum.ScrollingDirection.Y,
			AutomaticCanvasSize: Enum.AutomaticSize.Y,
			Parent: createElement("ScreenGui", {
				Name: "DebugSCREEN",
				Parent: Interface.getPlayerGui(),
			}),
		},
		{
			list: createElement("UIListLayout", { SortOrder: Enum.SortOrder.LayoutOrder }),
			Main: createElement("TextButton", {
				Text: "TREE",
				AutomaticSize: Enum.AutomaticSize.XY,
				LayoutOrder: 0,
			}),
			Children: createElement(
				"Frame",
				{
					Size: new UDim2(1, 0, 0, 0),
					AutomaticSize: Enum.AutomaticSize.Y,
					BackgroundColor3: Colors.black,
					BackgroundTransparency: 1,
					LayoutOrder: 1,
				},
				{
					list: createElement("UIListLayout", { SortOrder: Enum.SortOrder.LayoutOrder }),
					padding: createElement("UIPadding", { PaddingLeft: new UDim(0, 20) }),
				},
			),
		},
	);

	return new TreeControl(root);
};

let tree: TreeControl | undefined;
const update = (root: DebuggableComponent) => {
	if (!tree) throw "what";

	const add = (component: object | DebuggableComponent, tree: TreeControl) => {
		const childtree = tree.childContainer.add(
			TreeControl.createChildList(
				Element.create("TextButton", {
					AutomaticSize: Enum.AutomaticSize.XY,
					Text: tostring(getmetatable(component)),
					TextColor3: Colors.white,
					Font: Enum.Font.Ubuntu,
					TextSize: 16,
				}),
			),
		);

		if ("getDebugChildren" in component) {
			for (const child of component.getDebugChildren()) {
				add(child, childtree);
			}
		}
	};

	add(root, tree);
};
const toggle = (root: DebuggableComponent) => {
	if (!tree) {
		tree = create();
		update(root);
		tree.show();
		return;
	} else {
		tree.destroy();
		tree = undefined;
	}
};

const launch = RunService.IsStudio() || PlayerRank.isAdmin(Players.LocalPlayer);
if (!launch) new Instance("BindableEvent").Event.Wait();
task.wait(0.5); // wait for the controls to enable

UserInputService.InputBegan.Connect((input) => {
	if (input.UserInputType !== Enum.UserInputType.Keyboard) return;
	if (input.KeyCode !== Enum.KeyCode.F6) return;
	if (!InputController.isShiftPressed()) return;

	toggle(new Component()); // TODO:
});


import { Players } from "@rbxts/services";
import { TooltipsHolder } from "client/gui/static/TooltipsControl";
import { Component } from "engine/shared/component/Component";
import { Objects } from "engine/shared/fixes/Objects";
import type { Tooltip } from "client/gui/static/TooltipsControl";
import type { BuildingMode } from "client/modes/build/BuildingMode";
import type { ComponentEvents } from "engine/shared/component/ComponentEvents";
import type { SharedPlot } from "shared/building/SharedPlot";

/** An abstract class of tools for working with the world */
export abstract class ToolBase extends Component {
	readonly mirrorMode;
	readonly targetPlot;

	protected readonly mouse: Mouse;
	readonly mode: BuildingMode;
	protected readonly tooltipHolder: TooltipsHolder;

	constructor(mode: BuildingMode) {
		super();
		this.mode = mode;
		this.mirrorMode = mode.mirrorMode;
		this.targetPlot = mode.targetPlot.asReadonly();

		this.tooltipHolder = this.parent(TooltipsHolder.createComponent(this.getDisplayName()));
		this.tooltipHolder.set(this.getTooltips());

		this.mouse = Players.LocalPlayer.GetMouse();
	}

	subscribeSomethingToCurrentPlot(
		state: Component & { readonly event: ComponentEvents },
		func: (plot: SharedPlot) => void,
	) {
		state.event.subscribeObservable(
			this.targetPlot,
			(plot) => {
				state.event.eventHandler.subscribe(plot.changed, () => func(plot));
				func(plot);
			},
			true,
		);
	}
	protected subscribeToCurrentPlot(func: (plot: SharedPlot) => void) {
		this.subscribeSomethingToCurrentPlot(this, func);
	}

	supportsMirror() {
		return false;
	}

	/** The name of the tool, for example: `Example Mode` */
	abstract getDisplayName(): string;

	/** Image of the tool*/
	abstract getImageID(): string;

	protected getTooltips(): readonly Tooltip[] {
		return Objects.empty;
	}
}


import { ReplicatedStorage, RunService, UserInputService, Workspace } from "@rbxts/services";
import { TooltipsHolder } from "client/gui/static/TooltipsControl";
import { FloatingText } from "client/tools/additional/FloatingText";
import { MoveGrid, ScaleGrid } from "client/tools/additional/Grid";
import { RotateGrid } from "client/tools/additional/Grid";
import { Action } from "engine/client/Action";
import { Interface } from "engine/client/gui/Interface";
import { InputController } from "engine/client/InputController";
import { Keybinds } from "engine/client/Keybinds";
import { LocalPlayer } from "engine/client/LocalPlayer";
import { Component } from "engine/shared/component/Component";
import { ComponentChild } from "engine/shared/component/ComponentChild";
import { ComponentInstance } from "engine/shared/component/ComponentInstance";
import { OverlayValueStorage } from "engine/shared/component/OverlayValueStorage";
import { Transforms } from "engine/shared/component/Transforms";
import { TransformService } from "engine/shared/component/TransformService";
import { Element } from "engine/shared/Element";
import { ObservableCollectionSet } from "engine/shared/event/ObservableCollection";
import { ObservableValue } from "engine/shared/event/ObservableValue";
import { ArgsSignal } from "engine/shared/event/Signal";
import { BB } from "engine/shared/fixes/BB";
import { MathUtils } from "engine/shared/fixes/MathUtils";
import { Strings } from "engine/shared/fixes/String.propmacro";
import { BlockManager } from "shared/building/BlockManager";
import { SharedBuilding } from "shared/building/SharedBuilding";
import { Colors } from "shared/Colors";
import type { MainScreenBottomLayer, MainScreenLayout } from "client/gui/MainScreenLayout";
import type { ClientBuildingTypes } from "client/modes/build/ClientBuilding";
import type { PlayerDataStorage } from "client/PlayerDataStorage";
import type { Theme } from "client/Theme";
import type { Control } from "engine/client/gui/Control";
import type { KeybindDefinition } from "engine/client/Keybinds";
import type { ReadonlyObservableValue } from "engine/shared/event/ObservableValue";
import type { SharedPlot } from "shared/building/SharedPlot";

interface EditingBlock {
	readonly block: BlockModel;
	readonly origModel: BlockModel;
	readonly origLocation: CFrame;
	readonly origScale: Vector3;
}

class HandleMovementController extends Component {
	constructor(
		handle: Handles,
		sideways: ReadonlyObservableValue<boolean>,
		update: (delta: Vector3, face: Enum.NormalId) => void,
		release: () => void,
	) {
		super();

		const findRayPlaneIntersection = (
			rayOrigin: Vector3,
			rayDirection: Vector3,
			planeOrigin: Vector3,
			planeNormal: Vector3,
		): Vector3 | undefined => {
			const denominator = rayDirection.Dot(planeNormal);
			if (math.abs(denominator) < 1e-6) {
				return undefined;
			}

			const rayToPlane = planeOrigin.sub(rayOrigin);
			const t = rayToPlane.Dot(planeNormal) / denominator;
			if (t < 0) {
				return undefined;
			}

			return rayOrigin.add(rayDirection.mul(t));
		};
		const calculateCursorDeltaVecOnPlane = (arrowPosition: Vector3, arrowDirection: Vector3): (() => Vector3) => {
			const camera = Workspace.CurrentCamera;
			if (!camera) return () => Vector3.zero;

			const mouseLocation = UserInputService.GetMouseLocation();
			const mouseRay = camera.ScreenPointToRay(mouseLocation.X, mouseLocation.Y);
			const startingMouseRay = mouseRay;

			const startingPosition = findRayPlaneIntersection(
				mouseRay.Origin,
				mouseRay.Direction,
				arrowPosition,
				mouseRay.Direction,
			);
			if (!startingPosition) return () => Vector3.zero;

			return () => {
				const camera = Workspace.CurrentCamera;
				if (!camera) return Vector3.zero;

				const mouseLocation = UserInputService.GetMouseLocation();
				const mouseRay = camera.ScreenPointToRay(mouseLocation.X, mouseLocation.Y);

				if (sideways.get()) {
					const point = findRayPlaneIntersection(
						mouseRay.Origin,
						mouseRay.Direction,
						startingPosition,
						arrowDirection,
					);
					if (!point) return Vector3.zero;

					return point.sub(startingPosition);
				}

				const point = findRayPlaneIntersection(
					mouseRay.Origin,
					mouseRay.Direction,
					startingPosition,
					startingMouseRay.Direction,
				);
				if (!point) return Vector3.zero;

				const diff = point.sub(startingPosition);
				const rotatedDiff = CFrame.lookAt(Vector3.zero, arrowDirection).PointToObjectSpace(diff);

				return arrowDirection.mul(-rotatedDiff.Z);
			};
		};

		let f: Enum.NormalId | undefined;
		let cu: (() => Vector3) | undefined;
		const upd = () => {
			if (!cu || !f) return;
			update(cu(), f);
		};
		this.event.subscribe(RunService.Heartbeat, upd);
		this.event.subscribeObservable(sideways, upd);

		this.event.subscribe(handle.MouseButton1Down, (face) => {
			if (!handle.Adornee) return;

			f = face;
			cu = calculateCursorDeltaVecOnPlane(
				handle.Adornee.Position,
				handle.Adornee.CFrame.VectorToWorldSpace(Vector3.FromNormalId(face)),
			);
		});
		this.event.subscribe(handle.MouseButton1Up, () => {
			cu = undefined;
			f = undefined;
			release();
		});
	}
}

type EditMode = "move" | "rotate" | "scale";

const repositionOne = (block: BlockModel, origModel: BlockModel, location: CFrame, scale: Vector3) => {
	block.PivotTo(location);
	SharedBuilding.scale(block, origModel, scale);
};
const almostSame = (left: number, right: number) => math.abs(left - right) < 0.0001;
const reposition = (blocks: readonly EditingBlock[], originalBB: BB, currentBB: BB) => {
	const scalediff = currentBB.originalSize.div(originalBB.originalSize);

	for (const { block, origModel, origLocation, origScale } of blocks) {
		const localToOriginalLocation = originalBB.center.ToObjectSpace(origLocation);

		const newloc = currentBB.center.ToWorldSpace(
			localToOriginalLocation.Rotation.add(localToOriginalLocation.Position.mul(scalediff)),
		);

		let newscale: Vector3;
		if (almostSame(scalediff.X, scalediff.Y) && almostSame(scalediff.Y, scalediff.Z)) {
			newscale = origScale.mul(scalediff.X);
		} else {
			newscale = origScale.mul(
				originalBB.center.ToObjectSpace(origLocation).Rotation.PointToObjectSpace(scalediff).Abs(),
			);
		}

		repositionOne(block, origModel, newloc, newscale);
	}
};

interface EditComponent extends Component {
	readonly error?: ReadonlyObservableValue<string | undefined>;
}

const centerBasedKb = Keybinds.registerDefinition(
	"edit_scale_centerBased",
	["Edit Tool", "Scale", "Scale from the center"],
	[["LeftAlt"]],
);
const sameSizeKb = Keybinds.registerDefinition(
	"edit_scale_sameSize",
	["Edit Tool", "Scale", "Uniform scaling"],
	[["LeftShift"]],
);
const sidewaysKb = Keybinds.registerDefinition(
	"edit_move_sideways",
	["Edit Tool", "Move", "Sideways movement"],
	[["LeftAlt"]],
);

const formatVecForFloatingText = (vec: Vector3, positive: boolean = true): string => {
	const format = (num: number): string => {
		const str = Strings.prettyNumber(num, 0.01);
		if (num > 0 && positive) return `+${str}`;

		return `${str}`;
	};

	return `${format(vec.X)}, ${format(vec.Y)}, ${format(vec.Z)}`;
};

interface b {
	readonly iconId?: string;
	readonly state: OverlayValueStorage<boolean>;
}
const createButtonSwitchList = <K extends string>(
	parent: Component,
	mainScreen: MainScreenLayout,
	theme: Theme,
	buttons: { readonly [k in K]: b },
): LuaTuple<[{ readonly [k in K]: Control }, MainScreenBottomLayer]> => {
	const layer = parent.parentGui(mainScreen.bottom.push());

	const ret: { [k in K]?: Control } = {};
	for (const [k, { iconId, state }] of pairs(buttons)) {
		const kb = new ObservableValue(state.get());

		const btn = layer
			.addButton(k.upper(), iconId, "buttonInactive")
			.addButtonAction(() => state.and("kb", kb.toggle()))
			.initializeSimpleTransform("BackgroundColor3")
			.themeButton(
				theme,
				parent.event.addObservable(
					state.fReadonlyCreateBased((enabled) => (enabled ? "buttonActive" : "buttonInactive")),
				),
			);

		ret[k] = btn;
	}

	return $tuple(ret as { [k in K]: Control }, layer);
};

// #region Move
// #endregion
@injectable
class MoveComponent extends Component implements EditComponent {
	constructor(
		handles: typeof ReplicatedStorage.Assets.Helpers.EditHandles,
		blocks: readonly EditingBlock[],
		originalBB: BB,
		grid: ReadonlyObservableValue<MoveGrid>,
		@inject plot: SharedPlot,
		@inject keybinds: Keybinds,
		@inject theme: Theme,
		@inject mainScreen: MainScreenLayout,
	) {
		super();

		const forEachHandle = (func: (handle: Handles) => void) => {
			func(handles.Move.XHandles);
			func(handles.Move.YHandles);
			func(handles.Move.ZHandles);
		};

		this.onEnabledStateChange((enabled) => forEachHandle((handle) => (handle.Visible = enabled)));

		const sideways = OverlayValueStorage.bool();
		let bb = BB.fromPart(handles);

		const floatingText = this.parent(FloatingText.create(handles));
		const startbb = bb;
		const updateFloatingText = () => {
			floatingText.text.set(formatVecForFloatingText(handles.Position.sub(startbb.center.Position)));
			floatingText.subtext?.set(
				formatVecForFloatingText(handles.Position.sub(plot.instance.BuildingArea.GetPivot().Position), false),
			);
		};
		updateFloatingText();

		const update = (delta: Vector3) => {
			delta = grid.get().constrain(handles.GetPivot(), delta);

			handles.PivotTo(bb.center.add(delta));
			reposition(blocks, originalBB, BB.fromPart(handles));

			updateFloatingText();
		};

		let currentMovement: Vector3 | undefined;
		const updateFromCurrentMovement = (): void => {
			if (!currentMovement) return;
			update(currentMovement);
		};

		this.event.subscribeObservable(grid, updateFromCurrentMovement);

		// #region Keyboard controls initialization
		const tooltips = this.parent(TooltipsHolder.createComponent("Edit Tool > Move"));
		tooltips.setFromKeybinds(keybinds.fromDefinition(sidewaysKb));

		this.event.subscribeObservable(keybinds.fromDefinition(sidewaysKb).isPressed, (value) => {
			sideways.and("kb", value);
			updateFromCurrentMovement();
		});
		const [, buttons] = createButtonSwitchList(this, mainScreen, theme, { sideways: { state: sideways } });
		// #endregion
		buttons
			.addButton("Snap to grid") //
			.addButtonAction(() => {
				bb = BB.fromPart(handles);
				let targetPosition = bb.center.Position;

				// костыль of fixing the plots position
				targetPosition = targetPosition.add(new Vector3(0, 0.5, 0.5));

				const sizeOffset = bb.getRotatedSize().apply((v) => (v < 0.5 ? 0 : (v % 2) / 2));
				targetPosition = targetPosition.sub(sizeOffset);
				targetPosition = grid.get().constrain(CFrame.identity, targetPosition);
				targetPosition = targetPosition.add(sizeOffset);

				// targetPosition = constrainPositionToGrid(targetPosition, 1);
				targetPosition = targetPosition.sub(new Vector3(0, 0.5, 0.5));

				handles.PivotTo(bb.center.Rotation.add(targetPosition));
				reposition(blocks, originalBB, (bb = BB.fromPart(handles)));
				updateFloatingText();
			});

		const createVisualizer = () => {
			const instance = ReplicatedStorage.Assets.Helpers.MovementVisualizer.Clone();

			instance.Decal.Transparency = 1;

			const size = 500;
			instance.Size = new Vector3(0, size, size);

			ComponentInstance.init(this, instance);
			instance.Parent = Workspace;

			let visible = false;

			const props = {
				...TransformService.commonProps.quadOut02,
				duration: 0.5,
			};

			const update = (direction: Vector3, position: Vector3) => {
				if (!sideways.get()) {
					stop();
					return;
				}

				if (!visible) {
					visible = true;

					TransformService.cancel(instance);
					TransformService.run(instance.Decal, (tr) =>
						tr.transform(instance.Decal, "Transparency", 0.95, props),
					);
				}

				instance.CFrame = CFrame.lookAt(Vector3.zero, direction)
					.mul(CFrame.Angles(0, math.rad(90), 0))
					.add(position);
			};

			const stop = () => {
				if (!visible) return;
				visible = false;

				TransformService.cancel(instance);
				TransformService.run(instance.Decal, (tr) => tr.transform(instance.Decal, "Transparency", 1, props));
			};

			return { update, stop };
		};
		const visualizer = createVisualizer();

		forEachHandle((handle) => {
			this.parent(
				new HandleMovementController(
					handle,
					sideways,
					(delta, face) => {
						currentMovement = delta;
						updateFromCurrentMovement();
						visualizer.update(
							bb.center.Rotation.mul(Vector3.FromNormalId(face)),
							bb.center.Position.add(delta),
						);
					},
					() => {
						currentMovement = undefined;

						bb = BB.fromPart(handles);
						reposition(blocks, originalBB, bb);
						visualizer.stop();
					},
				),
			);
		});
	}
}

// #region Rotate
// #endregion
@injectable
class RotateComponent extends Component implements EditComponent {
	constructor(
		handles: typeof ReplicatedStorage.Assets.Helpers.EditHandles,
		blocks: readonly EditingBlock[],
		originalBB: BB,
		grid: ReadonlyObservableValue<RotateGrid>,
		@inject theme: Theme,
		@inject mainScreen: MainScreenLayout,
	) {
		super();

		const forEachHandle = (func: (handle: ArcHandles) => void) => {
			func(handles.Rotate.ArcHandles);
		};

		this.onEnabledStateChange((enabled) => forEachHandle((handle) => (handle.Visible = enabled)));
		this.onEnable(() => (handles.Rotate.Center.Size = handles.Size));

		let bb = BB.fromPart(handles);

		const floatingText = this.parent(FloatingText.create(handles));
		const startbb = bb;
		const updateFloatingText = () => {
			const format = (cframe: CFrame, positive: boolean) => {
				const [x, y, z] = cframe.ToOrientation();
				const vec = new Vector3(x, y, z).apply((c) => MathUtils.round(math.deg(c), 0.01));
				return formatVecForFloatingText(vec, positive);
			};

			floatingText.text.set(format(handles.CFrame.Rotation.ToObjectSpace(startbb.center.Rotation), true));
			floatingText.subtext?.set(format(handles.CFrame.Rotation, false));
		};
		updateFloatingText();

		const update = (axis: Enum.Axis, relativeAngle: number) => {
			const roundedRelativeAngle = grid.get().constrain(relativeAngle);
			handles.PivotTo(bb.center.mul(CFrame.fromAxisAngle(Vector3.FromAxis(axis), roundedRelativeAngle)));
			handles.Rotate.Center.PivotTo(bb.center.mul(CFrame.fromAxisAngle(Vector3.FromAxis(axis), relativeAngle)));

			reposition(blocks, originalBB, BB.fromPart(handles));
			updateFloatingText();
		};

		let currentRotation: { readonly axis: Enum.Axis; relativeAngle: number } | undefined;
		const updateFromCurrentRotation = (): void => {
			if (!currentRotation) return;
			update(currentRotation.axis, currentRotation.relativeAngle);
		};

		this.event.subscribeObservable(grid, updateFromCurrentRotation);

		const [, buttons] = createButtonSwitchList(this, mainScreen, theme, {});
		buttons
			.addButton("Snap to grid") //
			.addButtonAction(() => {
				bb = BB.fromPart(handles);

				const [x, y, z] = handles.CFrame.Rotation.ToObjectSpace(startbb.center.Rotation).ToOrientation();
				handles.PivotTo(
					new CFrame(bb.center.Position).mul(
						CFrame.fromOrientation(
							grid.get().constrain(x),
							grid.get().constrain(y),
							grid.get().constrain(z),
						),
					),
				);
				handles.Rotate.Center.PivotTo(handles.GetPivot());

				reposition(blocks, originalBB, (bb = BB.fromPart(handles)));
				updateFloatingText();
			});

		const sub = (handle: ArcHandles) => {
			this.event.subscribe(handle.MouseDrag, (axis, relativeAngle, deltaRadius) => {
				currentRotation ??= { axis, relativeAngle };
				currentRotation.relativeAngle = relativeAngle;

				updateFromCurrentRotation();
			});

			this.event.subscribe(handle.MouseButton1Up, () => {
				currentRotation = undefined;
				handles.Rotate.Center.PivotTo(handles.GetPivot());

				bb = BB.fromPart(handles);
				reposition(blocks, originalBB, bb);
			});
		};
		forEachHandle(sub);
	}
}

// #region Scale
// #endregion
@injectable
class ScaleComponent extends Component implements EditComponent {
	readonly error = new ObservableValue<string | undefined>(undefined);

	constructor(
		handles: typeof ReplicatedStorage.Assets.Helpers.EditHandles,
		blocks: readonly EditingBlock[],
		originalBB: BB,
		grid: ReadonlyObservableValue<ScaleGrid>,
		@inject keybinds: Keybinds,
		@inject mainScreen: MainScreenLayout,
		@inject theme: Theme,
	) {
		super();

		const forEachHandle = (func: (handle: Handles) => void) => {
			func(handles.Scale.XHandles);
			func(handles.Scale.YHandles);
			func(handles.Scale.ZHandles);
		};

		this.onEnabledStateChange((enabled) => forEachHandle((handle) => (handle.Visible = enabled)));

		let bb = BB.fromPart(handles);

		const centerBased = OverlayValueStorage.bool();
		const sameSize = OverlayValueStorage.bool();

		const pivot = Element.create(
			"Part",
			{
				Anchored: true,
				Size: Vector3.one.mul(0.2),
				Color: Colors.white,
				Shape: Enum.PartType.Ball,
				Parent: Workspace,
			},
			{
				highlight: Element.create("Highlight", {
					FillColor: Colors.red,
					DepthMode: Enum.HighlightDepthMode.AlwaysOnTop,
				}),
			},
		);
		ComponentInstance.init(this, pivot);

		const floatingText = this.parent(FloatingText.create(handles));
		const startbb = bb;

		// couldent find a function that gives the bounds of everything
		const getBoundsSize = () => {
			let min = new Vector3(math.huge, math.huge, math.huge);
			let max = new Vector3(-math.huge, -math.huge, -math.huge);

			for (const { block } of blocks) {
				const [cf, size] = block.GetBoundingBox();
				const h = size.mul(0.5);

				const offsets = [
					new Vector3(h.X, h.Y, h.Z),
					new Vector3(h.X, h.Y, -h.Z),
					new Vector3(h.X, -h.Y, h.Z),
					new Vector3(h.X, -h.Y, -h.Z),
					new Vector3(-h.X, h.Y, h.Z),
					new Vector3(-h.X, h.Y, -h.Z),
					new Vector3(-h.X, -h.Y, h.Z),
					new Vector3(-h.X, -h.Y, -h.Z),
				];

				for (const off of offsets) {
					const c = cf.Position.add(off);
					min = new Vector3(math.min(min.X, c.X), math.min(min.Y, c.Y), math.min(min.Z, c.Z));
					max = new Vector3(math.max(max.X, c.X), math.max(max.Y, c.Y), math.max(max.Z, c.Z));
				}
			}

			return max.sub(min);
		};

		const updateFloatingText = () => {
			floatingText.text.set(formatVecForFloatingText(handles.Size.sub(startbb.originalSize)));
			const scale = getBoundsSize();
			floatingText.subtext?.set("(" + formatVecForFloatingText(scale, false) + ")");
		};
		updateFloatingText();

		const calculatePivotPosition = (face: Enum.NormalId): Vector3 => {
			const globalNormal = Vector3.FromNormalId(face);

			return centerBased.get() //
				? Vector3.zero
				: globalNormal.mul(bb.originalSize.div(-2));
		};
		const update = (face: Enum.NormalId, distance: number): void => {
			const negative =
				face === Enum.NormalId.Front || face === Enum.NormalId.Bottom || face === Enum.NormalId.Left;

			const globalNormal = Vector3.FromNormalId(face);
			const localPivot = calculatePivotPosition(face);
			pivot.Position = bb.center.PointToWorldSpace(localPivot);

			const distanceMul = (1 - localPivot.div(bb.originalSize).Abs().findMax()) * 2;

			const makeUnitWithAxis1 = (vector: Vector3, direction: Vector3) => {
				let axisValue = 0;
				if (direction.X !== 0) axisValue = vector.X;
				else if (direction.Y !== 0) axisValue = vector.Y;
				else if (direction.Z !== 0) axisValue = vector.Z;

				return vector.div(axisValue);
			};

			const gn = sameSize.get() //
				? makeUnitWithAxis1(bb.originalSize, globalNormal)
				: globalNormal.mul(negative ? -1 : 1);
			let g = gn.mul(distance * distanceMul);
			g = grid.get().constrain(globalNormal, handles.GetPivot(), g);

			handles.Size = bb.originalSize.add(g);
			updateFloatingText();

			handles.PivotTo(
				bb.center.ToWorldSpace(
					new CFrame(localPivot.add(localPivot.apply(math.sign).mul(handles.Size.div(-2)))),
				),
			);
			reposition(blocks, originalBB, BB.fromPart(handles));

			const overscaled = blocks.any(
				(b) => b.block.PrimaryPart!.Size.div(b.origModel.PrimaryPart!.Size).findMax() > 256,
			);
			if (overscaled) {
				this.error.set("Some blocks are scaled too big (maximum is 256x)");
			} else {
				const underscaled = blocks.any(
					(b) => b.block.PrimaryPart!.Size.div(b.origModel.PrimaryPart!.Size).findMin() < 1 / 32,
				);
				if (underscaled) {
					this.error.set("Some blocks are scaled too small (minimum is 1/32x)");
				} else {
					this.error.set(undefined);
				}
			}
		};

		let currentMovement: { readonly face: Enum.NormalId; distance: number } | undefined;
		const updateFromCurrentMovement = (): void => {
			if (!currentMovement) return;
			update(currentMovement.face, currentMovement.distance);
		};

		this.event.subscribeObservable(grid, updateFromCurrentMovement);

		// #region Keyboard controls initialization
		const tooltips = this.parent(TooltipsHolder.createComponent("Edit Tool > Scale"));
		tooltips.setFromKeybinds(keybinds.fromDefinition(centerBasedKb), keybinds.fromDefinition(sameSizeKb));

		this.event.subscribeObservable(keybinds.fromDefinition(centerBasedKb).isPressed, (value) => {
			centerBased.and("kb", value);
			updateFromCurrentMovement();
		});
		this.event.subscribeObservable(keybinds.fromDefinition(sameSizeKb).isPressed, (value) => {
			sameSize.and("kb", value);
			updateFromCurrentMovement();
		});
		// #endregion

		forEachHandle((handle) => {
			this.event.subscribe(handle.MouseDrag, (face, distance) => {
				currentMovement ??= { face, distance };
				currentMovement.distance = distance;

				updateFromCurrentMovement();
			});

			this.event.subscribe(handle.MouseButton1Down, (face) => {
				pivot.Position = bb.center.PointToWorldSpace(calculatePivotPosition(face));
			});
			this.event.subscribe(handle.MouseButton1Up, () => {
				currentMovement = undefined;
				pivot.CFrame = CFrame.identity;

				bb = BB.fromPart(handles);
				reposition(blocks, originalBB, bb);
			});
		});

		const [{ centered, uniform }, buttons] = createButtonSwitchList(this, mainScreen, theme, {
			centered: { state: centerBased },
			uniform: { state: sameSize },
		});
		const sameSizeErr = (uniform as Control<ControlWithErrorDefinition>).getComponent(ControlWithError);

		buttons
			.addButton("Snap to grid") //
			.addButtonAction(() => {
				bb = BB.fromPart(handles);
				let target = bb.originalSize;

				target = grid
					.get()
					.constrain(Vector3.xAxis, CFrame.identity, new Vector3(target.X, 0, 0))
					.add(grid.get().constrain(Vector3.yAxis, CFrame.identity, new Vector3(0, target.Y, 0)))
					.add(grid.get().constrain(Vector3.zAxis, CFrame.identity, new Vector3(0, 0, target.Z)));

				handles.Size = target;
				reposition(blocks, originalBB, (bb = BB.fromPart(handles)));
				updateFloatingText();
			});

		// #region Block rotation check
		const areRotations90DegreesApart = (cframeA: CFrame, cframeB: CFrame): boolean => {
			const rotationDifference = cframeA.ToObjectSpace(cframeB);
			const [_1, _2, _3, m00, m01, m02, m10, m11, m12, m20, m21, m22] = rotationDifference.GetComponents();

			const is90DegreeMultiple = (value: number): boolean => {
				const degrees = math.deg(math.acos(math.clamp(value, 0, 1)));
				return math.abs(degrees % 90) < 0.1 || math.abs((degrees % 90) - 90) < 0.1;
			};
			return is90DegreeMultiple(m00) && is90DegreeMultiple(m11) && is90DegreeMultiple(m22);
		};

		if (blocks.any((b) => !areRotations90DegreesApart(bb.center, b.block.GetPivot()))) {
			sameSize.and("multipleBlocks", true);
			sameSizeErr.setError(
				"Uniform scaling is forced because the selection has blocks with non-aligned rotations.",
			);
		}
		// #endregion
	}
}

//

type ControlWithErrorDefinition = GuiObject & {
	readonly WarningImage: ImageLabel;
};
class ControlWithError extends Component {
	constructor(private readonly component: Control<ControlWithErrorDefinition>) {
		super();

		component.instance.WarningImage.Visible = false;
		this.onDisable(() => component.setTooltipText(undefined));
	}

	setError(err: string | undefined) {
		this.component.instance.WarningImage.Visible = err !== undefined;
		this.component.setTooltipText(err);
	}
}

class CompoundObservableSet<T extends defined> {
	readonly set = new ObservableCollectionSet<T>();

	addSource(observable: ReadonlyObservableValue<T | undefined>) {
		observable.subscribePrev((value, prev) => {
			if (value !== undefined) {
				this.set.add(value);
			} else if (prev !== undefined) {
				this.set.remove(prev);
			}
		});
	}
}

@injectable
export class BlockEditor extends Component {
	static readonly keybinds = {
		move: Keybinds.registerDefinition("edit_move", ["Edit tool", "Move"], [["F"], ["ButtonX"]]),
		rotate: Keybinds.registerDefinition("edit_rotate", ["Edit tool", "Rotate"], [["R"]]),
		scale: Keybinds.registerDefinition("edit_scale", ["Edit tool", "Scale"], [["B"]]),
	} as const satisfies { readonly [k in string]: KeybindDefinition };

	private readonly _completed = new ArgsSignal<[state: "completed" | "cancelled"]>();
	readonly completed = this._completed.asReadonly();

	private readonly editBlocks: readonly EditingBlock[];
	private readonly currentMode: ObservableValue<EditMode>;

	private readonly moveGrid = new ObservableValue(MoveGrid.def);
	private readonly rotateGrid = new ObservableValue(RotateGrid.def);
	private readonly scaleGrid = new ObservableValue(ScaleGrid.def);
	private _errors = new CompoundObservableSet<string>();
	readonly errors = this._errors.set.asReadonly();

	constructor(
		blocks: readonly BlockModel[],
		startMode: EditMode,
		bounds: BB,
		editMode: "global" | "local",
		@inject keybinds: Keybinds,
		@inject blockList: BlockList,
		@inject playerData: PlayerDataStorage,
		@inject mainScreen: MainScreenLayout,
		@inject theme: Theme,
		@inject di: DIContainer,
	) {
		super();
		this.currentMode = new ObservableValue<EditMode>(startMode);

		const actions = {
			move: this.parent(new Action(() => setModeByKey("move"))),
			rotate: this.parent(new Action(() => setModeByKey("rotate"))),
			scale: this.parent(new Action(() => setModeByKey("scale"))),

			cancel: this.parent(
				new Action(() => {
					this.cancel();
					this._completed.Fire("cancelled");
				}),
			),
			finish: this.parent(new Action(() => this._completed.Fire("completed"))),
		} as const;

		actions.move.initKeybind(keybinds.fromDefinition(BlockEditor.keybinds.move), { priority: -1 });
		actions.rotate.initKeybind(keybinds.fromDefinition(BlockEditor.keybinds.rotate), { priority: -1 });
		actions.scale.initKeybind(keybinds.fromDefinition(BlockEditor.keybinds.scale), { priority: -1 });

		actions.move.subCanExecuteFrom({ main: new ObservableValue(true) });
		actions.rotate.subCanExecuteFrom({ main: new ObservableValue(true) });
		actions.scale.subCanExecuteFrom({ main: new ObservableValue(true) });

		actions.cancel.subCanExecuteFrom({ main: new ObservableValue(true) });
		actions.finish.subCanExecuteFrom({ main: new ObservableValue(true) });

		{
			const layer = this.parentGui(mainScreen.bottom.push());

			layer.addButton("cancel", "15429854809", "buttonNegative").subscribeToAction(actions.cancel);
			layer.addButton("finish", "15429854809", "buttonPositive").subscribeToAction(actions.finish);
		}

		{
			const layer = this.parentGui(mainScreen.bottom.push());
			layer.visibilityComponent().addTransform(false, () =>
				Transforms.create() //
					.fullFadeOut(layer.instance, Transforms.quadOut02)
					.then()
					.hide(layer.instance),
			);
			this.onDestroy(() => layer.visibilityComponent().waitForTransformThenDestroy());

			const move = layer
				.addButton("move", "18369400240")
				.initializeSimpleTransform("BackgroundColor3")
				.subscribeToAction(actions.move);
			const rotate = layer
				.addButton("rotate", "18369417777")
				.initializeSimpleTransform("BackgroundColor3")
				.subscribeToAction(actions.rotate);
			const scale = layer
				.addButton("scale", "89349384867990")
				.initializeSimpleTransform("BackgroundColor3")
				.subscribeToAction(actions.scale);

			const btns = { move, rotate, scale };
			this.event.subscribeObservable(
				this.currentMode,
				(mode) => {
					for (const [name, button] of pairs(btns)) {
						button.overlayValue(
							"BackgroundColor3",
							theme.get(mode === name ? "buttonActive" : "buttonNormal"),
						);
					}
				},
				true,
			);
		}

		this.editBlocks = blocks.map((b): EditingBlock => {
			const origModel = blockList.blocks[BlockManager.manager.id.get(b)]!.model;
			const scale = b.PrimaryPart!.Size.div(origModel.PrimaryPart!.Size);

			return {
				block: b,
				origModel,
				origLocation: b.GetPivot(),
				origScale: scale,
			};
		});

		const handles = ReplicatedStorage.Assets.Helpers.EditHandles.Clone();
		handles.Parent = Interface.getPlayerGui();
		ComponentInstance.init(this, handles);

		this.event.subscribeObservable(
			playerData.config,
			(config) => {
				const visuals = config.visuals.multiSelection;
				const sb = handles.SelectionBox;

				sb.Color3 = visuals.borderColor;
				sb.Transparency = visuals.borderTransparency;
				sb.LineThickness = visuals.borderThickness;

				sb.SurfaceColor3 = visuals.surfaceColor;
				sb.SurfaceTransparency = visuals.surfaceTransparency;
			},
			true,
		);

		const inBoundsError = new ObservableValue<string | undefined>(undefined);
		this._errors.addSource(inBoundsError);
		const updateHandlesInBounds = () => {
			if (bounds.isBBInside(BB.fromPart(handles))) {
				inBoundsError.set(undefined);
			} else {
				inBoundsError.set("Out of bounds");
			}
		};
		this.event.readonlyObservableFromInstanceParam(handles, "CFrame").subscribe(updateHandlesInBounds);
		this.event.readonlyObservableFromInstanceParam(handles, "Size").subscribe(updateHandlesInBounds);

		const errIndicator = Element.create("SelectionBox", {
			Color3: Colors.red,
			SurfaceColor3: Colors.red,
			Transparency: 1,
			SurfaceTransparency: 1,
			Adornee: handles,
			Parent: handles,
		});
		this.event.subscribe(this.errors.collectionChanged, () => {
			const valid = this.errors.size() === 0;

			const props = {
				...TransformService.commonProps.quadOut02,
				duration: valid ? 0.5 : 0.1,
			};

			TransformService.run(errIndicator, (tr) =>
				tr
					.transform(errIndicator, "Transparency", valid ? 1 : 0, props)
					.transform(errIndicator, "SurfaceTransparency", valid ? 1 : 0.3, props),
			);
		});

		let prevCameraState: Enum.CameraType | undefined;
		const grabCamera = () => {
			LocalPlayer.getPlayerModule().GetControls().Disable();

			const camera = Workspace.CurrentCamera;
			if (!camera) return;

			prevCameraState = camera.CameraType;
			camera.CameraType = Enum.CameraType.Scriptable;
		};
		const releaseCamera = () => {
			LocalPlayer.getPlayerModule().GetControls().Enable();
			if (!prevCameraState) return;

			const camera = Workspace.CurrentCamera;
			if (!camera) return;

			camera.CameraType = prevCameraState;
			prevCameraState = undefined;
		};
		this.onDisable(releaseCamera);

		const initializeHandles = (handles: Handles | ArcHandles) => {
			handles.Visible = false;

			this.event.subscribeObservable(
				this.event.readonlyObservableFromInstanceParam(handles, "Visible"),
				(visible) => {
					if (!visible) {
						releaseCamera();
					}
				},
			);

			// disable camera on drag
			this.event.subscribeRegistration(() => {
				if (InputController.inputType.get() !== "Touch") {
					return;
				}

				return [handles.MouseButton1Down.Connect(grabCamera), handles.MouseButton1Up.Connect(releaseCamera)];
			});
			this.event.subInput((ih) => {
				ih.onInputEnded((b) => {
					if (b.UserInputType !== Enum.UserInputType.Touch) return;
					releaseCamera();
				});
			});
		};
		initializeHandles(handles.Move.XHandles);
		initializeHandles(handles.Move.YHandles);
		initializeHandles(handles.Move.ZHandles);
		initializeHandles(handles.Scale.XHandles);
		initializeHandles(handles.Scale.YHandles);
		initializeHandles(handles.Scale.ZHandles);
		initializeHandles(handles.Rotate.ArcHandles);

		const setModeByKey = (mode: EditMode) => {
			if (this.currentMode.get() === mode) {
				this._completed.Fire("completed");
				return Enum.ContextActionResult.Sink;
			}

			this.currentMode.set(mode);
			return Enum.ContextActionResult.Sink;
		};

		const bb = BB.fromModels(blocks, editMode === "global" ? CFrame.identity : undefined);
		handles.PivotTo(bb.center);
		handles.Size = bb.originalSize;

		const modes: { readonly [k in EditMode]: () => Component } = {
			move: () => di.resolveForeignClass(MoveComponent, [handles, this.editBlocks, bb, this.moveGrid]),
			rotate: () => di.resolveForeignClass(RotateComponent, [handles, this.editBlocks, bb, this.rotateGrid]),
			scale: () => di.resolveForeignClass(ScaleComponent, [handles, this.editBlocks, bb, this.scaleGrid]),
		};

		const container = this.parent(new ComponentChild<EditComponent>());
		container.childSet.Connect((child) => {
			if (!child?.error) return;
			this._errors.addSource(child.error);
		});
		this.event.subscribeObservable(this.currentMode, (mode) => container.set(modes[mode]()), true);
	}

	initializeGrids(grids: {
		readonly moveGrid: ReadonlyObservableValue<number>;
		readonly rotateGrid: ReadonlyObservableValue<number>;
		readonly scaleGrid: ReadonlyObservableValue<number>;
	}) {
		this.event.subscribeObservable(grids.moveGrid, (grid) => this.moveGrid.set(MoveGrid.normal(grid)), true);
		this.event.subscribeObservable(
			grids.rotateGrid,
			(grid) => this.rotateGrid.set(RotateGrid.normal(math.rad(grid))),
			true,
		);
		this.event.subscribeObservable(grids.scaleGrid, (grid) => this.scaleGrid.set(ScaleGrid.normal(grid)), true);
	}

	getUpdate(): readonly ClientBuildingTypes.EditBlockInfo[] {
		return this.editBlocks.map(
			(b): ClientBuildingTypes.EditBlockInfo => ({
				instance: b.block,
				origPosition: b.origLocation,
				newPosition: b.block.GetPivot(),
				origScale: b.origScale,
				newScale: b.block.PrimaryPart!.Size.div(b.origModel.PrimaryPart!.Size),
			}),
		);
	}

	cancel() {
		for (const { block, origModel, origLocation, origScale } of this.editBlocks) {
			repositionOne(block, origModel, origLocation, origScale);
		}
	}
}


import { Workspace } from "@rbxts/services";
import { InstanceComponent } from "engine/shared/component/InstanceComponent";
import { Element } from "engine/shared/Element";
import { ObservableValue } from "engine/shared/event/ObservableValue";
import { Colors } from "shared/Colors";

type FloatingTextDefinition = BillboardGui & {
	readonly text: TextLabel;
	readonly subtext?: TextLabel;
};
export class FloatingText extends InstanceComponent<FloatingTextDefinition> {
	static create(adornee: PVInstance | Attachment): FloatingText {
		const textConfig = {
			Size: new UDim2(1, 0, 1, 0),
			AutoLocalize: false,
			BackgroundTransparency: 1,
			FontFace: Element.newFont(Enum.Font.Ubuntu, Enum.FontWeight.Bold),
			TextColor3: Colors.black,
			TextStrokeColor3: Colors.white,
			TextStrokeTransparency: 0,
		};
		const children: Partial<Record<string, Instance>> = {
			text: Element.create("TextLabel", {
				...textConfig,
				TextSize: 20,
			}),
			subtext: Element.create("TextLabel", {
				...textConfig,
				Position: new UDim2(0, 0, 0.35, 0),
				TextSize: 16,
				TextTransparency: 0.3,
			}),
		};

		const instance = Element.create(
			"BillboardGui",
			{
				Name: `FloatingText_${adornee.Name}`,
				Size: new UDim2(0, 200, 0, 50),
				AlwaysOnTop: true,
				Adornee: adornee,
				Parent: Workspace,
			},
			children as Record<string, Instance>,
		) as unknown as FloatingTextDefinition;

		return new FloatingText(instance);
	}

	readonly text = new ObservableValue<string>("");
	readonly subtext = new ObservableValue<string>("");

	constructor(instance: FloatingTextDefinition) {
		super(instance);

		this.text.subscribe((text) => (instance.text.Text = text), true);
		this.subtext.subscribe((text) => (instance.subtext!.Text = text), true);
	}
}


import { Players, Workspace } from "@rbxts/services";
import { Interface } from "engine/client/gui/Interface";
import { InputController } from "engine/client/InputController";
import { Component } from "engine/shared/component/Component";
import { ArgsSignal } from "engine/shared/event/Signal";
import type { BlockSelector } from "client/tools/highlighters/MultiBlockSelector";
import type { SharedPlot } from "shared/building/SharedPlot";

export class BoxSelector extends Component implements BlockSelector {
	private readonly _submit = new ArgsSignal<[blocks: readonly BlockModel[]]>();
	readonly submit = this._submit.asReadonly();

	constructor(plot: SharedPlot, filter?: (blocks: readonly BlockModel[]) => readonly BlockModel[]) {
		super();

		const camera = Workspace.CurrentCamera!;
		const mouse = Players.LocalPlayer.GetMouse();
		const template = this.asTemplate(Interface.getTemplates<{ Selection: GuiObject }>().Selection, false);

		const search = (objects: readonly BlockModel[], p1: Vector2, p2: Vector2) => {
			const to3dSpace = (pos: Vector2) => {
				return camera.ScreenPointToRay(pos.X, pos.Y).Origin;
			};

			const calcSlope = (vec: Vector3) => {
				const rel = camera.CFrame.PointToObjectSpace(vec);
				return new Vector2(rel.X / -rel.Z, rel.Y / -rel.Z);
			};

			const swap = (a1: Vector2, a2: Vector2) => {
				return $tuple(
					new Vector2(math.min(a1.X, a2.X), math.min(a1.Y, a2.Y)),
					new Vector2(math.max(a1.X, a2.X), math.max(a1.Y, a2.Y)),
				);
			};

			const overlaps = (cf: CFrame, a1: Vector2, a2: Vector2) => {
				const rel = camera.CFrame.ToObjectSpace(cf);
				const x = rel.X / -rel.Z;
				const y = rel.Y / -rel.Z;

				return a1.X < x && x < a2.X && a1.Y < y && y < a2.Y && rel.Z < 0;
			};

			const search = (objs: readonly BlockModel[], p1: Vector3, p2: Vector3) => {
				let a1 = calcSlope(p1);
				let a2 = calcSlope(p2);
				[a1, a2] = swap(a1, a2);

				return objs.filter((obj) => overlaps(obj.GetBoundingBox()[0], a1, a2));
			};

			return search(objects, to3dSpace(p1), to3dSpace(p2));
		};

		let selection: GuiObject | undefined;
		const highlights: (SelectionBox & { Parent: BlockModel })[] = [];
		let selected: readonly BlockModel[] | undefined;
		const clearHighlights = () => {
			for (const highlight of highlights) {
				highlight.Destroy();
			}
			highlights.clear();
		};

		if (InputController.inputType.get() === "Desktop") {
			const startpos = new UDim2(0, mouse.X, 0, mouse.Y);

			selection = template();
			selection.Position = startpos;
			selection.Parent = Interface.getUnscaled();
			selection.Visible = true;
			selection.Size = new UDim2(0, 0, 0, 0);

			const searchBlocks = (): readonly BlockModel[] => {
				const blocks: readonly BlockModel[] = search(
					plot.getBlocks(),
					new Vector2(startpos.Width.Offset, startpos.Height.Offset),
					new Vector2(mouse.X, mouse.Y),
				);

				return filter?.(blocks) ?? blocks;
			};

			this.event.subscribe(mouse.Move, () => {
				selection!.Size = new UDim2(0, mouse.X, 0, mouse.Y).sub(startpos);

				const inside = searchBlocks();
				selected = inside;
				clearHighlights();

				for (const block of inside) {
					const highlight = new Instance("SelectionBox") as SelectionBox & { Parent: BlockModel };
					highlight.Parent = block;
					highlight.Adornee = block;
					highlight.LineThickness = 0.05;
					highlight.Color3 = Color3.fromRGB(0, 255 / 2, 255);

					highlights.push(highlight);
				}
			});
		}

		this.onDisable(() => {
			clearHighlights();
			selection?.Destroy();
		});
		this.onDestroy(() => {
			if (selected && selected.size() !== 0) {
				this._submit.Fire(selected);
			}
		});
	}
}


import { GuiService, Players } from "@rbxts/services";
import { Signals } from "client/Signals";
import { Interface } from "engine/client/gui/Interface";
import { Component } from "engine/shared/component/Component";
import { ObservableValue } from "engine/shared/event/ObservableValue";
import { PlayerUtils } from "engine/shared/utils/PlayerUtils";
import { SharedPlot } from "shared/building/SharedPlot";

export class HoveredPartHighlighter<T extends Instance> extends Component {
	readonly highlightedPart = new ObservableValue<T | undefined>(undefined);

	constructor(map?: (part: BasePart) => T | undefined) {
		super();

		const mouse = Players.LocalPlayer.GetMouse();
		let selectionBox: (SelectionBox & { Parent: T }) | undefined;

		const destroyHighlight = () => {
			selectionBox?.Destroy();
			selectionBox = undefined;

			this.highlightedPart.set(undefined);
		};

		const updatePosition = () => {
			// ignore if ESC menu is open
			if (GuiService.MenuIsOpen) {
				return;
			}

			// ignore if not alive
			if (!PlayerUtils.isAlive(Players.LocalPlayer)) {
				return;
			}

			// ignore if over a GUI element
			if (Interface.isCursorOnVisibleGui()) {
				destroyHighlight();
				return;
			}

			let target: Instance | undefined = mouse.Target;

			// if the same target
			if (target === selectionBox?.Parent) {
				return;
			}

			destroyHighlight();

			if (target) {
				target = map?.(target as BasePart);
			}

			if (!target) return;

			destroyHighlight();

			selectionBox = new Instance("SelectionBox") as typeof selectionBox & defined;
			selectionBox.Parent = target as T;
			selectionBox.Adornee = target;
			selectionBox.SurfaceTransparency = 0.95;
			selectionBox.LineThickness = 0.1;
			selectionBox.Color3 = new Color3(1, 1, 1);
			selectionBox.SurfaceColor3 = new Color3(1, 1, 1);
			selectionBox.Transparency = 0;

			this.highlightedPart.set(selectionBox.Parent);
		};

		this.event.onPrepare((inputType, eventHandler, inputHandler) => {
			eventHandler.subscribe(SharedPlot.anyChanged, updatePosition);

			if (inputType === "Desktop") {
				eventHandler.subscribe(mouse.Move, updatePosition);
				eventHandler.subscribe(Signals.CAMERA.MOVED, updatePosition);
			} else if (inputType === "Gamepad") {
				eventHandler.subscribe(Signals.CAMERA.MOVED, updatePosition);
			} else if (inputType === "Touch") {
				inputHandler.onTouchTap(updatePosition);
			}

			updatePosition();
		});
		this.onDisable(destroyHighlight);
	}
}


import { Workspace } from "@rbxts/services";
import { BlockGhoster } from "client/tools/additional/BlockGhoster";
import { Component } from "engine/shared/component/Component";
import { Element } from "engine/shared/Element";
import type { ReadonlyObservableValue } from "engine/shared/event/ObservableValue";

const model = Element.create("Model", { Name: "MultiHighlighterGlobal", Parent: Workspace });
const highlightInstance = BlockGhoster.createHighlight({ Parent: model });

/** Highlights multiple {@link PVInstance}s by cloning them and putting them in a single {@link Model} */
export class MultiModelHighlighter extends Component {
	private highlighted?: readonly PVInstance[];

	constructor(instances: ReadonlyObservableValue<readonly PVInstance[]>) {
		super();

		const highlight = (instances: readonly PVInstance[]) => {
			stop();
			if (instances.size() === 0) return;

			const cloned = instances.map((i) => i.Clone());
			for (const instance of cloned) {
				for (const desc of instance.GetDescendants()) {
					if (desc.IsA("WeldConstraint")) {
						if (
							desc.Part0?.IsDescendantOf(instance) === false ||
							desc.Part1?.IsDescendantOf(instance) === false
						) {
							desc.Destroy();
						}
					}
				}

				instance.Name += "_CLONED";
				instance.Parent = model;
			}

			highlightInstance.Adornee = model;
			this.highlighted = cloned;
		};
		const stop = () => {
			if (!this.highlighted) return;

			highlightInstance.Adornee = undefined;
			for (const instance of this.highlighted) {
				instance.Destroy();
			}

			this.highlighted = undefined;
		};

		this.event.subscribeObservable(instances, highlight, true);
		this.onDisable(stop);
	}
}


import { ContextActionService } from "@rbxts/services";
import { LabelControl } from "client/gui/controls/LabelControl";
import { ScaledScreenGui } from "client/gui/ScaledScreenGui";
import { ServiceIntegrityChecker } from "client/integrity/ServiceIntegrityChecker";
import { Control } from "engine/client/gui/Control";
import { Interface } from "engine/client/gui/Interface";
import { LocalPlayer } from "engine/client/LocalPlayer";
import { Colors } from "engine/shared/Colors";
import { Component } from "engine/shared/component/Component";
import { InstanceComponent } from "engine/shared/component/InstanceComponent";
import { Transforms } from "engine/shared/component/Transforms";
import { Element } from "engine/shared/Element";

const prefab = Interface.getInterface<{
	Help: {
		Tutorial: GuiObject & {
			// readonly Dialog: DialogDefinition;
			readonly Progress: ProgressDefinition;
			readonly Text: HelpTextDefinition;
		};
	};
}>().Help.Tutorial;
prefab.Visible = false;

type ProgressTaskDefinition = GuiObject & {
	readonly UIGradient: UIGradient;
	readonly NameLabel: TextLabel;
	readonly ProgressLabel: TextLabel;
};
class ProgressTask extends Control<ProgressTaskDefinition> {
	constructor(gui: ProgressTaskDefinition) {
		super(gui);
	}

	hideProgressText() {
		this.gui.ProgressLabel.Visible = false;
	}

	setText(text: string) {
		this.gui.NameLabel.Text = text;
	}
	setProgress(done: number, count: number) {
		if (count === 0) {
			this.gui.ProgressLabel.Text = "";
			this.gui.UIGradient.Transparency = new NumberSequence(0);
			return;
		}

		this.gui.ProgressLabel.Text = `${done}/${count}`;

		if (done >= count - 0.1) {
			this.gui.UIGradient.Transparency = new NumberSequence(0);
		} else {
			this.gui.UIGradient.Transparency = new NumberSequence([
				new NumberSequenceKeypoint(0, 0),
				new NumberSequenceKeypoint(done / count + 0.05, 0),
				new NumberSequenceKeypoint(done / count + 0.1, 1),
				new NumberSequenceKeypoint(1, 1),
			]);
		}
	}
}

type ProgressDefinition = GuiObject & {
	readonly Header: TextLabel;
	readonly Content: GuiObject & {
		readonly Tasks: GuiObject & {
			readonly Task: ProgressTaskDefinition;
		};
		readonly TextLabel: TextLabel;
		readonly Buttons: GuiObject & {
			readonly Skip: GuiButton;
			readonly Stop: GuiButton;
		};
	};
	readonly Progress: GuiObject & {
		readonly Inner: GuiObject;
	};
};
class Progress extends Control<ProgressDefinition> {
	private readonly taskTemplate;
	private stopAction = () => {};
	private skipAction = () => {};

	constructor(gui: ProgressDefinition) {
		super(gui);
		this.gui.Progress.Size = new UDim2(new UDim(0, 0), this.gui.Progress.Size.Y);
		this.taskTemplate = this.asTemplate(gui.Content.Tasks.Task);

		this.gui.Content.Buttons.Stop.Visible = false;
		this.parent(new Control(gui.Content.Buttons.Stop)) //
			.addButtonAction(() => this.stopAction());
	}

	/** Sets the title */
	setTitle(text: string) {
		this.gui.Header.Text = text;
	}
	/** Sets the text */
	setText(text: string) {
		this.gui.Content.TextLabel.Text = text;
	}

	/** Enables the Skip button, sets it to invoke {@link func} upon pressing */
	enableSkip(): this {
		const btnn = this.parent(new Control(this.gui.Content.Buttons.Skip)) //
			.addButtonAction(() => this.skipAction());
		btnn.setInstanceVisibility(true);

		return this;
	}

	/** Adds a task */
	addTask(text: string, count: number) {
		const task = new ProgressTask(this.taskTemplate());
		task.instance.Parent = this.gui.Content.Tasks;
		task.setText(text);
		task.setProgress(0, count);

		return task;
	}

	/**
	 * Sets the Skip button action
	 * @param func New Skip action. Accepts previous Skip action as `original` parameter
	 */
	setSkipAction(func: (original: () => void) => void) {
		this.gui.Content.Buttons.Stop.Visible = true;

		const prev = this.skipAction;
		this.skipAction = () => func(prev);
	}

	/**
	 * Sets the Stop button action
	 * @param func New Stop action. Accepts previous Stop action as `original` parameter
	 */
	setStopAction(func: (original: () => void) => void) {
		this.gui.Content.Buttons.Stop.Visible = true;

		const prev = this.stopAction;
		this.stopAction = () => func(prev);
	}

	/**
	 * Sets the progress
	 * @argument progress The new progress, 0-1
	 */
	setProgress(progress: number) {
		const color = progress > this.gui.Progress.Size.X.Scale ? Colors.white : Colors.red;
		Transforms.create() //
			.flashColor(this.gui.Progress, color)
			.flashColor(this.gui.Progress.Inner, color)
			.transform(
				this.gui.Progress,
				"Size",
				new UDim2(new UDim(progress, 0), this.gui.Progress.Size.Y),
				Transforms.quadOut02,
			)
			.run(this.gui.Progress);
	}
}

type HelpTextDefinition = GuiObject & {
	readonly Content: GuiObject & {
		readonly TextLabel: TextLabel;
		readonly Key: GuiObject & {
			readonly Controller: ImageLabel;
			readonly Keyboard: ImageLabel & {
				readonly KeyLabel: TextLabel;
			};
		};
	};
	readonly Buttons: GuiObject & {
		readonly Skip: GuiButton;
		readonly Next: GuiButton;
	};
};
class HelpText extends Control<HelpTextDefinition> {
	private readonly textTemplate;
	private readonly keyTemplate;
	private readonly content;

	constructor(
		gui: HelpTextDefinition,
		private readonly scaledScreen: ScaledScreenGui,
		private readonly progress: Progress,
	) {
		super(gui);

		this.content = this.parent(new Control(gui.Content));

		this.textTemplate = this.asTemplate(gui.Content.TextLabel);
		this.keyTemplate = this.asTemplate(gui.Content.Key);

		gui.Buttons.Skip.Visible = gui.Buttons.Next.Visible = false;
	}

	/** Positions this gui around {@link instance}, on its {@link side} with a {@link margin} */
	withPositionAround(instance: GuiObject, side: "right" | "up" | "down" | "left", margin: number = 16): this {
		margin *= 2;

		this.event.loop(0, () => {
			let pos = new Vector2();

			const absolutePos = instance.AbsolutePosition;
			if (side === "right") {
				pos = absolutePos.add(new Vector2(instance.AbsoluteSize.X + margin, instance.AbsoluteSize.Y / 2));
				this.instance.AnchorPoint = new Vector2(0, 0.5);
			} else if (side === "left") {
				pos = absolutePos.add(new Vector2(-margin, instance.AbsoluteSize.Y / 2));
				this.instance.AnchorPoint = new Vector2(1, 0.5);
			} else if (side === "down") {
				pos = absolutePos.add(new Vector2(instance.AbsoluteSize.X / 2, instance.AbsoluteSize.Y + margin));
				this.instance.AnchorPoint = new Vector2(0.5, 0);
			} else if (side === "up") {
				pos = absolutePos.add(new Vector2(instance.AbsoluteSize.X / 2, -margin));
				this.instance.AnchorPoint = new Vector2(0.5, 1);
			}

			this.instance.Position = new UDim2(
				0,
				pos.X / this.scaledScreen.getScale(),
				0,
				pos.Y / this.scaledScreen.getScale(),
			);
		});

		return this;
	}
	/**
	 * Moves this gui to a new position
	 * @argument x New position X, relative to the screen
	 * @argument y New position Y, relative to the screen
	 */
	withRelativePosition(x: number, y: number): this {
		this.instance.Position = new UDim2(x, 0, y, 0);
		return this;
	}

	/** Adds a text line */
	addText(text: string): LabelControl {
		const control = this.parent(new LabelControl(this.textTemplate()));
		control.value.set(text);

		return control;
	}

	/** Adds a text line, returns `this` */
	withText(text: string): this {
		this.addText(text);
		return this;
	}

	/** Enables the Next button, sets it to invoke {@link func} upon pressing */
	withNext(func: () => void): this {
		const btnn = this.parent(new Control(this.gui.Buttons.Next)) //
			.addButtonAction(func);
		btnn.setInstanceVisibility(true);
		this.parent(this.progress.addTask("Press Next", 0));

		return this;
	}
}

export type { TutorialControllerGui };
class TutorialControllerGui extends InstanceComponent<ScreenGui> {
	private readonly scaled;
	readonly progress;

	constructor(gui: ScreenGui) {
		super(gui);

		this.onEnabledStateChange((state) => (gui.Enabled = state));
		this.scaled = this.parent(new ScaledScreenGui(gui));

		this.progress = this.parentGui(new Progress(prefab.Progress.Clone()));
	}

	/** Creates a fullscreen fade control */
	createFullScreenFade() {
		const frame = Element.create("Frame", {
			ZIndex: -9999,
			BorderSizePixel: 0,
			Transparency: 0.5,
			Size: new UDim2(1, 0, 1, 0),
			BackgroundColor3: Color3.fromRGB(0, 0, 0),
			Active: true,
			Parent: this.instance,
		});

		return new Control(frame);
	}
	/** Creates a fullscreen fade control with a hole around {@link gui} + additional margin */
	createFullScreenFadeWithHoleAround(gui: GuiObject, margin: Vector2 = new Vector2(16, 16)) {
		const overlay = new InstanceComponent(
			Element.create("ScreenGui", {
				Name: "overlay",
				DisplayOrder: this.instance.DisplayOrder - 1,
				IgnoreGuiInset: true,
				Parent: this.instance,
			}),
		);

		const addFrame = (color = Color3.fromRGB(0, 0, 0), transparency = 0.5) => {
			return overlay.parentGui(
				new Control(
					Element.create("Frame", {
						ZIndex: -9999,
						BorderSizePixel: 0,
						Transparency: transparency,
						BackgroundColor3: color,
						Active: true,
					}),
				),
			);
		};

		const reposition = (frame: InstanceComponent<GuiObject>, pos: UDim2, size: UDim2) => {
			frame.instance.Position = pos;
			frame.instance.Size = size;
		};

		const color = Color3.fromRGB(255, 0, 0);
		const framesDim = [addFrame(), addFrame(), addFrame(), addFrame()];
		const framesOutline = [addFrame(color, 0), addFrame(color, 0), addFrame(color, 0), addFrame(color, 0)];

		this.event.loop(0, () => {
			let min = gui.AbsolutePosition.sub(margin);
			let max = gui.AbsolutePosition.add(gui.AbsoluteSize).add(margin.mul(2));

			min = min.sub(overlay.instance.AbsolutePosition);
			max = max.sub(overlay.instance.AbsolutePosition);

			reposition(framesDim[0], new UDim2(), new UDim2(1, 0, 0, min.Y));
			reposition(framesDim[1], new UDim2(0, 0, 0, max.Y), new UDim2(1, 0, 1, 0));
			reposition(framesDim[2], new UDim2(0, 0, 0, min.Y), new UDim2(0, min.X, 0, max.Y - min.Y));
			reposition(framesDim[3], new UDim2(0, max.X, 0, min.Y), new UDim2(1, 0, 0, max.Y - min.Y));

			const thickness = 2;
			reposition(framesOutline[0], new UDim2(0, min.X, 0, min.Y), new UDim2(0, max.X - min.X, 0, thickness));
			reposition(framesOutline[1], new UDim2(0, min.X, 0, min.Y), new UDim2(0, thickness, 0, max.Y - min.Y));
			reposition(
				framesOutline[2],
				new UDim2(0, min.X, 0, max.Y),
				new UDim2(0, max.X - min.X + thickness, 0, thickness),
			);
			reposition(framesOutline[3], new UDim2(0, max.X, 0, min.Y), new UDim2(0, thickness, 0, max.Y - min.Y));
		});

		return overlay;
	}

	/** Creates a floating window with ability to add text, or a Next button */
	createText() {
		return this.parent(new HelpText(prefab.Text.Clone(), this.scaled, this.progress));
	}
}

const allInputTypes: readonly (Enum.KeyCode | Enum.PlayerActions)[] = [
	...Enum.KeyCode.GetEnumItems(),
	...Enum.PlayerActions.GetEnumItems(),
];
export class TutorialController extends Component {
	readonly gui;

	constructor() {
		super();

		const screen = Element.create("ScreenGui", { Name: "Tutorial", DisplayOrder: 99999, IgnoreGuiInset: true });
		ServiceIntegrityChecker.whitelistInstance(screen);
		screen.Parent = Interface.getInterface();

		this.gui = this.parent(new TutorialControllerGui(screen));
	}

	/** Creates a component which disables user input of the provided {@link types} and enables back upon destroy */
	disableInput(types: readonly (Enum.KeyCode | Enum.PlayerActions | Enum.UserInputType)[]): Component {
		const component = new Component();
		ContextActionService.BindActionAtPriority(
			"sink@tutorial",
			() => Enum.ContextActionResult.Sink,
			false,
			math.huge,
			...types,
		);
		component.onDestroy(() => ContextActionService.UnbindAction("sink@tutorial"));

		LocalPlayer.getPlayerModule().GetControls().Disable();
		component.onDestroy(() => LocalPlayer.getPlayerModule().GetControls().Enable());

		return component;
	}

	/** Creates a component which disables user input and enables back upon destroy */
	disableAllInput(): Component {
		return this.disableInput(allInputTypes);
	}

	/** Creates a component which disables user input except the provided ones and enables back upon destroy */
	disableAllInputExcept(except: readonly Enum.KeyCode[]): Component {
		return this.disableInput(allInputTypes.except(except));
	}

	/** Creates a component which waits until {@link func} returns `true`, then calls {@link thenfunc} */
	waitFor(func: () => boolean | undefined, thenfunc: () => void) {
		const component = new Component();
		component.event.loop(0, () => {
			if (!func()) return;
			thenfunc();
		});

		return component;
	}
}