sandboxels/mods/worldEdit.ts

570 lines
14 KiB
TypeScript

// WorldEdit.ts
// Version: 1.2.0
// Interfaces
interface WorldEditState {
firstSelectionPos: Vec2D,
clipboard: Pixel[][] | null
selection: Rect | null
lastNonWorldEditElement: string
}
interface WorldEditStyle {
strokeWidth: number
selectFill: string
selectStroke: string
selectDash: boolean
pasteFill: string
pasteStroke: string
pastePixelColor: string
}
// Constants
const w_accentColor = "#7cff62"
const w_style: WorldEditStyle = {
strokeWidth: 1,
selectFill: "#57b64530",
selectStroke: w_accentColor,
selectDash: true,
pasteFill: "#00FFFF40",
pasteStroke: "#00FFFF",
pastePixelColor: "#00FFFF44"
}
// Global variables
let worldEditElements: ElementsType = {}
let pastePreviewCanvas: OffscreenCanvas
let w_state: WorldEditState = {
firstSelectionPos: {x: 0, y: 0},
selection: null,
clipboard: null,
lastNonWorldEditElement: "unknown"
}
// Define settings
let w_settingsTab: SettingsTab
let w_deselectOnResetSetting: Setting
dependOn("betterSettings.js", () => {
w_settingsTab = new SettingsTab("WorldEdit")
w_deselectOnResetSetting = new Setting(
"Deselect on reset",
"deselectOnReset",
settingType.BOOLEAN,
false,
true
)
w_settingsTab.registerSettings("Selection", w_deselectOnResetSetting)
settingsManager.registerTab(w_settingsTab)
}, true)
// Classes
class Rect {
constructor(
public x: number,
public y: number,
public w: number,
public h: number,
) {
}
static fromCorners(start: Vec2D, end: Vec2D): Rect {
return new Rect(start.x, start.y, end.x - start.x, end.y - start.y)
}
static fromCornersXYXY(x: number, y: number, x2: number, y2: number): Rect {
return new Rect(x, y, x2 - x, y2 - y)
}
static fromGrid(grid: any[][], origin: Vec2D = {x: 0, y: 0}): Rect {
return new Rect(
origin.x,
origin.y,
grid[0].length,
grid.length,
)
}
get area(): number {
return this.w * this.h
}
get x2(): number {
return this.x + this.w
}
get y2(): number {
return this.y + this.h
}
set x2(val: number) {
this.w = val - this.x
}
set y2(val: number) {
this.h = val - this.y
}
copy(): Rect {
return new Rect(this.x, this.y, this.w, this.h)
}
normalized(): Rect {
return Rect.fromCornersXYXY(
Math.min(this.x, this.x2),
Math.min(this.y, this.y2),
Math.max(this.x, this.x2),
Math.max(this.y, this.y2)
)
}
}
// Functions
function reverseString(str: string): string {
return [...str].reverse().join("")
}
function isPointInWorld(point: Vec2D): boolean {
return point.x >= 0 && point.x <= width && point.y >= 0 && point.y <= height
}
function limitPointToWorld(point: Vec2D): Vec2D {
return {
x: Math.max(0, Math.min(point.x, width)),
y: Math.max(0, Math.min(point.y, height))
}
}
function mousePosToWorldPos(pos: Vec2D) {
const rect = canvas.getBoundingClientRect()
let x = pos.x - rect.left
let y = pos.y - rect.top
x = Math.floor((x / canvas.clientWidth) * (width + 1))
y = Math.floor((y / canvas.clientHeight) * (height + 1))
return {x: x, y: y}
}
function updatePastePreviewCanvas(): void {
const clipboard = w_state.clipboard
if (!clipboard) return
const clipboardRect = Rect.fromGrid(clipboard)
// Create canvas
pastePreviewCanvas = new OffscreenCanvas(clipboardRect.w, clipboardRect.h)
const pastePreviewCtx = pastePreviewCanvas.getContext("2d")!
const imageData = pastePreviewCtx.createImageData(clipboardRect.w, clipboardRect.h)
const buffer = new Uint32Array(imageData.data.buffer)
buffer.fill(0x00000000)
const pixelColorBinary: number = parseInt(
reverseString(
w_style.pastePixelColor.slice(1)
),
16
);
for (let y = 0; y < clipboardRect.h; y++) {
for (let x = 0; x < clipboardRect.w; x++) {
if (clipboard[y][x])
buffer[y * clipboardRect.w + x] = pixelColorBinary
}
}
pastePreviewCtx.putImageData(imageData, 0, 0)
}
function renderSelection(ctx: CanvasRenderingContext2D): void {
const selection = w_state.selection
if (!selection) return
const isSelecting = (
mouseIsDown &&
(mouseType !== "middle" && mouseType !== "right") &&
currentElement === "w_select"
)
ctx.globalAlpha = 1.0
// Fill
if (!isSelecting) {
ctx.fillStyle = w_style.selectFill
ctx.fillRect(
selection.x * pixelSize,
selection.y * pixelSize,
selection.w * pixelSize,
selection.h * pixelSize
)
}
// Dash if selection is big enough
if (w_style.selectDash && selection.w >= 2 && selection.h >= 2)
ctx.setLineDash([pixelSize, pixelSize])
// Stroke
ctx.strokeStyle = w_style.selectStroke
ctx.lineWidth = w_style.strokeWidth
ctx.strokeRect(
selection.x * pixelSize,
selection.y * pixelSize,
selection.w * pixelSize,
selection.h * pixelSize
)
ctx.setLineDash([])
}
function renderPastePreview(ctx: CanvasRenderingContext2D): void {
if (currentElement !== 'w_paste') return
const clipboard = w_state.clipboard
if (!clipboard) return
const clipboardRect = Rect.fromGrid(clipboard, mousePos)
ctx.globalAlpha = 1.0
// Fill
ctx.fillStyle = w_style.pasteFill
ctx.fillRect(
clipboardRect.x * pixelSize,
clipboardRect.y * pixelSize,
clipboardRect.w * pixelSize,
clipboardRect.h * pixelSize
)
// Stroke
ctx.strokeStyle = w_style.pasteStroke
ctx.lineWidth = w_style.strokeWidth
ctx.strokeRect(
clipboardRect.x * pixelSize,
clipboardRect.y * pixelSize,
clipboardRect.w * pixelSize,
clipboardRect.h * pixelSize
)
if (pastePreviewCanvas)
ctx.drawImage(pastePreviewCanvas, mousePos.x * pixelSize, mousePos.y * pixelSize, clipboardRect.w * pixelSize, clipboardRect.h * pixelSize)
}
function addWorldEditKeybinds(): void {
keybinds.w = () => { // Switch to WorldEdit tab
selectCategory("worldEdit")
}
keybinds.d = () => { // Deselect
elements.w_deselect.rawOnSelect()
}
keybinds.a = () => { // Select all
elements.w_select_all.rawOnSelect()
}
keybinds.s = () => { // Select
selectElement("w_select")
selectCategory("worldEdit")
}
keybinds.c = () => { // Copy
elements.w_copy.rawOnSelect()
}
keybinds.v = () => { // Paste
selectElement("w_paste")
selectCategory("worldEdit")
}
keybinds.x = () => { // Cut
elements.w_cut.rawOnSelect()
}
keybinds.Delete = () => { // Delete
elements.w_delete.rawOnSelect()
}
keybinds.g = () => { // Fill
elements.w_fill.rawOnSelect()
}
}
function modifySelectElement(): void {
const originalSelectElement = selectElement
// @ts-ignore
selectElement = (element: string): void => {
// Keep track of last non-worldEdit element
if (!worldEditElements.hasOwnProperty(element))
w_state.lastNonWorldEditElement = element
originalSelectElement(element)
}
}
function addWorldEditElements(elementsToAdd: ElementsType): void {
for (const elementName in elementsToAdd) {
const element = elementsToAdd[elementName]
elements[elementName] = element
// Apply base settings for every worldEdit element
element.category ??= "worldEdit"
element.color ??= w_accentColor
element.tool ??= () => null
element.maxSize ??= 1
// Some elements will auto-deselect themselves
if (!element.shouldStaySelected) {
const originalOnSelect = element.onSelect
element.rawOnSelect = originalOnSelect
element.onSelect = function (...args: any[]) {
originalOnSelect(...args)
selectElement(w_state.lastNonWorldEditElement)
}
}
}
}
// Elements
worldEditElements.w_deselect = {
onSelect: function (): void {
w_state.selection = null
if (pixelTicks != 0)
logMessage("Deselected area.")
}
}
worldEditElements.w_select_all = {
onSelect: function (): void {
w_state.selection = new Rect(0, 0, width + 1, height + 1)
logMessage("Selected everything.")
}
}
worldEditElements.w_select = {
onPointerDown: function (e: PointerEvent): void {
const pos = mousePosToWorldPos({x: e.clientX, y: e.clientY})
if (showingMenu) return
if (!isPointInWorld(pos)) return
if (e.button === 1 || e.button === 2) return
w_state.firstSelectionPos = pos
},
onPointerMoveAnywhere: function (e: PointerEvent): void {
const pos = mousePosToWorldPos({x: e.clientX, y: e.clientY})
if (!mouseIsDown) return
if (showingMenu) return
if (e.button === 1 || e.button === 2) return
if (currentElement !== "w_select") return
const rect = Rect.fromCorners(
w_state.firstSelectionPos,
limitPointToWorld(pos)
).normalized()
rect.x2 += 1
rect.y2 += 1
w_state.selection = rect
},
shouldStaySelected: true
}
worldEditElements.w_copy = {
onSelect: function (): void {
const selection = w_state.selection
if (!selection) {
logMessage("Error: Nothing is selected.")
return
}
// Copy pixels
w_state.clipboard = []
let clipboard = w_state.clipboard
for (let y = selection.y; y < selection.y2; y++) {
const row: Pixel[] = []
for (let x = selection.x; x < selection.x2; x++) {
row.push(structuredClone(pixelMap[x][y]))
}
clipboard.push(row)
}
updatePastePreviewCanvas()
logMessage(`Copied ${selection.w}x${selection.h}=${selection.area} pixel area.`)
}
}
worldEditElements.w_paste = {
onPointerDown: function (e: PointerEvent): void {
if (showingMenu) return
if (!isPointInWorld(mousePos)) return
if (e.button === 1 || e.button === 2) return
const clipboard = w_state.clipboard
if (!clipboard) {
logMessage("Error: Nothing in clipboard.")
return
}
const pasteOrigin = mousePos
// Paste pixels
for (let y = 0; y < clipboard.length; y++) {
for (let x = 0; x < clipboard[0].length; x++) {
const clipboardPixel = clipboard[y][x]
const dest = {x: pasteOrigin.x + x, y: pasteOrigin.y + y}
if (!isPointInWorld(dest)) continue // Skip if out of bounds
if (pixelMap[dest.x][dest.y]) continue // Skip if pixel already there
if (!clipboardPixel) continue // Skip if new pixel is air
// Create pixel
const newPixel = structuredClone(clipboardPixel)
Object.assign(newPixel, dest)
pixelMap[dest.x][dest.y] = newPixel
currentPixels.push(newPixel)
}
}
const area = Rect.fromGrid(clipboard).area
logMessage(`Pasted ${clipboard[0].length}x${clipboard.length}=${area} pixel area.`)
},
shouldStaySelected: true
}
worldEditElements.w_cut = {
onSelect: function (): void {
const selection = w_state.selection
if (!selection) {
logMessage("Error: Nothing is selected.")
return
}
// Cut pixels
w_state.clipboard = []
let clipboard = w_state.clipboard
for (let y = selection.y; y < selection.y2; y++) {
const row: Pixel[] = []
for (let x = selection.x; x < selection.x2; x++) {
row.push(structuredClone(pixelMap[x][y]))
const pixel = pixelMap[x][y]
const index = currentPixels.indexOf(pixel)
if (index !== -1) currentPixels.splice(index, 1)
if (pixel) {
delete pixelMap[x][y]
}
}
clipboard.push(row)
}
updatePastePreviewCanvas()
logMessage(`Cut ${selection.w}x${selection.h}=${selection.area} pixel area.`)
}
}
worldEditElements.w_delete = {
onSelect: function (): void {
const selection = w_state.selection
if (!selection) {
logMessage("Error: Nothing is selected.")
return
}
// Delete pixels
for (let y = selection.y; y < selection.y2; y++) {
for (let x = selection.x; x < selection.x2; x++) {
const pixel = pixelMap[x][y]
const index = currentPixels.indexOf(pixel)
if (index !== -1) currentPixels.splice(index, 1)
if (pixel) {
delete pixelMap[x][y]
}
}
}
logMessage(`Deleted ${selection.w}x${selection.h}=${selection.area} pixel area.`)
}
}
worldEditElements.w_fill = {
onSelect: function (): void {
const selection = w_state.selection
const fillElement = w_state.lastNonWorldEditElement
if (!selection) {
logMessage("Error: Nothing is selected.")
return
}
// Fill area
for (let y = selection.y; y < selection.y2; y++) {
for (let x = selection.x; x < selection.x2; x++) {
if (pixelMap[x][y]) continue
const placed = currentPixels.push(new Pixel(x, y, fillElement))
if (!placed) continue
if (currentPixels.length > maxPixelCount || !fillElement) {
currentPixels[currentPixels.length - 1].del = true
} else if (elements[fillElement] && elements[fillElement].onPlace !== undefined) {
elements[fillElement].onPlace(currentPixels[currentPixels.length - 1])
}
}
}
logMessage(`Filled in ${selection.w}x${selection.h}=${selection.area} pixel area.`)
}
}
// Setup and hooks
modifySelectElement()
addWorldEditElements(worldEditElements)
addWorldEditKeybinds()
runAfterReset(() => {
if (w_deselectOnResetSetting.value)
w_state.selection = null
})
runAfterReset(updatePastePreviewCanvas)
renderPostPixel(renderSelection)
renderPostPixel(renderPastePreview)
// Mobile support
let addedCustomEventListeners = false
runAfterReset(() => {
if (addedCustomEventListeners) return
gameCanvas.addEventListener("pointerdown", (e: PointerEvent) => {
if (elements[currentElement] && elements[currentElement].onPointerDown)
elements[currentElement].onPointerDown(e)
}, {passive: false})
gameCanvas.addEventListener("pointermove", (e: PointerEvent) => {
if (elements[currentElement] && elements[currentElement].onPointerMove)
elements[currentElement].onPointerMove(e)
}, {passive: false})
document.addEventListener("pointermove", (e: PointerEvent) => {
if (elements[currentElement] && elements[currentElement].onPointerMoveAnywhere)
elements[currentElement].onPointerMoveAnywhere(e)
})
addedCustomEventListeners = true
})