Files
contests/ccc/ccc_42/level4/sandbox-level-4.html
T
2026-10-20 01:31:45 +03:00

1754 lines
53 KiB
HTML

<!DOCTYPE html>
<!-- IF YOU READ THIS YOU SHOULD PROBABLY BE WORKING ON YOUR SOLUTION INSTEAD -->
<html lang="en-US">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width">
<meta name="description" content="This is a visualizer for the 41th CCC, classic edition. Use this to help verify your solutions! To use it, open this file in your browser.">
<title>CCC 41 | Spaceship</title>
</head>
<body>
<header>
<div class="description-text">
<h1>Spaceship</h1>
<p>
This is an interactive tool to help you <strong>visualize</strong> and <strong>prototype</strong> your solutions.<br/>
<em>You can drag and zoom on the viewer below!</em>
</p>
</div>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="39 48 384 284" width="150" id="codinator">
<polygon fill="#FDDD00" points="
59.65 53.93 52.5 53.93 52.5 61.08 59.65 61.08 66.8 61.08 66.8 53.93 59.65 53.93
"/>
<polygon fill="#FDDD00" points="
173.98 311.18 173.98 318.33 181.13 318.33
181.13 311.18 181.13 304.04 181.13 296.89
173.98 296.89 173.98 304.04 173.98 311.18
"/>
<rect fill="#FDDD00" x="181.13" y="318.33" width="7.15" height="7.15"/>
<polygon fill="#FDDD00" points="
409.79 61.08 409.79 53.93 402.65 53.93 395.5 53.93 395.5 61.08 402.65 61.08 409.79 61.08
"/>
<path fill="#FDDD00" d="
m409.79,103.95v-14.29h-7.15v-14.29h-7.15v-14.29h-7.15v7.15h-7.15v14.29h7.15v7.15h7.15v14.29h7.15
v21.44h-7.15v7.15h-7.15v7.15h-71.46v7.15h-21.44v7.15h-14.29v7.15h-7.15v-7.15h-14.29v-7.15h-57.17
v7.15h-14.29v7.15h-7.15v-7.15h-14.29v-7.15h-21.44v-7.15h-71.46v-7.15h-7.15v-7.15h-7.15v-21.44h7.15
v-14.29h7.15v-7.15h7.15v-14.29h-7.15v-7.15h-7.15v14.29h-7.15v14.29h-7.15v14.29h-7.15v42.87h7.15
v7.15h7.15v7.15h35.73v7.15h35.73v7.15h7.15v7.15h7.15v28.58h7.15v7.15h7.15v42.87h7.15v35.73h7.15
v-7.15h7.15v-7.15h28.58v-7.15h14.29v-7.15h14.29v7.15h14.29v7.15h28.58v7.15h7.15v7.15h7.15
v-35.73h7.15v-42.87h7.15v-7.15h7.15v-28.58h7.15v-7.15h7.15v-7.15h35.73v-7.15h35.73v-7.15h7.15
v-7.15h7.15v-42.87h-7.15Zm-128.62,128.62v14.29h-7.15v7.15h-28.58v-14.29h14.29v-7.15h14.29
v-7.15h7.15v7.15Zm-71.46,21.44h-21.44v-7.15h-7.15v-21.44h7.15v7.15h14.29v7.15h14.29v14.29h-7.15Z
"/>
<polygon fill="#FDDD00" points="
281.17 304.04 281.17 311.18 281.17 318.33
288.32 318.33 288.32 311.18 288.32 304.04
288.32 296.89 281.17 296.89 281.17 304.04
"/>
<rect fill="#FDDD00" x="274.02" y="318.33" width="7.15" height="7.15"/>
</svg>
</header>
<main>
<div class="inputArea">
<div class="labeled-text-area">
<label for="levelInput">Level Input</label>
<textarea id="levelInput" rows="4"></textarea>
</div>
<div class="labeled-text-area">
<label for="levelSolution">Level Solution</label>
<textarea id="levelSolution" rows="4"></textarea>
</div>
</div>
<canvas id="canvas"></canvas>
<div class="controls">
<div class="control-group">
<div class="pace-count-wrapper">
<input id="inputStepIndex">
<span>/</span>
<span id="paceCount">Count</span>
</div>
<label for="inputStepIndex">Step Index</label>
</div>
<div class="control-group">
<button id="buttonReset">↺</button>
<small>R</small>
</div>
<div class="control-group">
<button id="buttonPrevStep">⏮</button>
<small>Left</small>
</div>
<div class="control-group">
<button id="buttonPlay">▶</button>
<small>Space</small>
</div>
<div class="control-group">
<button id="buttonNextStep">⏭</button>
<small>Right</small>
</div>
<div id="asteroidAreaControlGroup" class="control-group">
<div class="checkbox-label-wrapper">
<input type="checkbox" id="checkboxAsteroidArea">
<label for="checkboxAsteroidArea">Draw area<br>around asteroids</label>
</div>
<small>T</small>
</div>
<div class="control-group-info">
<div>
<span>Ship Position:</span>
<span id="spanShipPosition"></span>
</div>
<div>
<span>Mouse Position:</span>
<span id="spanMousePosition"></span>
</div>
<div>
<span><strong>Status:</strong></span>
<span><strong id="spanStatus"></strong></span>
</div>
<div>
<span id="spanStatusMessage"></span>
</div>
</div>
</div>
</main>
</body>
<style>
*, ::before, ::after {
box-sizing: border-box;
}
html, body {
width: 100%;
height: 100%;
}
html {
color-scheme: dark;
background: #27272A;
font-family: Arial, sans-serif;
}
body {
margin: 0;
padding: 1rem;
overflow: hidden;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
h1 {
color: #FDDD00;
margin: 0;
}
p, label {
margin: 0;
}
strong {
color: #FDDD00;
&[success='false'] {
color: #EE1010;
}
&[success='true'] {
color: #30DD10;
}
}
textarea, input {
background: #27272A;
outline: none;
border: 2px solid white;
border-radius: 5px;
padding: 0.5rem;
resize: vertical;
&:focus-visible {
border: 2px solid #FDDD00;
}
}
button {
min-width: 5rem;
min-height: 3rem;
background: #FDDD00;
color: black;
border: 2px solid #FDDD00;
border-radius: 5px;
font-family: monospace;
font-size: 1.8rem;
cursor: pointer;
}
button, input[type=checkbox] {
&:focus-visible {
outline: 2px solid #FDDD00;
outline-offset: 2px;
}
}
button[disabled] {
border: 2px solid #FDDD0040;
background: #FDDD0040;
cursor: not-allowed;
}
header {
display: flex;
}
.description-text {
flex: 1;
}
#codinator {
margin: -1rem 0 -1rem 0;
cursor: help;
}
main {
flex: 1;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.inputArea {
display: flex;
gap: 1rem;
}
.labeled-text-area {
display: flex;
flex-direction: column;
width: 50%;
}
#canvas {
flex: 1;
background-color: black;
margin: 0;
cursor: grab;
&:active {
cursor: grabbing;
}
}
.controls {
display: flex;
flex-direction: row;
gap: 1rem;
}
.control-group {
display: flex;
flex-direction: column;
align-items: center;
}
.pace-count-wrapper {
min-width: 7rem;
min-height: 3rem;
padding: 0.25rem 0;
}
#inputStepIndex {
width: 4rem;
text-align: right;
font-size: 16px;
}
.checkbox-label-wrapper {
min-height: 3rem;
display: flex;
align-items: center;
gap: 0.5rem;
}
.control-group-info {
display: flex;
flex-direction: column;
}
</style>
<script>
/**
* @typedef GlobalState
* @type {object}
* @property {number} level - The level of the game.
* @property {RenderState} render - Container for rendering context and values.
* @property {GameState} game - Container for all game related data.
*/
/**
* @typedef RenderState
* @type {object}
* @property {CanvasRenderingContext2D} c - The canvas rendering context.
* @property {number} w - The width of the drawing area in pixels.
* @property {number} h - The height of the drawing area in pixels.
* @property {Array.<number>} uToPxTable - The table for the zoom levels of units to pixels.
* @property {number} uToPxCount - The number of entries of uToPxTable.
* @property {number} uToPxIndex - The index of the entry of uToPxTable.
* @property {number} uToPx - The current size of a unit in pixels.
* @property {Path2D} arrowPath - The path of an arrow.
*/
/**
* @typedef GameState
* @type {object}
* @property {number} lastTimeSinceStart - Time elapsed since last requestAnimationFrame callback.
* @property {number} timeSinceLastLogicUpdate - Time elapsed since last logic update.
* @property {InputState} input - Inputs from UI.
* @property {V2} camera - The position of the camera.
* @property {V2} mouseGridPosition - The position of the mouse in grid space coordinates.
* @property {boolean} simPlaying - The status of the pace autoplay.
* @property {number} simStep - The index of the current pace.
* @property {number} timeLimit - The maximum allowed paces.
* @property {number} simLimit - The index of the last pace.
* @property {Entity} ship - The ship entity.
* @property {Entity} goal - The goal entity.
* @property {Array.<Entity>} asteroids - The asteroid entities.
* @property {boolean} drawAsteroidArea - Flag to determine whether to draw the asteroid area or not.
*/
/**
* @typedef InputState
* @type {object}
* @property {boolean} up
* @property {boolean} down
* @property {boolean} left
* @property {boolean} right
* @property {boolean} shift
* @property {boolean} nextTick
* @property {boolean} prevTick
* @property {boolean} mouseIn
* @property {V2} mouseScreenPosition
* @property {V2} mouseNormalizedPosition
*/
/** @type {GlobalState} */
const state = {};
state.level = 4;
const SIMULATION_TIME_PER_FRAME = 5 * 1000.0 / 60.0;
const MIN_PACE = 5;
const MAX_PACE = 1;
const AREA_GOAL = 5;
const AREA_SHIP = 5;
const AREA_ASTEROID = 5;
const AREA_COLLISION = 2;
const PACE_BAR_UNIT_SIZE = 24;
const PACE_BAR_MARGIN = 8;
const PACE_BAR_PADDING = 4;
const PACE_BAR_HEIGHT = 2 * (PACE_BAR_UNIT_SIZE + PACE_BAR_MARGIN) + PACE_BAR_MARGIN;
const PACE_CURSOR_WIDTH = 4;
const INDICATOR_SIZE = 12;
const PLAY_PAUSE_SYMBOLS = ['▶', '⏸'];
const RENDER_FULL_AREA_THRESHOLD = 2;
const PACE_COLOR_RIGHT = '#D5241C';
const PACE_COLOR_LEFT = '#CF30F7';
const PACE_COLOR_UP = '#30DD10';
const PACE_COLOR_DOWN = '#30DDDD';
const PACE_ARROW_COLOR_X = '#F7F7F7';
const PACE_ARROW_COLOR_Y = '#080808';
const COLOR_ASTEROID = '#FDDD00';
const COLOR_AREA_ASTEROID = '#3F3700';
const COLOR_GOAL = '#D5241C';
const COLOR_SHIP = '#0053FF';
const STATUS_MESSAGE_INVALID_FORMAT_INPUT = 'The input format is invalid! ';
const STATUS_MESSAGE_INVALID_FORMAT_SOLUTION = 'The solution format is invalid! ';
const STATUS_MESSAGE_SUCCESS = 'The spaceship docked successfully with the station!';
const STATUS_MESSAGE_EXCEEDED_TIME = 'Too many paces provided: ';
const STATUS_MESSAGE_COLLISION = 'The spaceship collided at step: ';
const STATUS_MESSAGE_MISSED = 'The spaceship did not reach the station: ';
const STATUS_MESSAGE_NOT_STOPPED = 'The spaceship did not stop at the station: ';
const codinator = document.getElementById('codinator');
const canvas = document.getElementById('canvas');
const levelInput = document.getElementById('levelInput');
const levelSolution = document.getElementById('levelSolution');
const paceCount = document.getElementById('paceCount');
const inputStepIndex = document.getElementById('inputStepIndex');
const buttonReset = document.getElementById('buttonReset');
const buttonPrevStep = document.getElementById('buttonPrevStep');
const buttonPlay = document.getElementById('buttonPlay');
const buttonNextStep = document.getElementById('buttonNextStep');
const checkboxAsteroidArea = document.getElementById('checkboxAsteroidArea');
const spanShipPosition = document.getElementById('spanShipPosition');
const spanMousePosition = document.getElementById('spanMousePosition');
const spanStatus = document.getElementById('spanStatus');
const spanStatusMessage = document.getElementById('spanStatusMessage');
const asteroidAreaControlGroup = document.getElementById('asteroidAreaControlGroup');
function init() {
initRenderState();
initGameState();
if (state.level < 7) {
asteroidAreaControlGroup.style.display = 'none';
}
codinator.addEventListener('click', () => secret());
addEventListener('keydown', (event) => onKey(event, true));
addEventListener('keyup', (event) => onKey(event, false));
canvas.addEventListener('mousemove', onCanvasMouseMove);
canvas.addEventListener('mousedown', onCanvasMouseDown);
canvas.addEventListener('mouseup', onCanvasMouseUp);
canvas.addEventListener('mouseout', onCanvasMouseOut);
canvas.addEventListener('wheel', onCanvasWheel);
levelInput.addEventListener('input', () => recalculate());
levelSolution.addEventListener('input', () => recalculate());
inputStepIndex.addEventListener('input', onInputPaceIndex);
buttonReset.addEventListener('click', onClickButtonReset);
buttonPrevStep.addEventListener('click', onClickButtonPrevStep);
buttonPlay.addEventListener('click', onClickButtonPlay);
buttonNextStep.addEventListener('click', onClickButtonNextStep);
checkboxAsteroidArea.addEventListener('input', onInputDrawAsteroidArea);
handleSpilloverEvents();
window.requestAnimationFrame(loop);
}
/**
* Make that elements you click on won't interact with other events.
* e.g. you click on a button, and it gets highlighted and when you pause (space) it gets triggered.
*/
function handleSpilloverEvents() {
const interactiveElements = [...document.getElementsByTagName('button')];
interactiveElements.forEach(
(element) => element.addEventListener('click', event => {
if (event.detail !== 0) {
element.blur();
}
})
);
}
function initRenderState() {
const uToPxIndex = 4;
const uToPxTable = [4, 6, 8, 12, 16, 18, 24, 32, 40, 64, 96, 128];
const arrowPath = new Path2D();
arrowPath.moveTo(-1.0, -0.5);
arrowPath.lineTo(0.0, -0.5);
arrowPath.lineTo(0.0, -1.0);
arrowPath.lineTo(1.0, 0.0);
arrowPath.lineTo(0.0, 1.0);
arrowPath.lineTo(0.0, 0.5);
arrowPath.lineTo(-1.0, 0.5);
state.render = {
c: canvas.getContext('2d'),
w: canvas.width,
h: canvas.height,
uToPxTable,
uToPxCount: uToPxTable.length,
uToPxIndex,
uToPx: uToPxTable[uToPxIndex],
arrowPath,
};
}
function initGameState() {
state.game = {
lastTimeSinceStart: 0.0,
timeSinceLastLogicUpdate: 0.0,
input: {
up: false,
down: false,
left: false,
right: false,
shift: false,
nextTick: false,
prevTick: false,
mouseIn: false,
mouseScreenPosition: v2(),
mouseNormalizedPosition: v2(),
},
camera: v2(),
mouseGridPosition: v2(),
simPlaying: false,
simStep: 0,
timeLimit: 0,
simLimit: 0,
ship: null,
goal: null,
asteroids: [],
drawAsteroidArea: false,
};
levelInput.value = '3,-3 16';
levelSolution.value = '0 5 4 5 0\n0 -5 -4 -5 0';
recalculate();
}
/** Class representing an entity that can move and be drawn. */
class Entity {
/**
* Entity constructor.
* @param {string} type - The entity type.
* @param {V2} p - The entity position.
* @param {V2} v - The entity velocity (pace).
*/
constructor(type, p = v2(), v = v2()) {
this.type = type;
this.p = p;
this.originalP = v2c(p);
this.v = v;
this.ticks = v2();
}
static createAsteroid(p, v = v2()) {
return new Entity('asteroid', p, v);
}
static createGoal(p, v = v2()) {
return new Entity('goal', p, v);
}
static createShip(p = v2(), v = v2()) {
return new Entity('ship', p, v);
}
/**
* Simulation function for calculating the next entity state.
*/
move() {
if (Math.abs(this.v.x) > 0 && Math.abs(this.v.x) <= MIN_PACE) {
this.ticks.x += 1;
if (this.ticks.x >= Math.abs(this.v.x)) {
this.ticks.x = 0;
this.p.x += Math.sign(this.v.x);
}
}
if (Math.abs(this.v.y) > 0 && Math.abs(this.v.y) <= MIN_PACE) {
this.ticks.y += 1;
if (this.ticks.y >= Math.abs(this.v.y)) {
this.ticks.y = 0;
this.p.y += Math.sign(this.v.y);
}
}
}
/**
* @return {Entity}
*/
copy() {
const result = new Entity(this.type, v2c(this.p), v2c(this.v));
result.ticks = v2c(this.ticks);
return result;
}
}
/**
* Main loop function.
* @param {number} timeSinceStart - The time since the start of execution.
*/
function loop(timeSinceStart) {
const deltaTime = timeSinceStart - state.game.lastTimeSinceStart;
state.game.lastTimeSinceStart = timeSinceStart;
update(deltaTime);
draw(state);
window.requestAnimationFrame(loop);
}
/**
* Main update state function. Framerate dependent.
* @param {number} deltaTime - Time elapsed since last frame.
*/
function update(deltaTime) {
state.game.timeSinceLastLogicUpdate += deltaTime;
let cameraDelta = v2();
if (state.game.input.up) {
cameraDelta.addEquals(v2(0, 1));
}
if (state.game.input.down) {
cameraDelta.addEquals(v2(0, -1));
}
if (state.game.input.left) {
cameraDelta.addEquals(v2(-1, 0));
}
if (state.game.input.right) {
cameraDelta.addEquals(v2(1, 0));
}
cameraDelta.mulEquals(8 / state.render.uToPx);
state.game.camera.addEquals(cameraDelta);
while (state.game.timeSinceLastLogicUpdate > SIMULATION_TIME_PER_FRAME) {
state.game.timeSinceLastLogicUpdate -= SIMULATION_TIME_PER_FRAME;
logicUpdate();
}
updateUI();
}
/**
* Logical update function. Framerate independent.
*/
function logicUpdate() {
if (state.game.simPlaying) {
if (state.game.simStep < state.game.simLimit) {
simNextStep();
} else {
simPlay();
}
}
}
/**
* Simulation toggle.
*/
function simPlay() {
state.game.timeSinceLastLogicUpdate = 0;
if (state.game.simPlaying) {
state.game.simPlaying = false;
buttonPlay.textContent = PLAY_PAUSE_SYMBOLS[0];
} else {
if (state.game.simStep === state.game.simLimit) {
simReset();
}
state.game.simPlaying = true;
buttonPlay.textContent = PLAY_PAUSE_SYMBOLS[1];
}
}
function simReset() {
applyStepIndex(0);
}
function simNextStep() {
applyStepIndex(Math.min(state.game.simStep + 1, state.game.simLimit));
}
function simPrevStep() {
applyStepIndex(Math.max(state.game.simStep - 1, 0));
}
function applyStepIndexEntity(entity, stepIndex) {
entity.p.x = entity.originalP.x + ((entity.v.x !== 0) ? Number.parseInt((stepIndex / entity.v.x).toPrecision()) : 0);
entity.ticks.x = (entity.v.x !== 0) ? (stepIndex % entity.v.x) : 0;
entity.p.y = entity.originalP.y + ((entity.v.y !== 0) ? Number.parseInt((stepIndex / entity.v.y).toPrecision()) : 0);
entity.ticks.y = (entity.v.y !== 0) ? (stepIndex % entity.v.y) : 0;
}
function applyStepIndex(stepIndex, skipUpdatingInput = false) {
const shipHistoryIndex = clamp(stepIndex, 0, state.game.ship.simLimit);
state.game.simStep = stepIndex;
state.game.ship = state.game.shipHistory[shipHistoryIndex];
applyStepIndexEntity(state.game.goal, stepIndex);
state.game.asteroids.forEach((asteroid) => applyStepIndexEntity(asteroid, stepIndex));
updateControls(skipUpdatingInput);
}
function hasShipCollided() {
for (const asteroid of state.game.asteroids) {
const d = state.game.ship.p.sub(asteroid.p);
if (Math.abs(d.x) <= AREA_COLLISION && Math.abs(d.y) <= AREA_COLLISION) {
return true;
}
}
return false;
}
function updateUI() {
spanShipPosition.textContent = `${state.game.ship.p.x},${state.game.ship.p.y}`;
spanMousePosition.textContent = `${state.game.mouseGridPosition.x},${state.game.mouseGridPosition.y}`;
}
/**
* Main loop draw function. Called once per frame.
* @param {GlobalState} state - The global state.
* @param {RenderState} state.render - The renderer state.
* @param {GameState} state.game - The game state.
*/
function draw({render, game}) {
drawClear(render);
drawAsteroids(render, game);
drawGrid(render, game);
drawGoal(render, game);
drawShip(render, game);
drawIndicators(render, game);
drawUI(render, game);
}
/**
* Clears the canvas. Adjusts for window or canvas resize.
* @param {CanvasRenderingContext2D} c - The drawing context.
*/
function drawClear({c}) {
canvas.width = 0;
canvas.height = 0;
// NOTE(alex): setting the minimum size to 1 prevents the game from freezing when resizing the window
const w = Math.max(canvas.offsetWidth, 1);
const h = Math.max(canvas.offsetHeight, 1);
canvas.width = w;
canvas.height = h;
state.render.w = w;
state.render.h = h;
c.clearRect(0, 0, w, h);
}
function drawEntity(c, uToPx, entity, color) {
c.save();
const screenPosition = worldToScreenSpace(entity.p);
c.translate(screenPosition.x, screenPosition.y);
c.fillStyle = color;
c.fillRect(-0.5 * uToPx + 1, -0.5 * uToPx + 1, uToPx - 2, uToPx - 2);
c.restore();
return screenPosition;
}
/**
* Draw all asteroids and the collision area.
* @param {RenderState} render - Renderer state.
* @param {CanvasRenderingContext2D} render.c - The drawing context.
* @param {number} render.uToPx - The unit to pixel ratio.
* @param {GameState} game - Game state.
* @param {Array.<Entity>} asteroids - Asteroid entities.
*/
function drawAsteroids({c, uToPx}, {asteroids}) {
if (state.game.drawAsteroidArea) {
for (const asteroid of asteroids) {
drawAsteroidArea(c, uToPx, asteroid);
}
}
for (const asteroid of asteroids) {
drawEntity(c, uToPx, asteroid, COLOR_ASTEROID);
}
}
/**
* Draw asteroid collision area.
* @param {CanvasRenderingContext2D} c - The drawing context.
* @param {number} uToPx - The unit to pixel ratio.
* @param {Entity} asteroid - The asteroid entity.
*/
function drawAsteroidArea(c, uToPx, asteroid) {
c.save();
const screenPosition = worldToScreenSpace(asteroid.p);
c.translate(screenPosition.x, screenPosition.y);
c.fillStyle = COLOR_AREA_ASTEROID;
const areaSize = AREA_ASTEROID * state.render.uToPx;
c.fillRect(-0.5 * areaSize, -0.5 * areaSize, areaSize, areaSize);
c.restore();
}
/**
* Draw the goal.
* @param {RenderState} render - Renderer state.
* @param {CanvasRenderingContext2D} render.c - The drawing context.
* @param {number} render.uToPx - The unit to pixel ratio.
* @param {number} render.uToPxIndex - The zoom level.
* @param {GameState} game - Game state.
* @param {Entity} game.goal - The goal entity.
*/
function drawGoal({c, uToPx, uToPxIndex}, {goal}) {
const screenPosition = drawEntity(c, uToPx, goal, COLOR_GOAL);
c.save();
c.translate(screenPosition.x, screenPosition.y);
c.strokeStyle = COLOR_GOAL;
c.lineWidth = 4;
c.beginPath();
const areaSize = 0.5 * AREA_GOAL * uToPx;
c.arc(0, 0, areaSize - 3, 0.0, 2 * Math.PI);
c.stroke();
if (uToPxIndex < RENDER_FULL_AREA_THRESHOLD) {
c.fillStyle = COLOR_GOAL;
c.fill();
}
c.closePath();
c.restore();
}
/**
* Draw the ship.
* @param {RenderState} render - Renderer state.
* @param {CanvasRenderingContext2D} render.c - The drawing context.
* @param {number} render.uToPx - The unit to pixel ratio.
* @param {number} render.uToPxIndex - The zoom level.
* @param {GameState} game - Game state.
* @param {Entity} game.ship - The ship entity.
*/
function drawShip({c, uToPx, uToPxIndex}, {ship}) {
const screenPosition = drawEntity(c, uToPx, ship, COLOR_SHIP);
c.save();
c.translate(screenPosition.x, screenPosition.y);
c.strokeStyle = COLOR_SHIP;
c.lineWidth = 4;
const areaSize = AREA_SHIP * uToPx;
c.strokeRect(-0.5 * areaSize + 3, -0.5 * areaSize + 3, areaSize - 6, areaSize - 6);
if (uToPxIndex < RENDER_FULL_AREA_THRESHOLD) {
c.fillStyle = COLOR_SHIP;
c.fillRect(-0.5 * areaSize + 3, -0.5 * areaSize + 3, areaSize - 6, areaSize - 6);
}
c.restore();
}
/**
* Draw the world grid.
* @param {RenderState} render - Renderer state.
* @param {CanvasRenderingContext2D} render.c - Rendering context.
* @param {number} render.w - Canvas width.
* @param {number} render.h - Canvas height.
* @param {number} render.uToPx - The unit to pixel ratio.
* @param {GameState} game - Game state.
* @param {V2} game.camera - The camera in world position.
*/
function drawGrid({c, w, h, uToPx}, {camera}) {
c.save();
c.fillStyle = 'rgb(50, 50, 50)';
const gridCountX = Math.ceil(0.5 * w / uToPx);
const gridOffsetX = Math.ceil(camera.x);
const gridFromX = gridOffsetX - gridCountX;
const gridToX = gridOffsetX + gridCountX;
const gridCountY = Math.ceil(0.5 * h / uToPx);
const gridOffsetY = Math.ceil(camera.y);
const gridFromY = gridOffsetY - gridCountY;
const gridToY = gridOffsetY + gridCountY;
for (let x = gridFromX; x <= gridToX; ++x) {
const gridPriority = getGridPriority(x);
if (gridPriority === 2) {
drawGridLineX(c, w, h, x, gridPriority);
}
}
for (let y = gridFromY; y <= gridToY; ++y) {
const gridPriority = getGridPriority(y);
if (gridPriority === 2) {
drawGridLineY(c, w, h, y, gridPriority);
}
}
for (let x = gridFromX; x <= gridToX; ++x) {
const gridPriority = getGridPriority(x);
if (gridPriority === 1) {
drawGridLineX(c, w, h, x, gridPriority);
}
}
for (let y = gridFromY; y <= gridToY; ++y) {
const gridPriority = getGridPriority(y);
if (gridPriority === 1) {
drawGridLineY(c, w, h, y, gridPriority);
}
}
drawAxis(c, w, h);
c.restore();
}
/**
* Get the grid priority for this coordinate, lower should be drawn on top.
* @param {number} coordinate the X or Y coordinate.
* @return {number} the gird priority.
*/
function getGridPriority(coordinate) {
if (coordinate === 0) {
return 0;
} else if (coordinate % 16 === 0) {
return 1;
} else {
return 2;
}
}
/**
* Set the fill color for the grid drawing functions. The higher priority should have more contrast with the black background.
* @param {CanvasRenderingContext2D} c - The rendering context.
* @param {number} gridPriority - The grid line priority.
*/
function setGridLineColor(c, gridPriority) {
if (gridPriority === 0) {
c.fillStyle = 'rgb(200, 200, 200)';
} else if (gridPriority === 1) {
c.fillStyle = 'rgb(100, 100, 100)';
} else if (gridPriority === 2) {
c.fillStyle = 'rgb(32, 32, 32)';
}
}
function drawGridLineX(c, w, h, x, gridPriority) {
setGridLineColor(c, gridPriority);
const screenPosition = worldToScreenSpace(v2(x - 0.5, 0));
c.fillRect(screenPosition.x - 1, 0, 2, h);
}
function drawGridLineY(c, w, h, y, gridPriority) {
setGridLineColor(c, gridPriority);
const screenPosition = worldToScreenSpace(v2(0, y - 0.5));
c.fillRect(0, screenPosition.y - 1, w, 2);
}
function drawAxis(c, w, h) {
const screenPosition = worldToScreenSpace(v2(-0.5, -0.5));
c.fillStyle = PACE_COLOR_RIGHT;
c.fillRect(Math.max(screenPosition.x, 0), screenPosition.y - 1, w, 2);
c.fillStyle = PACE_COLOR_LEFT;
c.fillRect(0, screenPosition.y - 1, Math.min(screenPosition.x, w), 2);
c.fillStyle = PACE_COLOR_UP;
c.fillRect(screenPosition.x - 1, 0, 2, Math.min(screenPosition.y, h));
c.fillStyle = PACE_COLOR_DOWN;
c.fillRect(screenPosition.x - 1, Math.max(screenPosition.y, 0), 2, h);
}
function drawIndicators({c, w, h, uToPx, arrowPath}, {goal, ship, camera}) {
drawEntityIndicator(c, w, h, uToPx, arrowPath, camera, goal, COLOR_GOAL);
drawEntityIndicator(c, w, h, uToPx, arrowPath, camera, ship, COLOR_SHIP);
}
function drawEntityIndicator(c, w, h, uToPx, arrowPath, camera, entity, color) {
const offsetPosition = entity.p.mul(-1).add(camera);
const ratioW = 0.5 * w / uToPx;
const ratioH = 0.5 * h / uToPx;
const normalizedPosition = v2(offsetPosition.x / ratioW, offsetPosition.y / ratioH);
const paceBarNormalSize = 2 * PACE_BAR_HEIGHT / h;
if (normalizedPosition.x <= -1.0 || normalizedPosition.x >= 1.0 || normalizedPosition.y <= -1.0 || normalizedPosition.y >= 1.0 - paceBarNormalSize) {
c.save();
c.fillStyle = color;
const arrowX = clamp(mapValue(-normalizedPosition.x, -1.0, 1.0, 0, w), INDICATOR_SIZE, w - INDICATOR_SIZE);
const arrowY = clamp(mapValue(normalizedPosition.y, -1.0, 1.0, 0, h), INDICATOR_SIZE, h - INDICATOR_SIZE - PACE_BAR_HEIGHT);
c.translate(arrowX, arrowY);
const angle = -Math.atan2(-normalizedPosition.y, -normalizedPosition.x);
c.rotate(angle);
c.scale(INDICATOR_SIZE, INDICATOR_SIZE);
c.fill(arrowPath);
c.restore();
}
}
/**
* Draws the on-canvas UI.
* @param {RenderState} render - Renderer state.
* @param {CanvasRenderingContext2D} render.c - Rendering context.
* @param {number} render.w - Canvas width.
* @param {number} render.h - Canvas height.
* @param {GameState} game - Game state.
* @param {Array.<number>} game.xPaces - The x pace array.
* @param {Array.<number>} game.yPaces - The y pace array.
* @param {number} game.simStep - The pace index.
*/
function drawUI({c, w, h}, {xPaces, yPaces, simStep}) {
drawPaceBarBackground(c, w, h);
const xyOffset = PACE_BAR_MARGIN + PACE_BAR_UNIT_SIZE + PACE_BAR_MARGIN;
const yyOffset = PACE_BAR_MARGIN;
drawPaceBar(c, w, h, xPaces, xyOffset, PACE_COLOR_RIGHT, PACE_COLOR_LEFT, 0, PACE_ARROW_COLOR_X, simStep);
drawPaceBar(c, w, h, yPaces, yyOffset, PACE_COLOR_UP, PACE_COLOR_DOWN, -0.5 * Math.PI, PACE_ARROW_COLOR_Y, simStep);
drawPaceCursor(c, w, h);
}
/**
* UI function that draws the background of the pace bar region.
* @param c {CanvasRenderingContext2D} - The canvas rendering context
* @param w {number} - The canvas width
* @param h {number} - The canvas height
*/
function drawPaceBarBackground(c, w, h) {
c.save();
c.fillStyle = 'rgba(105,110,120,1.0)';
const barWidth = w;
const barHeight = 2 * (PACE_BAR_UNIT_SIZE + PACE_BAR_MARGIN) + PACE_BAR_MARGIN;
c.fillRect(0, h - barHeight, barWidth, barHeight);
c.restore();
}
/**
* UI function that draws a pace bar.
* @param c {CanvasRenderingContext2D} - The canvas rendering context.
* @param w {number} - The canvas width.
* @param h {number} - The canvas height.
* @param uPaces {Array.<number>} - Array of paces.
* @param yOffset {number} - Vertical offset of the pace bar.
* @param positiveColor {string} - Pace bar positive color.
* @param negativeColor {string} - Pace bar negative color.
* @param arrowRotationOffset {number} - Pace arrow rotation offset.
* @param arrowColor {string} - Pace bar arrow color.
* @param simStep {number} - The pace index.
*/
function drawPaceBar(c, w, h, uPaces, yOffset, positiveColor, negativeColor, arrowRotationOffset, arrowColor, simStep) {
c.save();
const xOffsetCursor = PACE_BAR_MARGIN + PACE_BAR_UNIT_SIZE * simStep;
let xOffset = (xOffsetCursor < 0.5 * w) ? 0 : (0.5 * w - xOffsetCursor);
let paceBarY;
for (const pace of uPaces) {
const paceZero = pace === 0;
const pacePositive = pace > 0;
const blockWidth = PACE_BAR_UNIT_SIZE * Math.max(1, Math.abs(pace));
c.fillStyle = paceZero ? '#00000000' : (pacePositive ? positiveColor : negativeColor);
const paceBarX = PACE_BAR_MARGIN + xOffset;
paceBarY = h - yOffset - PACE_BAR_UNIT_SIZE;
c.fillRect(paceBarX, paceBarY, blockWidth, PACE_BAR_UNIT_SIZE);
c.strokeStyle = 'rgba(32,32,32,1.0)';
c.lineWidth = 4;
c.strokeRect(paceBarX, paceBarY, blockWidth, PACE_BAR_UNIT_SIZE);
c.lineWidth = 2;
c.strokeStyle = 'rgba(0,0,0,0.25)';
for (let paceDividerIndex = 1; paceDividerIndex < Math.abs(pace); ++paceDividerIndex) {
const paceDividerX = paceBarX + paceDividerIndex * PACE_BAR_UNIT_SIZE;
c.strokeRect(paceDividerX, paceBarY, 0, PACE_BAR_UNIT_SIZE);
}
const arrowRotation = arrowRotationOffset + (pacePositive ? 0 : Math.PI);
if (!paceZero) {
drawPaceArrow(c, paceBarX, paceBarY, blockWidth, arrowRotation, arrowColor);
}
xOffset += blockWidth;
}
c.restore();
}
/**
*
* @param c {CanvasRenderingContext2D} - The canvas rendering context.
* @param x {number} - X offset.
* @param y {number} - Y offset.
* @param w {number} - The width of the pace bar.
* @param arrowRotation {number} - The rotation of the arrow.
* @param arrowColor {string} - The color of the arrow.
*/
function drawPaceArrow(c, x, y, w, arrowRotation, arrowColor) {
c.save();
const arrowUnitSize = 8;
const arrowX = x + w - arrowUnitSize - PACE_BAR_PADDING;
const arrowY = y + 0.5 * PACE_BAR_UNIT_SIZE;
c.translate(arrowX, arrowY);
c.scale(arrowUnitSize, arrowUnitSize);
c.rotate(arrowRotation);
c.fillStyle = arrowColor;
c.fill(state.render.arrowPath);
c.restore();
}
/**
* UI function that draws the pace cursor.
* @param c {CanvasRenderingContext2D} - The canvas rendering context
* @param w {number} - The canvas width
* @param h {number} - The canvas height
*/
function drawPaceCursor(c, w, h) {
c.save();
const xOffsetCursor = Math.min(PACE_BAR_MARGIN + PACE_BAR_UNIT_SIZE * state.game.simStep, 0.5 * w);
c.fillStyle = 'rgba(255,255,0,1.0)';
const paceCursorHeight = 2 * (PACE_BAR_UNIT_SIZE + PACE_BAR_MARGIN) + PACE_BAR_MARGIN;
c.fillRect(xOffsetCursor - 0.5 * PACE_CURSOR_WIDTH, h - paceCursorHeight, PACE_CURSOR_WIDTH, paceCursorHeight);
c.restore();
}
/**
* @param {KeyboardEvent} event
* @param {boolean} isDown
*/
function onKey(event, isDown) {
if (event.target.tabIndex >= 0) {
if (isDown && event.key === 'Escape') {
event.target.blur();
}
return;
}
if (event.key === 'w' || event.key === 'W') {
state.game.input.up = isDown;
} else if (event.key === 'a' || event.key === 'A') {
state.game.input.left = isDown;
} else if (event.key === 's' || event.key === 'S') {
state.game.input.down = isDown;
} else if (event.key === 'd' || event.key === 'D') {
state.game.input.right = isDown;
}
if (isDown) {
if (event.key === ' ') {
simPlay();
} else if (event.key === 'r' || event.key === 'R') {
simReset();
} else if (event.key === 'ArrowRight') {
simNextStep();
} else if (event.key === 'ArrowLeft') {
simPrevStep();
} else if (event.key === 't' || event.key === 'T') {
setCheckboxAsteroidArea(!checkboxAsteroidArea.checked);
}
}
}
/**
* @param {MouseEvent} event
*/
function onCanvasMouseMove(event) {
updateMouseInputState(event);
if (event.buttons & 1 || event.buttons & 4) {
const delta = v2(-event.movementX, event.movementY);
state.game.camera.addEquals(V2.div(delta, state.render.uToPx));
}
}
/**
* @param {MouseEvent} event
*/
function onCanvasMouseDown(event) {
updateMouseInputState(event);
}
/**
* @param {MouseEvent} event
*/
function onCanvasMouseUp(event) {
updateMouseInputState(event);
}
/**
*
*/
function onCanvasMouseOut() {
state.game.input.mouseIn = false;
}
/**
* @param {WheelEvent} event
*/
function onCanvasWheel(event) {
updateMouseInputState(event);
event.preventDefault();
if (event.deltaY > 0) {
zoom(-1);
} else if (event.deltaY < 0) {
zoom(1);
}
}
function zoom(delta) {
const newIndex = state.render.uToPxIndex + delta;
if (newIndex >= 0 && newIndex < state.render.uToPxCount) {
state.render.uToPxIndex = newIndex;
state.render.uToPx = state.render.uToPxTable[newIndex];
}
}
function recalculate() {
parseInputAndSolution();
updateControls();
}
function parseInputAndSolution() {
state.game.simStep = 0;
state.game.shipHistory = [];
state.game.simLimit = 0;
const inputLines = normalizeInput(levelInput.value);
const solutionLines = normalizeInput(levelSolution.value);
state.game.xPaces = parsePaces(solutionLines[0]);
state.game.yPaces = parsePaces(solutionLines[1]);
if (inputLines.length === 0) {
setStatusFailure(STATUS_MESSAGE_INVALID_FORMAT_INPUT + 'The first line should be GoalX,GoalY TimeLimit');
return;
}
let inputIndex = 0;
if (!inputLines[inputIndex].includes(',')) {
++inputIndex;
}
const firstLine = inputLines[inputIndex++];
if (!firstLine.includes(' ')) {
setStatusFailure(STATUS_MESSAGE_INVALID_FORMAT_INPUT + 'The first line should be GoalX,GoalY TimeLimit');
return;
}
const goalLineParts = firstLine.split(' ');
if (goalLineParts.length !== 2 || !goalLineParts[0].includes(',')) {
setStatusFailure(STATUS_MESSAGE_INVALID_FORMAT_INPUT + 'The first line should be GoalX,GoalY TimeLimit');
return;
}
const goalPositionParts = goalLineParts[0].split(',');
if (goalPositionParts.length !== 2) {
setStatusFailure(STATUS_MESSAGE_INVALID_FORMAT_INPUT + 'The first line should be GoalX,GoalY TimeLimit');
return;
}
const [goalX, goalY] = goalPositionParts.map(it => Number.parseInt(it));
const goal = Entity.createGoal(v2(goalX, goalY));
state.game.timeLimit = Number.parseInt(goalLineParts[1]);
const asteroids = [];
if (inputLines.length > inputIndex) {
let asteroidCount = 1;
if (!inputLines[inputIndex].includes(',')) {
asteroidCount = Number.parseInt(inputLines[inputIndex++]);
if (inputLines.length < 3) {
setStatusFailure(STATUS_MESSAGE_INVALID_FORMAT_INPUT + 'Asteroid data is missing');
return;
}
}
const asteroidLineData = inputLines[inputIndex].split(' ');
if (asteroidLineData.length !== asteroidCount) {
setStatusFailure(STATUS_MESSAGE_INVALID_FORMAT_INPUT + 'Asteroid count does not match with the provided number');
return;
}
for (let asteroidIndex = 0; asteroidIndex < asteroidCount; ++asteroidIndex) {
const asteroidData = asteroidLineData[asteroidIndex].split(',').map(it => Number.parseInt(it));
if (asteroidData.length === 2) {
const [px, py] = asteroidData;
asteroids.push(Entity.createAsteroid(v2(px, py)));
} else if (asteroidData.length === 4) {
const [px, py, vx, vy] = asteroidData;
asteroids.push(Entity.createAsteroid(v2(px, py), v2(vx, vy)));
} else {
setStatusFailure(STATUS_MESSAGE_INVALID_FORMAT_INPUT + 'Asteroid format is invalid!');
return;
}
}
}
state.game.ship = Entity.createShip();
state.game.goal = goal;
state.game.asteroids = asteroids;
if (!state.game.xPaces.length || !state.game.yPaces.length) {
return;
}
for (const paces of [state.game.xPaces, state.game.yPaces]) {
if (paces[0] !== 0) {
setStatusFailure(STATUS_MESSAGE_INVALID_FORMAT_SOLUTION + `The first pace must be 0`);
return;
}
let lastPace = 0;
for (const pace of paces) {
const absPace = Math.abs(pace);
if (pace !== 0 && (absPace < MAX_PACE || absPace > MIN_PACE)) {
setStatusFailure(STATUS_MESSAGE_INVALID_FORMAT_SOLUTION + `Pace ${pace} is invalid!`);
return;
}
if (lastPace !== 0 && pace !== 0 && Math.sign(lastPace) !== Math.sign(pace)) {
setStatusFailure(STATUS_MESSAGE_INVALID_FORMAT_SOLUTION + `Pace progression ${lastPace} ${pace} is invalid! Changing directions requires a pace of 0 in between.`);
return;
}
if (!getValidPaces(lastPace).includes(pace)) {
setStatusFailure(STATUS_MESSAGE_INVALID_FORMAT_SOLUTION + `Pace progression ${lastPace} ${pace} is invalid!`);
return;
}
lastPace = pace;
}
if (lastPace !== 0) {
setStatusFailure(STATUS_MESSAGE_INVALID_FORMAT_SOLUTION + `The last pace must be 0`);
return;
}
}
const xTime = state.game.xPaces.reduce((acc, pace) => acc + Math.max(1, Math.abs(pace)), 0);
const yTime = state.game.yPaces.reduce((acc, pace) => acc + Math.max(1, Math.abs(pace)), 0);
if (xTime > yTime) {
for (let index = 0; index < xTime - yTime; ++index) {
state.game.yPaces.push(0);
}
} else if (xTime < yTime) {
for (let index = 0; index < yTime - xTime; ++index) {
state.game.xPaces.push(0);
}
}
const xPacesExpanded = expandPaces(state.game.xPaces);
const yPacesExpanded = expandPaces(state.game.yPaces);
const paces = xPacesExpanded.map((paceX, index) => v2(paceX, yPacesExpanded[index]));
state.game.ship = Entity.createShip();
state.game.shipPaces = paces;
state.game.simLastStep = paces.length - 1;
calculateHistory();
}
const PACE_REGEX = /^-?\d+( -?\d+)*$/;
/**
*
* @param {string} paceLine
* @return {number[]}
*/
function parsePaces(paceLine) {
if (!paceLine) {
setStatusFailure(STATUS_MESSAGE_INVALID_FORMAT_SOLUTION + 'It should have exactly 2 lines.');
return [];
}
if (!PACE_REGEX.test(paceLine)) {
setStatusFailure(STATUS_MESSAGE_INVALID_FORMAT_SOLUTION + 'Wrong pace format');
return [];
}
return paceLine.split(' ').map(pace => Number.parseInt(pace));
}
/**
* Calculate all paces that can legally follow the given pace.
* @param {number} pace
* @return {number[]}
*/
function getValidPaces(pace) {
switch (pace) {
case -MIN_PACE:
return [pace + 1, pace, 0];
case -MAX_PACE:
return [pace, pace - 1];
case 0:
return [-MIN_PACE, pace, MIN_PACE];
case MAX_PACE:
return [pace + 1, pace];
case MIN_PACE:
return [0, pace, pace - 1];
default:
return [pace + 1, pace, pace - 1];
}
}
function calculateHistory() {
state.game.simStep = 0;
state.game.shipHistory = [state.game.ship.copy()];
state.game.simLimit = Math.min(state.game.timeLimit, state.game.shipPaces.length);
for (let simIndex = 0; simIndex <= state.game.simLimit; ++simIndex) {
state.game.ship.v = state.game.shipPaces[clamp(simIndex, 0, state.game.shipPaces.length - 1)];
state.game.ship.move();
state.game.shipHistory.push(state.game.ship.copy());
}
for (let stepIndex = 0; stepIndex <= state.game.shipPaces.length; ++stepIndex) {
if (stepIndex > state.game.simLimit) {
setStatusFailure(STATUS_MESSAGE_EXCEEDED_TIME + `${state.game.shipPaces.length} > ${state.game.simLimit}`);
return;
}
applyStepIndex(stepIndex);
if (hasShipCollided()) {
setStatusFailure(STATUS_MESSAGE_COLLISION + `${stepIndex}/${state.game.simLimit}`);
return;
}
}
const goalDiff = state.game.ship.p.sub(state.game.goal.p);
const shipNotAtGoal = goalDiff.x !== 0 || goalDiff.y !== 0;
if (shipNotAtGoal) {
setStatusFailure(STATUS_MESSAGE_MISSED + `Ship Position (${state.game.ship.p.x},${state.game.ship.p.y}) ≠ Goal Position (${state.game.goal.p.x},${state.game.goal.p.y})`);
simReset();
return;
}
const shipNotInPaceWithGoal = state.game.ship.v.x !== 0 || state.game.ship.v.y !== 0;
if (shipNotInPaceWithGoal) {
setStatusFailure(STATUS_MESSAGE_NOT_STOPPED + `Ship pace (${state.game.ship.v.x},${state.game.ship.v.y}) ≠ (0,0)`);
simReset();
return;
}
setStatusSuccess(STATUS_MESSAGE_SUCCESS);
simReset();
}
/**
*
* @param {string} message
*/
function setStatusSuccess(message) {
spanStatus.textContent = 'SUCCESS';
spanStatus.setAttribute('success', 'true');
spanStatusMessage.textContent = message;
}
/**
*
* @param {string} message
*/
function setStatusFailure(message) {
spanStatus.textContent = 'FAILURE';
spanStatus.setAttribute('success', 'false');
spanStatusMessage.textContent = message;
}
function onInputPaceIndex() {
applyStepIndex(clamp(Number.parseInt(inputStepIndex.value) || 0, 0, state.game.simLimit), true);
}
function onClickButtonReset() {
simReset();
}
function onClickButtonPrevStep() {
simPrevStep();
}
function onClickButtonPlay() {
simPlay();
}
function onClickButtonNextStep() {
simNextStep();
}
function onInputDrawAsteroidArea() {
state.game.drawAsteroidArea = checkboxAsteroidArea.checked;
}
function setCheckboxAsteroidArea(value) {
checkboxAsteroidArea.checked = value;
state.game.drawAsteroidArea = value;
}
function updateControls(skipUpdatingInput = false) {
const stepIndex = state.game.simStep;
if (!skipUpdatingInput) {
inputStepIndex.value = `${stepIndex}`;
}
paceCount.textContent = `${state.game.shipPaces.length}`;
buttonReset.disabled = stepIndex <= 0;
buttonPrevStep.disabled = stepIndex <= 0;
buttonPlay.disabled = state.game.simLimit <= 0;
buttonNextStep.disabled = stepIndex >= state.game.simLimit;
}
/**
* Expand the solution paces.
* @param {Array.<number>} paces - the solution paces.
* @return {Array.<number>} the expanded list of paces.
*/
function expandPaces(paces) {
return paces.flatMap(pace => Array(Math.max(1, Math.abs(pace))).fill(pace));
}
/**
* Cleans up the input string.
* Breaks input string into lines and normalizes them.
* 'Compresses' all adjacent spaces into one whitespace and trims the lines.
* @param {string} value - The input string.
* @returns {Array.<string>} - The normalized lines.
*/
function normalizeInput(value) {
return value.split(/\n/)
.map(line => line
.replace(/\s+/g, ' ')
.trim()
)
.filter(line => line.length > 0);
}
function updateMouseInputState(event) {
state.game.input.mouseIn = true;
const rect = canvas.getBoundingClientRect();
state.game.input.mouseScreenPosition = v2(
event.clientX - rect.left,
event.clientY - rect.top,
);
state.game.input.mouseNormalizedPosition = v2(
mapValue(state.game.input.mouseScreenPosition.x, 0, canvas.width, -1, 1),
mapValue(canvas.height - state.game.input.mouseScreenPosition.y, 0, canvas.height, -1, 1),
);
const ratioW = 0.5 * state.render.c.canvas.width / state.render.uToPx;
const ratioH = 0.5 * state.render.c.canvas.height / state.render.uToPx;
const worldPosition = v2(state.game.input.mouseNormalizedPosition.x * ratioW, state.game.input.mouseNormalizedPosition.y * ratioH);
state.game.mouseGridPosition = worldPosition.add(v2(state.game.camera.x, state.game.camera.y));
state.game.mouseGridPosition.x = Math.round(state.game.mouseGridPosition.x);
state.game.mouseGridPosition.y = Math.round(state.game.mouseGridPosition.y);
}
/**
* Convert a point from world coordinates to screen space coordinates with respect to the camera.
* @param {V2} worldPosition - Point in world coordinates.
* @return {V2} - Point in screen space coordinates translated to the camera location.
*/
function worldToScreenSpace(worldPosition) {
const worldCenteredX = worldPosition.x - state.game.camera.x;
const worldCenteredY = worldPosition.y - state.game.camera.y;
const screenX = Math.floor(state.render.uToPx * worldCenteredX + 0.5 * state.render.w);
const screenY = Math.floor(state.render.h - (state.render.uToPx * worldCenteredY + 0.5 * state.render.h));
return v2(screenX, screenY);
}
/** Class that represents a two-dimensional vector. */
class V2 {
/**
* Create a vector 2.
* @param {number} x - The first coordinate.
* @param {number} y - The second coordinate.
*/
constructor(x = 0, y = 0) {
this.x = x;
this.y = y;
}
/**
* Add the two vectors and return the result as a new vector.
* @param {V2} a - The first vector.
* @param {V2} b - The second vector.
* @return {V2} The sum of the two vectors.
*/
static add(a, b) {
return v2(
a.x + b.x,
a.y + b.y,
);
}
/**
* Add this vector to another and return the result as a new vector.
* @param {V2} v - The vector.
* @return {V2} The sum of the two vectors.
*/
add(v) {
return V2.add(this, v);
}
/**
* Add a vector to this one.
* @param {V2} v - The vector.
*/
addEquals(v) {
this.x += v.x;
this.y += v.y;
}
/**
* Subtract the two vectors and return the result as a new vector.
* @param {V2} a - The first vector.
* @param {V2} b - The second vector.
* @return {V2} The difference between the two vectors.
*/
static sub(a, b) {
return v2(
a.x - b.x,
a.y - b.y,
);
}
/**
* Subtract this vector from another and return the result as a new vector.
* @param {V2} v - The vector.
* @return {V2} The difference between the two vectors.
*/
sub(v) {
return V2.sub(this, v);
}
/**
* Multiply a vector by a scalar and return the result as a new vector.
* @param {V2} v - The vector.
* @param {number} s - The scalar.
* @return {V2} The scaled up vector.
*/
static mul(v, s) {
return v2(
v.x * s,
v.y * s,
);
}
/**
* Multiply this vector by the scalar and return a new vector.
* @param {number} s - The scalar.
* @return {V2} The multiplied vector.
*/
mul(s) {
return V2.mul(this, s);
}
/**
* Multiply this vector by a scalar.
* @param {number} s - The scalar.
*/
mulEquals(s) {
this.x *= s;
this.y *= s;
}
/**
* Divide a vector by a scalar and return the result as a new vector.
* @param {V2} v - The vector.
* @param {number} s - The scalar.
* @return {V2} The divided vector.
*/
static div(v, s) {
return v2(
v.x / s,
v.y / s,
);
}
/**
* Compute the dot product between two vectors.
* @param {V2} a - The first vector.
* @param {V2} b - The second vector.
* @return {number} The dot product.
*/
static dot(a, b) {
return a.x * b.x + a.y * b.y;
}
/**
* Compute the squared length of a vector.
* @param {V2} v - The vector.
* @return {number} The length squared of the vector.
*/
static lengthSquared(v) {
return V2.dot(v, v);
}
/**
* Compute the length of a vector.
* @param {V2} v - The vector.
* @return {number} The length of the vector.
*/
static length(v) {
return Math.sqrt(V2.lengthSquared(v));
}
/**
* Rotate a vector by an angle and return it as a new vector.
* @param {V2} v - The vector.
* @param {number} a - The angle.
* @return {V2} The rotated vector.
*/
static rotate(v, a) {
const cos = Math.cos(a);
const sin = Math.sin(a);
return v2(
v.x * cos - v.y * sin,
v.x * sin + v.y * cos,
);
}
}
/**
* V2 constructor function.
* @param {number} x
* @param {number} y
* @return {V2}
*/
function v2(x = 0, y = 0) {
return new V2(x, y);
}
/**
* V2 copy constructor.
* @param {V2} v - The vector
* @return {V2} The copied vector
*/
function v2c(v) {
return new V2(v.x, v.y);
}
/**
* Clamp value between min and max.
* @param {number} value - Value to clamp.
* @param {number} min - Minimum value.
* @param {number} max - Maximum value.
* @return {number} The clamped value.
*/
function clamp(value, min, max) {
if (value < min) {
return min;
} else if (value > max) {
return max;
}
return value;
}
/**
* Maps the value in the given range (from, to) to (dstFrom, dstTo).
* @param {number} value
* @param {number} from
* @param {number} to
* @param {number} [dstFrom = 0]
* @param {number} [dstTo = 1]
* @return {number}
*/
function mapValue(value, from, to, dstFrom = 0, dstTo = 1) {
const clampedValue = clamp(value, from, to);
const deltaValue = clampedValue - from;
const range = to - from;
return dstFrom + (deltaValue / range) * (dstTo - dstFrom);
}
function secret() {
/* SECRET */ levelInput.value = '3,6 68';
/* SECRET */ levelSolution.value = '0 5 4 4 5 0 -5 -4 -4 -5 0 0 0 0 0 0 0 0 0 0 0 0 0 0 5 4 5 0\n 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 5 4 3 3 4 5 0 0 0 0 0 0 0 0 0 0';
recalculate();
setTimeout(() => {
if (!state.game.simPlaying) {
simPlay();
}
}, 2000);
}
init();
// ── CCC frontend case injection ──────────────────────────────────────────
// The base platform's Sandbox.vue posts { input, solution } for the clicked case into this
// iframe. Fill the textareas and call the visualizer's own recalculate() so the clicked case
// renders (and re-simulates) instead of the baked-in default. Mirrors the desks/drones
// single-editor sandbox injection.
window.addEventListener('message', function (event) {
const data = event.data;
if (!data || typeof data !== 'object') return;
if (data.input === undefined && data.solution === undefined) return;
if (data.input !== undefined && data.input !== null && `${data.input}`.trim().length > 0) {
levelInput.value = `${data.input}`;
}
if (data.solution !== undefined && data.solution !== null) {
levelSolution.value = `${data.solution}`;
}
recalculate();
});
</script>
</html>