<script lang="ts">
import { DEFAULT_CONFIG, loadConfig } from "../config.ts";
import createDebug from "debug";
import "../css/themes/catppuccin-mocha.scss";
import "../css/style.scss";
import HeartIcon from "../icons/HeartIcon.svelte";
import Popup from "../components/ui/Popup.svelte";
import { __pack_save_for_api, unpack_save } from "../save.ts";
import { PartType } from "../protocol.ts";
import { onMount } from "svelte";
import { part_texture_url } from "../textures.ts";
import Button from "../components/ui/Button.svelte";
import WarningIcon from "../icons/WarningIcon.svelte";
let config = DEFAULT_CONFIG;
// Top-level await. Sets the default config, and overwrites it when the new config is avail. Thanks reactivity!
(async () => {
config = await loadConfig();
})();
let selected: PartType | null = null;
let rotation: number | null = null;
const logger = createDebug("main");
logger(
`Hello, world! StarKingdoms ${APP_VERSION} (${COMMIT_HASH}) at your service!`,
);
logger("Current view: ShipEditor.svelte");
let canvas: HTMLCanvasElement;
let x = window.innerWidth / 2 - 24;
let y = window.innerHeight / 2 - 24;
let grid_x = 0;
let grid_y = 0;
let x_dir = [0, -1, 0, 1];
let y_dir = [1, 0, -1, 0];
let scale = 1.0;
$: grid_size = 48 * scale;
function handleWheel(e: WheelEvent) {
let delta = e.shiftKey ? 0.01 : 0.1;
if (e.deltaY < 0) {
scale += delta;
} else {
scale -= delta;
}
if (scale > 5) {
scale = 5;
} else if (scale < 0.1) {
scale = 0.1;
}
}
let isdragging = false;
let last_cx = -1;
let last_cy = -1;
function mouseup(e) {
isdragging = false;
}
function mousemove(e: MouseEvent) {
if (isdragging) {
if (last_cx != -1) {
x += e.clientX - last_cx;
}
last_cx = e.clientX;
if (last_cy != -1) {
y += e.clientY - last_cy;
}
last_cy = e.clientY;
} else {
grid_x = Math.round((e.clientX - x) / grid_size - 0.5);
grid_y = Math.round((e.clientY - y) / grid_size - 0.5);
}
}
function goto(nx: number, ny: number) {
x = nx + (window.innerWidth / 2 - 24);
y = ny + (window.innerHeight / 2 - 24);
}
if (window.localStorage.getItem("save") === null) {
window.location.href = "/";
}
let save = unpack_save(window.localStorage.getItem("save")!);
console.log(save);
let grid: Map<number, Map<number, [PartType, number]>> = new Map();
function placePart(x: number, y: number, part: PartType, rotation: number) {
if (!grid.has(x)) {
grid.set(x, new Map());
}
grid.get(x)?.set(y, [part, rotation]);
}
let textures: Map<PartType, HTMLImageElement> = new Map();
let context: CanvasRenderingContext2D | null = null;
let cantplace_reason: string | null = null;
function canPlace(): boolean {
if (selected === null) {
cantplace_reason = "No part selected";
return false;
}
if (grid.get(grid_x)?.has(grid_y)) {
cantplace_reason = "Part already exists in this slot";
return false;
}
let counts = part_counts.get(selected)!;
if (counts.used === counts.available) {
cantplace_reason = "Out of this part";
return false;
}
cantplace_reason = null;
return true;
}
$: selected, grid, canPlace();
function render(
ctx: CanvasRenderingContext2D | null,
x: number,
y: number,
scale: number,
grid: Map<number, Map<number, PartType>>,
grid_x: number,
grid_y: number,
rotation: number | null,
) {
if (ctx === null) {
return;
}
// clear out the canvas
ctx.clearRect(0, 0, canvas.width, canvas.height);
(async () => {
if (selected !== null) {
if (!textures.has(selected!)) {
let promise = new Promise((resolve, reject) => {
let img = new Image(grid_size, grid_size);
img.onload = () => resolve(img);
img.onerror = reject;
img.src = part_texture_url(selected!, true);
});
let img = await promise;
textures.set(part_type, <HTMLImageElement>img);
}
let canvas_x = grid_x * grid_size + x;
let canvas_y = grid_y * grid_size + y;
let img = textures.get(selected!)!;
ctx.save();
ctx.translate(canvas_x + grid_size / 2, canvas_y + grid_size / 2);
ctx.rotate(((rotation+2) * Math.PI) / 2);
ctx.globalAlpha = 0.2;
ctx.drawImage(
img,
-grid_size / 2,
-grid_size / 2,
grid_size,
grid_size,
);
ctx.globalCompositeOperation = "source-atop";
if (selected === null) {
ctx.fillStyle = "rgb(24, 24, 37)";
} else if (canPlace()) {
ctx.fillStyle = "rgb(166, 227, 161)";
} else {
ctx.fillStyle = "rgb(243, 139, 169)";
}
ctx.globalAlpha = 0.5;
ctx.fillRect(-grid_size / 2, -grid_size / 2, grid_size, grid_size);
ctx.restore();
}
})();
grid.forEach((row, x_coord) => {
row.forEach(async ([part_type, rotation], y_coord) => {
// draw the image
if (!textures.has(part_type)) {
let promise = new Promise((resolve, reject) => {
let img = new Image(grid_size, grid_size);
img.onload = () => resolve(img);
img.onerror = reject;
img.src = part_texture_url(part_type, true);
});
let img = await promise;
textures.set(part_type, <HTMLImageElement>img);
}
let img = textures.get(part_type)!;
let canvas_x = x_coord * grid_size + x;
let canvas_y = y_coord * grid_size + y;
ctx.save();
ctx.translate(canvas_x + grid_size / 2, canvas_y + grid_size / 2);
ctx.rotate(((rotation+2) * Math.PI) / 2);
ctx.drawImage(
img,
-grid_size / 2,
-grid_size / 2,
grid_size,
grid_size,
);
ctx.restore();
});
});
}
$: {
render(context, x, y, scale, grid, grid_x, grid_y, rotation);
}
onMount(() => {
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;
context = canvas.getContext("2d");
render(context, x, y, scale, grid, grid_x, grid_y);
});
window.onresize = () => {
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;
};
let confirm_reload_save = false;
let confirm_save = false;
let confirm_quit = false;
let part_counts: Map<PartType, { used: number; available: number }> =
new Map();
function place(x: number, y: number, data: any, rotation: number) {
if (data === null) {
return;
}
let [type, children] = data;
console.log("(" + x + ", " + y + ") type:" + type + ", rot: " + rotation);
console.log(children);
placePart(x, y, type, rotation);
let existing_part_count =
part_counts.get(<PartType>type) === undefined
? { used: 0, available: 0 }
: part_counts.get(<PartType>type)!;
part_counts.set(<PartType>type, {
used: existing_part_count.used + 1,
available: existing_part_count.available + 1,
});
if (children[0] !== null) {
let rotation_next = (rotation + 2) % 4;
place(x + x_dir[rotation_next], y + y_dir[rotation_next], children[0], rotation_next);
}
if (children[1] !== null) {
let rotation_next = (rotation + 1) % 4;
place(x + x_dir[rotation_next], y + y_dir[rotation_next], children[1], rotation_next);
}
if (children[2] !== null) {
let rotation_next = (rotation + 0) % 4;
place(x + x_dir[rotation_next], y + y_dir[rotation_next], children[2], rotation_next);
}
if (children[3] !== null) {
let rotation_next = (rotation + 3) % 4;
place(x + x_dir[rotation_next], y + y_dir[rotation_next], children[3], rotation_next);
}
}
function load_save_data(save: any) {
grid = new Map();
place(0, 0, [PartType.Hearty, save[0]], 2);
}
$: {
load_save_data(save);
}
function reload_btn() {
if (confirm_reload_save) {
save = unpack_save(window.localStorage.getItem("save")!);
}
confirm_reload_save = !confirm_reload_save;
setTimeout(() => {
confirm_reload_save = false;
}, 5000);
}
function save_recursive(x: number, y: number /*, a_rotation: number*/) {
let [part_type, rotation] = grid.get(x)!.get(y)!;
let children = [null, null, null, null];
let x_dir = Math.round(Math.cos(((rotation + 1) * Math.PI) / 2));
let y_dir = Math.round(Math.sin(((rotation + 1) * Math.PI) / 2));
if (part_type == PartType.Hearty) {
if (grid.get(x + 1)?.get(y)?.[1] == 1) {
// right
children[1] = save_recursive(x + 1, y, 1);
}
if (grid.get(x - 1)?.get(y)?.[1] == 3) {
// left
children[3] = save_recursive(x - 1, y, 3);
}
if (grid.get(x)?.get(y - 1)?.[1] == 0) {
// up
children[2] = save_recursive(x, y - 1, 0);
}
if (grid.get(x)?.get(y + 1)?.[1] == 2) {
// down
children[0] = save_recursive(x, y + 1, 2);
}
return { part_type: part_type, children: children };
} else if (
part_type == PartType.Cargo ||
part_type == PartType.LandingThruster
) {
return { part_type: part_type, children: children };
}
if (grid.get(x + x_dir)?.get(y)?.[1] == (rotation + 3) % 4) {
// left
children[3] = save_recursive(x + x_dir, y, rotation + 3);
}
if (grid.get(x - x_dir)?.get(y)?.[1] == (rotation + 1) % 4) {
// right
children[1] = save_recursive(x - x_dir, y, rotation + 1);
}
/*if (grid.get(x)?.get(y - y_dir)?.[1] == rotation) {
// down
children[2] = save_recursive(x, y - y_dir);
}*/
if (grid.get(x)?.get(y - y_dir)?.[1] == rotation) {
// up
children[2] = save_recursive(x, y - y_dir, rotation);
}
return [part_type, children];
}
async function save_btn() {
if (confirm_save) {
// todo: @ghostly you need to turn this back into a savefile and then call pack_partial on it
let children = save_recursive(0, 0, 0).children;
let unused_modules = [];
for (let [part_type, value] of part_counts) {
let unused = value.available - value.used;
if (unused != 0 && part_type != PartType.Hearty) {
unused_modules.push([part_type, unused]);
}
}
let save_data = [children, unused_modules];
console.log(save_data);
let packed_savefile = __pack_save_for_api(save_data);
let r = await fetch(config.environments[3].apiBaseUrl + "/sign_save", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
old_save: window.localStorage.getItem("save")!,
new_partial: packed_savefile,
}),
});
if (r.ok) {
let body = await r.json();
// @ts-ignore
window.localStorage.setItem("save", body.signed_save);
window.location.href = "/";
}
//window.location.href = "/";
}
confirm_save = !confirm_save;
setTimeout(() => {
confirm_save = false;
}, 5000);
}
function quit_btn() {
if (confirm_quit) {
window.location.href = "/";
}
confirm_quit = !confirm_quit;
setTimeout(() => {
confirm_quit = false;
}, 5000);
}
function toggle_select(type: PartType) {
console.log(type);
if (selected == type) {
selected = null;
rotation = null;
} else {
selected = type;
rotation = 0;
}
canvas.focus();
}
function mousedown(e: MouseEvent) {
if (e.button === 0) {
if (canPlace()) {
if (!grid.has(grid_x)) {
grid.set(grid_x, new Map());
}
grid.get(grid_x)!.set(grid_y, [selected!, rotation]);
part_counts.set(selected!, {
used: part_counts.get(selected!)!.used + 1,
available: part_counts.get(selected!)!.available,
});
part_counts = part_counts;
grid = grid;
}
// This gets unset immediately if a normal click
isdragging = true;
last_cx = e.clientX;
last_cy = e.clientY;
} else if (e.button == 2) {
// removal
if (grid.get(grid_x)?.has(grid_y)) {
let type = grid.get(grid_x)!.get(grid_y)[0]!;
if (type === PartType.Hearty) {
return;
}
grid.get(grid_x)?.delete(grid_y);
part_counts.set(type, {
used: part_counts.get(type)!.used - 1,
available: part_counts.get(type)!.available,
});
part_counts = part_counts;
grid = grid;
}
}
canvas.focus();
}
function keydown(e: KeyboardEvent) {
if (e.code == "KeyQ") {
rotation = (rotation - 1) % 4;
}
if (e.code == "KeyE") {
rotation = (rotation + 1) % 4;
}
}
</script>
<Popup title="Parts" id="parts" draggable minimizable style="width: 15em;">
{#each part_counts.entries() as [type, counts]}
{#if type !== PartType.Hearty}
<Button
selected={type === selected}
style="margin-top: 5px; width: 100%;"
on:click={() => {
toggle_select(type);
}}>
<img
style="vertical-align: middle;"
src={part_texture_url(type, true)}
width="24"
height="24"
alt={type} />
<span>{type} - {counts.used} used of {counts.available}</span>
</Button>
{/if}
{/each}
{#if cantplace_reason !== null}
<span class="server-danger">
<WarningIcon class="server-danger-icon" />
Cannot place: {cantplace_reason}
</span>
{/if}
</Popup>
<Popup
title="Tools"
id="tools"
draggable
minimizable
style="width: min-content;">
<Button
style="width: 16em; margin-top: 5px;"
on:click={() => {
goto(0, 0);
scale = 1.0;
}}>
Reset Viewport
</Button>
<Button
on:click={reload_btn}
variant="danger"
style="min-width: 16em; margin-top: 5px;">
{#if confirm_reload_save}
Are you sure? (again to confirm, resets in 5s)
{:else}
Reload Save
{/if}
</Button>
<Button
on:click={save_btn}
variant="danger"
style="min-width: 16em; margin-top: 5px;">
{#if confirm_save}
Are you sure? Overwrites old save! (again to confirm, resets in 5s)
{:else}
Save Changes
{/if}
</Button>
<Button
on:click={quit_btn}
variant="danger"
style="min-width: 16em; margin-top: 5px;">
{#if confirm_quit}
Are you sure you want to quit without saving? (again to confirm, resets in
5s)
{:else}
Quit without Save
{/if}
</Button>
</Popup>
<canvas
on:mousedown|preventDefault={mousedown}
on:mouseup|preventDefault={mouseup}
on:mousemove|preventDefault={mousemove}
on:wheel|preventDefault={handleWheel}
on:keydown={keydown}
on:contextmenu|preventDefault={() => {
return false;
}}
style="background-size: {grid_size}px {grid_size}px; background-position: {x}px {y}px;"
tabindex="1"
bind:this={canvas} />
<span style="position: absolute; top: 10px; right: 10px;">
({grid_x}, {grid_y}) {scale.toFixed(2)}x
</span>
<span class="footer-left">
StarKingdoms Client {APP_VERSION} ({COMMIT_HASH})
</span>
<span class="footer-right">
Made with <HeartIcon class="footer-icon" /> by the StarKingdoms team
</span>
<style>
:global(#tools) {
position: absolute;
bottom: 0.5em;
left: 0.5em;
}
:global(#parts) {
position: absolute;
top: 0.5em;
left: 0.5em;
}
canvas {
width: 100vw;
height: 100vh;
position: absolute;
top: 0;
left: 0;
padding: 0;
margin: 0;
background-image: linear-gradient(to right, #adadad20 1px, transparent 1px),
linear-gradient(to bottom, #adadad20 1px, transparent 1px);
}
</style>