Files
PortfolioWebsite/script1.js
T
2026-10-07 10:56:35 +02:00

1665 lines
59 KiB
JavaScript

//Basics
const welcomeScreen = document.getElementById('welcome-screen');
const enterButton = document.getElementById('enter-button');
const snowContainer = document.getElementById('snow-container');
const gameContainer = document.getElementById('game-container');
const canvas = document.getElementById('game-canvas');
const ctx = canvas.getContext('2d');
function drawImageSafe(image, ...args) {
if (!image || !image.complete || image.naturalWidth === 0) return false;
ctx.drawImage(image, ...args);
return true;
}
const projectSection = document.getElementById('projects');
const projectDivs = projectSection.getElementsByClassName('project');
let projects = [];
let detailsScrollPosition = 0;
const detailsScrollStep = 30;
let detailsMaxScroll = 0;
let gameState = "outside";
let menuReturnState = "outside";
let gameStarted = false;
let isEnteringGame = false;
const menuNavigation = {prev: null, next: null};
const interiorBackground = new Image();
interiorBackground.src = "img/house-interior-v2.png";
const MAP_WIDTH = 3000;
const npcDialog = document.getElementById("npc-dialog");
const dialogText = document.getElementById("npc-dialog-text");
const dialogOption1 = document.getElementById("dialog-option-1");
const dialogOption2 = document.getElementById("dialog-option-2");
const dialogOption3 = document.getElementById("dialog-option-3");
const npcDialogClose = document.getElementById("npc-dialog-close");
let menuVisible = false;
const message = {
x: 0,
y: 0,
text: "Press Enter to open the menu!",
visible: false,
};
const houseMessage = {
text: "Press Enter to enter the house!",
visible: false,
x: 0,
y: 0
};
const npcMessage = {
text: "Press E to talk",
visible: false,
x: 0,
y: 0
};
let character = {x: 50, y: -800, width: 50, height: 50, dx: 0, dy: 0, speed: 3, facingRight: false};
let gravity = 0.9;
let isJumping = false;
let floorHeight = canvas.height - 50;
let snowflakes = [];
let snowOnGround = [];
const keyState = {
ArrowLeft: false,
ArrowRight: false,
ArrowUp: false,
d: false,
a: false,
w: false
};
const spriteSheets = {
idle: new Image(),
walk: new Image(),
jump: new Image(),
fall: new Image(),
land: new Image(),
};
spriteSheets.idle.src = './Spritesheets/Idle.png';
spriteSheets.walk.src = './Spritesheets/Walk.png';
spriteSheets.jump.src = './Spritesheets/Jump.png';
spriteSheets.fall.src = './Spritesheets/Fall.png';
spriteSheets.land.src = './Spritesheets/Land.png';
let currentFrame = 0;
let frameTimer = 0;
let characterState = "idle";
const frameWidth = 64;
const frameHeight = 64;
const framesPerState = {
idle: 2,
walk: 6,
jump: 2,
fall: 1,
land: 2,
};
function updateCharacterState() {
let newState;
if (isJumping) {
newState = "jump";
} else if (keyState.ArrowUp && character.dy > 0) {
newState = "fall";
} else if (keyState.ArrowLeft || keyState.ArrowRight) {
newState = "walk";
} else if (keyState.w && character.dy > 0) {
newState = "fall";
} else if (keyState.a || keyState.d) {
newState = "walk";
} else {
newState = "idle";
}
if (newState !== characterState) {
characterState = newState;
currentFrame = 0;
}
}
let camera = {
x: 0,
y: 0,
width: canvas.width,
height: canvas.height,
};
//Trees
const treeImage = new Image();
treeImage.src = "./img/image-tree-1.png";
const treeCount = 10;
const treeWidth = 200;
const treeHeight = 300;
const trees = Array.from({length: treeCount}, () => ({
x: Math.random() * MAP_WIDTH,
y: floorHeight - treeHeight,
width: treeWidth,
height: treeHeight,
}));
window.addEventListener("keydown", (e) => {
if (gameStarted && ["ArrowLeft", "ArrowRight", "ArrowUp", " "].includes(e.key)) e.preventDefault();
if (!gameStarted || isEnteringGame || gameState === "Menu" || !npcDialog.classList.contains("hidden")) return;
if (e.target instanceof Element && e.target.closest('button, a, input, textarea, select')) return;
const key = e.key.toLowerCase();
if (e.key === "ArrowRight" || key === "d") {
keyState[e.key === "ArrowRight" ? "ArrowRight" : "d"] = true;
character.facingRight = false;
}
if (e.key === "ArrowLeft" || key === "a") {
keyState[e.key === "ArrowLeft" ? "ArrowLeft" : "a"] = true;
character.facingRight = true;
}
if (e.key === "ArrowUp" || key === "w" || e.key === " ") {
if (e.key === "ArrowUp") keyState.ArrowUp = true;
if (key === "w") keyState.w = true;
if (!isJumping && !e.repeat) {
character.dy = -15;
isJumping = true;
window.gameAudio.play('jump');
}
}
});
window.addEventListener("keyup", (e) => {
if (e.key === "ArrowRight" || e.key === "ArrowLeft" || e.key === "ArrowUp") keyState[e.key] = false;
const key = e.key.toLowerCase();
if (key === "a" || key === "d" || key === "w") keyState[key] = false;
if (!keyState.ArrowLeft && !keyState.ArrowRight && !keyState.a && !keyState.d) character.dx = 0;
});
window.addEventListener("blur", () => {
Object.keys(keyState).forEach(key => keyState[key] = false);
character.dx = 0;
});
const closeButton = {
width: 30,
height: 30,
color: "rgba(15, 23, 42, 0.9)",
};
const uiTheme = {
font: "'Courier New', monospace",
text: "#102033",
mutedText: "#496071",
panel: "rgba(239, 247, 249, 0.98)",
panelDark: "rgba(12, 22, 36, 0.9)",
accent: "#2f7dd3",
accentWarm: "#f0b84f",
border: "#547389",
shadow: "rgba(9, 24, 43, 0.16)"
};
function syncCanvasTheme() {
if (document.body.classList.contains('dark-mode')) {
Object.assign(uiTheme, {
text: "#eef6ff",
mutedText: "#bdd0e0",
panel: "rgba(31, 47, 63, 0.98)",
panelDark: "#102033",
accent: "#2f7dd3",
border: "#8ba8b9",
shadow: "rgba(0, 8, 20, 0.2)"
});
} else {
Object.assign(uiTheme, {
text: "#102033",
mutedText: "#496071",
panel: "rgba(239, 247, 249, 0.98)",
panelDark: "rgba(12, 22, 36, 0.9)",
accent: "#2f7dd3",
border: "#547389",
shadow: "rgba(9, 24, 43, 0.16)"
});
}
}
window.addEventListener('themechange', syncCanvasTheme);
function viewportWidth() {
return camera.width || window.innerWidth;
}
function viewportHeight() {
return camera.height || window.innerHeight;
}
function getInteriorLayout() {
const width = viewportWidth();
const height = viewportHeight();
const paddingX = Math.min(100, width * 0.06);
const paddingY = Math.min(28, height * 0.04);
const originalWidth = interiorBackground.width || 1920;
const originalHeight = interiorBackground.height || 1080;
const scale = Math.min(
(width - paddingX * 2) / originalWidth,
(height - paddingY * 2) / originalHeight
);
const scaledWidth = originalWidth * scale;
const scaledHeight = originalHeight * scale;
return {
x: (width - scaledWidth) / 2,
y: (height - scaledHeight) / 2,
width: scaledWidth,
height: scaledHeight,
floorY: (height - scaledHeight) / 2 + scaledHeight - 58
};
}
function interiorCharacterScale(room) {
return Math.min(3, room.height * 0.68 / frameHeight);
}
function interiorNpcScale(room) {
return Math.min(1.5, room.height * 0.68 / NPC.height);
}
function currentFloorHeight() {
return gameState === "inside" ? getInteriorLayout().floorY : floorHeight;
}
function roundedRectPath(x, y, width, height, radius) {
const r = Math.min(radius, 4, width / 2, height / 2);
ctx.beginPath();
ctx.moveTo(x + r, y);
ctx.arcTo(x + width, y, x + width, y + height, r);
ctx.arcTo(x + width, y + height, x, y + height, r);
ctx.arcTo(x, y + height, x, y, r);
ctx.arcTo(x, y, x + width, y, r);
ctx.closePath();
}
function fillRoundedRect(x, y, width, height, radius, fillStyle) {
roundedRectPath(x, y, width, height, radius);
ctx.fillStyle = fillStyle;
ctx.fill();
}
function strokeRoundedRect(x, y, width, height, radius, strokeStyle, lineWidth = 1) {
roundedRectPath(x, y, width, height, radius);
ctx.strokeStyle = strokeStyle;
ctx.lineWidth = lineWidth;
ctx.stroke();
}
function drawPanel(x, y, width, height, radius = 24, fillStyle = uiTheme.panel) {
ctx.save();
fillRoundedRect(x, y, width, height, radius, fillStyle);
strokeRoundedRect(x, y, width, height, radius, uiTheme.border, 2);
ctx.restore();
}
function drawButton(x, y, width, height, label, options = {}) {
const fill = options.fill || uiTheme.panelDark;
const textColor = options.textColor || "#ffffff";
const radius = options.radius || 16;
ctx.save();
fillRoundedRect(x, y, width, height, radius, fill);
strokeRoundedRect(x, y, width, height, radius, options.border || "#8ba8b9", 1.5);
ctx.fillStyle = textColor;
ctx.font = `${options.size || 16}px ${uiTheme.font}`;
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.fillText(label, x + width / 2, y + height / 2);
ctx.restore();
}
function drawPrompt(text, x, y) {
ctx.save();
ctx.font = `700 18px ${uiTheme.font}`;
ctx.textAlign = "center";
ctx.textBaseline = "middle";
const paddingX = 18;
const width = ctx.measureText(text).width + paddingX * 2;
const height = 42;
drawPanel(x - width / 2, y - height / 2, width, height, 18, "rgba(8, 19, 34, 0.82)");
ctx.fillStyle = "#ffffff";
ctx.fillText(text, x, y);
ctx.restore();
}
function drawCharacter(deltaTime) {
frameTimer += deltaTime;
const frameDurationForState = 1000 / framesPerState[characterState];
if (frameTimer >= frameDurationForState) {
frameTimer = 0;
currentFrame = (currentFrame + 1) % framesPerState[characterState];
}
// Draw the character
const spriteSheet = spriteSheets[characterState];
const frameX = currentFrame * frameWidth;
const scaledWidth = frameWidth * 1.5;
const scaledHeight = frameHeight * 1.5;
if (character.facingRight) {
drawImageSafe(
spriteSheet,
frameX, 0, frameWidth, frameHeight,
character.x - camera.x, character.y, scaledWidth, scaledHeight
);
} else {
ctx.save();
ctx.scale(-1, 1);
drawImageSafe(
spriteSheet,
frameX, 0, frameWidth, frameHeight,
-(character.x - camera.x + scaledWidth), character.y, scaledWidth, scaledHeight
);
ctx.restore();
}
}
function drawCharacterBig(deltaTime) {
frameTimer += deltaTime;
if (frameTimer >= 1000 / framesPerState[characterState]) {
frameTimer = 0;
currentFrame = (currentFrame + 1) % framesPerState[characterState];
}
const spriteSheet = spriteSheets[characterState];
const frameX = currentFrame * frameWidth;
const scale = interiorCharacterScale(getInteriorLayout());
const scaledWidth = frameWidth * scale;
const scaledHeight = frameHeight * scale;
const drawY = character.y + character.height - scaledHeight;
if (character.facingRight) {
drawImageSafe(
spriteSheet,
frameX, 0, frameWidth, frameHeight,
character.x - camera.x, drawY, scaledWidth, scaledHeight
);
} else {
ctx.save();
ctx.scale(-1, 1);
drawImageSafe(
spriteSheet,
frameX, 0, frameWidth, frameHeight,
-(character.x - camera.x + scaledWidth), drawY, scaledWidth, scaledHeight
);
ctx.restore();
}
}
function handlePhysics(deltaTime) {
const activeFloorHeight = currentFloorHeight();
const wasJumping = isJumping;
character.dy += gravity * (deltaTime / frameDuration);
character.y += character.dy * (deltaTime / frameDuration);
if (character.y + character.height > activeFloorHeight) {
character.y = activeFloorHeight - character.height;
character.dy = 0;
isJumping = false;
if (wasJumping) window.gameAudio.play('land');
}
const direction = Number(keyState.ArrowRight || keyState.d) - Number(keyState.ArrowLeft || keyState.a);
character.dx = direction * character.speed * (deltaTime / frameDuration);
if (direction) character.facingRight = direction < 0;
}
//Basic Movement
function moveCharacter() {
if (gameState === "inside") {
const room = getInteriorLayout();
const minX = room.x + 12;
const maxX = Math.max(minX, room.x + room.width - frameWidth * interiorCharacterScale(room) - 12);
character.x = Math.max(minX, Math.min(maxX, character.x + character.dx));
} else {
character.x = Math.max(0, Math.min(MAP_WIDTH - character.width, character.x + character.dx));
}
}
function updateCamera() {
camera.x = character.x - camera.width / 2;
if (camera.x < 0) camera.x = 0;
if (camera.x + camera.width > MAP_WIDTH) camera.x = Math.max(0, MAP_WIDTH - camera.width);
}
function drawScenery() {
drawDistantHills();
trees.forEach((tree) => {
ctx.save();
ctx.globalAlpha = 0.18;
fillRoundedRect(tree.x - camera.x + tree.width * 0.2, floorHeight - 18, tree.width * 0.6, 18, 50, "#17324a");
ctx.restore();
drawImageSafe(
treeImage,
tree.x - camera.x,
tree.y,
tree.width,
tree.height
);
});
}
function drawSky() {
const width = viewportWidth();
const height = viewportHeight();
const isDark = document.body.classList.contains('dark-mode');
const sky = ctx.createLinearGradient(0, 0, 0, height);
if (isDark) {
sky.addColorStop(0, "#111827");
sky.addColorStop(0.55, "#20324d");
sky.addColorStop(1, "#40546b");
} else {
sky.addColorStop(0, "#8fc9f2");
sky.addColorStop(0.55, "#c5e8fb");
sky.addColorStop(1, "#eef8ff");
}
ctx.fillStyle = sky;
ctx.fillRect(0, 0, width, height);
ctx.save();
ctx.globalAlpha = isDark ? 0.2 : 0.38;
const sunGradient = ctx.createRadialGradient(width * 0.78, height * 0.16, 8, width * 0.78, height * 0.16, 180);
sunGradient.addColorStop(0, isDark ? "#dbeafe" : "#fff9cf");
sunGradient.addColorStop(1, "rgba(255,255,255,0)");
ctx.fillStyle = sunGradient;
ctx.fillRect(0, 0, width, height);
ctx.restore();
}
function drawDistantHills() {
const parallaxX = camera.x * 0.18;
ctx.save();
ctx.globalAlpha = 0.28;
ctx.fillStyle = document.body.classList.contains('dark-mode') ? "#23364f" : "#7db1cf";
ctx.beginPath();
ctx.moveTo(-parallaxX - 100, floorHeight);
ctx.quadraticCurveTo(260 - parallaxX, floorHeight - 170, 650 - parallaxX, floorHeight - 70);
ctx.quadraticCurveTo(1040 - parallaxX, floorHeight - 230, 1460 - parallaxX, floorHeight - 90);
ctx.quadraticCurveTo(1960 - parallaxX, floorHeight - 220, 2460 - parallaxX, floorHeight - 75);
ctx.quadraticCurveTo(2850 - parallaxX, floorHeight - 170, MAP_WIDTH + 200 - parallaxX, floorHeight - 110);
ctx.lineTo(MAP_WIDTH + 200, floorHeight);
ctx.closePath();
ctx.fill();
ctx.restore();
}
//Spawn Snowflakes
let snowSpawnTime = 0;
function createSnowflakes(deltaTime) {
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;
snowSpawnTime = Math.min(snowSpawnTime + deltaTime, 140);
while (snowSpawnTime >= 140 && snowflakes.length < 140) {
snowSpawnTime -= 140;
snowflakes.push({
x: Math.random() * MAP_WIDTH,
y: -10,
radius: Math.random() * 3 + 1,
speed: Math.random() + 0.5,
});
}
}
function drawSnowflakes(deltaTime) {
ctx.save();
ctx.beginPath();
snowflakes = snowflakes.filter((snowflake) => {
const isVisible =
snowflake.x >= camera.x - 50 &&
snowflake.x <= camera.x + viewportWidth() + 50;
if (isVisible) {
ctx.moveTo(snowflake.x - camera.x, snowflake.y);
ctx.arc(
snowflake.x - camera.x,
snowflake.y,
snowflake.radius,
0,
Math.PI * 2
);
}
snowflake.y += snowflake.speed * (deltaTime / frameDuration);
if (snowflake.y + snowflake.radius >= floorHeight) {
if (snowOnGround.length < 240) {
snowOnGround.push({
x: snowflake.x,
y: floorHeight - snowflake.radius,
radius: snowflake.radius,
});
}
return false;
}
return true;
});
ctx.fillStyle = "white";
ctx.fill();
ctx.restore();
ctx.save();
ctx.fillStyle = "white";
ctx.beginPath();
snowOnGround.forEach((snowflake) => {
const isVisible =
snowflake.x >= camera.x - 50 &&
snowflake.x <= camera.x + viewportWidth() + 50;
if (isVisible) {
ctx.moveTo(snowflake.x - camera.x, snowflake.y);
ctx.arc(
snowflake.x - camera.x,
snowflake.y,
snowflake.radius,
0,
Math.PI * 2
);
}
});
ctx.fill();
ctx.restore();
}
function drawFloor() {
const height = viewportHeight();
const ground = ctx.createLinearGradient(0, floorHeight, 0, height);
if (document.body.classList.contains('dark-mode')) {
ground.addColorStop(0, "#d9e8f7");
ground.addColorStop(1, "#7c92aa");
} else {
ground.addColorStop(0, "#f8fcff");
ground.addColorStop(1, "#c8e3f7");
}
ctx.fillStyle = ground;
ctx.fillRect(0 - camera.x, floorHeight, MAP_WIDTH, height - floorHeight);
ctx.save();
ctx.globalAlpha = 0.35;
ctx.fillStyle = "#ffffff";
ctx.fillRect(0 - camera.x, floorHeight, MAP_WIDTH, 8);
ctx.restore();
}
let lastFrameTime = performance.now(); // Tracks the time of the last frame
const targetFrameRate = 60; // Target frame rate
const frameDuration = 1000 / targetFrameRate; // Duration of one frame in milliseconds
let deltaTime = 0; // Time elapsed since the last frame
function update(currentTime) {
const elapsed = currentTime - lastFrameTime;
if (elapsed >= frameDuration) {
deltaTime = Math.min(elapsed, 50);
ctx.clearRect(0, 0, canvas.width, canvas.height);
if (gameState === "outside") {
drawSky();
createSnowflakes(deltaTime);
drawScenery();
drawHouse();
drawSnowflakes(deltaTime);
handlePhysics(deltaTime);
moveCharacter(deltaTime);
handleQuestionBlockCollision();
handleHouseInteraction();
drawFloor();
drawQuestionBlock();
drawParticles(deltaTime);
updateCamera();
drawQuestBoard();
handleQuestBoardInteraction();
drawCharacter(deltaTime);
drawMenu();
updateCharacterState();
drawHouseMessage();
} else if (gameState === "inside") {
handlePhysics(deltaTime);
drawInterior();
drawNPC();
handleNPCProximity();
drawCharacterBig(deltaTime);
drawNPCMessage();
moveCharacter(deltaTime);
updateCharacterState();
} else if (gameState === "Menu") {
if (menuReturnState === "inside") {
drawInterior();
} else {
drawSky();
drawDistantHills();
drawFloor();
}
drawMenu();
}
lastFrameTime = currentTime - (elapsed % frameDuration);
}
requestAnimationFrame(update);
}
function drawInterior() {
const width = viewportWidth();
const height = viewportHeight();
const room = getInteriorLayout();
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.fillStyle = "#17243a";
ctx.fillRect(0, 0, width, height);
ctx.save();
ctx.imageSmoothingEnabled = false;
drawImageSafe(interiorBackground, room.x, room.y, room.width, room.height);
ctx.restore();
}
function initSnow() {
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;
for (let i = 0; i < 30; i++) {
let snowflake = document.createElement("div");
snowflake.classList.add("snowflake");
snowflake.style.left = Math.random() * 100 + "vw";
snowflake.style.animationDuration = Math.random() * 3 + 4 + "s";
snowflake.style.animationDelay = Math.random() * 5 + "s";
snowContainer.appendChild(snowflake);
}
}
function startGame() {
if (gameStarted) return;
gameStarted = true;
isEnteringGame = true;
enterButton.disabled = true;
window.gameAudio.play('start');
gameContainer.style.display = "block";
resizeCanvas();
preloadProjectImages();
initSnow();
lastFrameTime = performance.now();
requestAnimationFrame(update);
let transitionDone = false;
function finishTransition() {
if (transitionDone) return;
transitionDone = true;
welcomeScreen.removeEventListener('transitionend', onTransitionEnd);
gameContainer.classList.add('visible');
welcomeScreen.style.display = "none";
document.body.classList.add("game-started");
isEnteringGame = false;
canvas.focus({preventScroll: true});
}
function onTransitionEnd(event) {
if (event.target === welcomeScreen && event.propertyName === 'opacity') finishTransition();
}
welcomeScreen.addEventListener('transitionend', onTransitionEnd);
requestAnimationFrame(() => requestAnimationFrame(() => {
gameContainer.classList.add('visible');
welcomeScreen.classList.add('hidden');
}));
setTimeout(finishTransition, 850);
}
enterButton.addEventListener("click", startGame);
window.addEventListener("resize", () => {
if (gameStarted) {
resizeCanvas();
}
});
document.addEventListener("visibilitychange", () => {
lastFrameTime = performance.now();
});
function resizeCanvas() {
const width = gameContainer.clientWidth || window.innerWidth;
const height = gameContainer.clientHeight || window.innerHeight;
const dpr = Math.min(window.devicePixelRatio || 1, 2);
canvas.width = Math.round(width * dpr);
canvas.height = Math.round(height * dpr);
canvas.style.width = width + "px";
canvas.style.height = height + "px";
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
camera.width = width;
camera.height = height;
floorHeight = height - 50;
// Update outdoor objects based on new floor height
trees.forEach(tree => tree.y = floorHeight - tree.height);
questionBlock.y = floorHeight - 200;
questionBlock.originalY = floorHeight - 200;
house.y = floorHeight - 390;
questBoard.y = floorHeight - 200;
// Update house interior elements only when inside
if (gameState === "inside") {
const room = getInteriorLayout();
NPC.y = room.floorY - NPC.height * interiorNpcScale(room);
character.y = Math.min(character.y, room.floorY - character.height);
}
}
const questionBlock = {
x: 300,
y: floorHeight - 200,
width: 50,
height: 50,
originalY: floorHeight - 200,
activated: false,
bounceOffset: 0,
inactiveTexture: 'info-box-1-inactive.png',
activeTexture: 'info-box-1-active.png',
texture: null,
};
const questBoard = {
x: 2500,
y: floorHeight - 200,
width: 200,
height: 200,
texture: null
}
const NPC = {
x: 100,
y: canvas.height - 280,
width: 100,
height: 200,
texture: null
}
const house = {
x: 1800,
y: floorHeight - 390,
width: 440,
height: 400,
texture: null,
};
const NPCImage = new Image();
NPCImage.src = 'img/me.png';
NPC.texture = NPCImage
const houseImage = new Image();
houseImage.src = 'img/House.png';
house.texture = houseImage;
const inactiveImage = new Image();
inactiveImage.src = 'img/info-box-1-inactive.png';
const activeImage = new Image();
activeImage.src = 'img/info-box-1-active.png';
questionBlock.texture = inactiveImage;
const questBoardImage = new Image();
questBoardImage.src = 'img/quest-board-1.png'
questBoard.texture = questBoardImage
let particles = [];
function drawNPC() {
const scale = interiorNpcScale(getInteriorLayout());
const drawWidth = NPC.width * scale;
const drawHeight = NPC.height * scale;
drawImageSafe(
NPC.texture,
NPC.x,
NPC.y,
drawWidth,
drawHeight
);
}
function handleNPCProximity() {
if (gameState !== "inside") {
npcMessage.visible = false;
return;
}
const npcScale = interiorNpcScale(getInteriorLayout());
const npcDrawWidth = NPC.width * npcScale;
const npcDrawHeight = NPC.height * npcScale;
const talkRadius = 60;
const nearNPC =
character.x + character.width > NPC.x - talkRadius &&
character.x < NPC.x + npcDrawWidth + talkRadius &&
character.y + character.height > NPC.y - talkRadius &&
character.y < NPC.y + npcDrawHeight + talkRadius;
if (nearNPC) {
npcMessage.visible = true;
npcMessage.x = NPC.x + npcDrawWidth / 2;
npcMessage.y = NPC.y - 20;
} else {
npcMessage.visible = false;
}
}
function drawNPCMessage() {
if (!npcMessage.visible) return;
drawPrompt(npcMessage.text, npcMessage.x, npcMessage.y - 20);
}
window.addEventListener("keydown", (e) => {
if ((e.key === "e" || e.key === "E") && !e.repeat) {
if (gameState === "inside" && npcMessage.visible) {
window.gameAudio.play('talk');
npcDialog.classList.remove("hidden");
npcMessage.visible = false;
npcMessage.text = "Press ESC to stop talking";
Object.keys(keyState).forEach(key => keyState[key] = false);
character.dx = 0;
npcDialogClose.focus();
}
}
});
dialogOption1.addEventListener("click", () => {
window.gameAudio.play('select');
dialogText.textContent = "I am Frederik Spirgi, a 17-year-old learning web-developer living in switzerland. I started programming at the age of 10 (via scratch), then later with python via a 'raspberry pi', where I coded some simple games. Additionally, due to my father being an informatician, I got to know more about computers from a very young age. At the age of 16 I started the IMS (Basically a highschool where you learn Computer Science) at the KSH in Zurich, where I'm currently in the fourth semester.";
});
dialogOption3.addEventListener("click", () => {
window.gameAudio.play('select');
dialogText.textContent = "Initially, I wanted to create a normal portfolio website, which almost everyone has. However, I noticed that every website I visited to gather ideas just didn't really feel engaging. Since my friend had made a 2d platformer a while back, I took that as inspiration to make this website more 'interactive'.";
});
dialogOption2.addEventListener("click", () => {
window.gameAudio.play('select');
dialogText.textContent = "This Website uses, besides HTML and CSS for the text like this, a BUNCH of Javascript (1000+ lines). The entire game is drawn in a canvas element, whilst the text appears in boxes like this one or is drawn onto the canvas directly. All textures are drawn by me, except the penguin, which is made by duckhive on itch.io (check him out :)";
});
document.addEventListener("click", (event) => {
if (!npcDialog.classList.contains("hidden")) {
const isClickInside = event.target.closest("#npc-dialog-content");
if (!isClickInside) closeNpcDialog();
}
});
document.addEventListener("keydown", (event) => {
if (event.key === "Escape" && !npcDialog.classList.contains("hidden")) {
closeNpcDialog();
}
});
function createParticles(x, y) {
for (let i = 0; i < 10; i++) {
particles.push({
x,
y,
dx: (Math.random() - 0.5) * 4,
dy: Math.random() * -4 - 2,
radius: Math.random() * 2 + 1,
life: 60,
});
}
}
function drawParticles() {
particles = particles.filter((particle) => particle.life > 0);
particles.forEach((particle) => {
ctx.beginPath();
ctx.arc(particle.x - camera.x, particle.y, particle.radius, 0, Math.PI * 2);
ctx.fillStyle = "gold";
ctx.fill();
particle.x += particle.dx;
particle.y += particle.dy;
particle.dy += gravity * 0.2;
particle.life--;
});
}
function drawQuestionBlock() {
const texture = questionBlock.activated ? activeImage : inactiveImage;
drawImageSafe(
texture,
questionBlock.x - camera.x,
questionBlock.y + questionBlock.bounceOffset,
questionBlock.width,
questionBlock.height
);
}
function drawQuestBoard() {
const texture = questBoard.texture;
drawImageSafe(
texture,
questBoard.x - camera.x,
questBoard.y,
questBoard.width,
questBoard.height
);
}
function drawHouse() {
drawImageSafe(
house.texture,
house.x - camera.x,
house.y,
house.width,
house.height
);
}
function handleQuestionBlockCollision() {
const bottomCollision =
character.x + character.width > questionBlock.x &&
character.x < questionBlock.x + questionBlock.width &&
character.y <= questionBlock.y + questionBlock.bounceOffset + questionBlock.height &&
character.y + character.height + character.dy >= questionBlock.y + questionBlock.bounceOffset;
if (bottomCollision && !questionBlock.activated) {
questionBlock.activated = true;
window.gameAudio.play('select');
createParticles(
questionBlock.x + questionBlock.width / 2,
questionBlock.y + questionBlock.height / 2
);
if (questionBlock.bounceOffset === 0) {
questionBlock.bounceOffset = -10;
setTimeout(() => (questionBlock.bounceOffset = 0), 150);
}
character.dy = 0;
}
if (bottomCollision) {
character.y = questionBlock.y + questionBlock.bounceOffset - character.height;
character.dy = 0;
}
const topCollision =
character.x + character.width > questionBlock.x &&
character.x < questionBlock.x + questionBlock.width &&
character.y + character.height <= questionBlock.y + questionBlock.bounceOffset &&
character.y + character.height + character.dy >= questionBlock.y + questionBlock.bounceOffset &&
character.dy >= 0;
if (topCollision) {
character.y = questionBlock.y + questionBlock.bounceOffset + questionBlock.height;
character.dy = 0;
}
const sideCollision =
character.x + character.width > questionBlock.x &&
character.x < questionBlock.x + questionBlock.width &&
character.y + character.height > questionBlock.y + questionBlock.bounceOffset &&
character.y < questionBlock.y + questionBlock.height + questionBlock.bounceOffset;
if (sideCollision) {
if (character.dx > 0) {
character.x = questionBlock.x - character.width;
} else if (character.dx < 0) {
character.x = questionBlock.x + questionBlock.width;
}
character.dx = 0;
if (character.y + character.height > questionBlock.y + questionBlock.bounceOffset + questionBlock.height) {
character.y = questionBlock.y + questionBlock.bounceOffset + questionBlock.height;
}
}
const blockInfo = document.getElementById('block-info');
const showInfo = questionBlock.activated && Math.abs(character.x - questionBlock.x) < 260;
blockInfo.classList.toggle('hidden', !showInfo);
if (showInfo) {
const infoWidth = blockInfo.offsetWidth || 300;
const infoHeight = blockInfo.offsetHeight || 170;
const left = questionBlock.x - camera.x + questionBlock.width / 2 - infoWidth / 2;
blockInfo.style.left = `${Math.max(15, Math.min(viewportWidth() - infoWidth - 15, left))}px`;
blockInfo.style.top = `${Math.max(75, Math.min(viewportHeight() - infoHeight - 80, questionBlock.y - 300))}px`;
}
}
function openProjectMenu() {
if (!gameStarted || gameState === "Menu") return;
window.gameAudio.play('menu');
menuReturnState = gameState;
gameState = "Menu";
menuVisible = true;
selectedProject = null;
message.visible = false;
closeNpcDialog();
Object.keys(keyState).forEach(key => keyState[key] = false);
character.dx = 0;
document.body.classList.add("menu-open");
canvas.focus({preventScroll: true});
drawMenu();
}
function closeProjectMenu() {
window.gameAudio.play('back');
selectedProject = null;
menuVisible = false;
gameState = menuReturnState;
detailsScrollPosition = 0;
document.body.classList.remove("menu-open");
canvas.focus({preventScroll: true});
}
window.addEventListener("keydown", (e) => {
if (!gameStarted) return;
if (e.key === "Escape" && gameState === "Menu") {
if (selectedProject) closeDetailsMenu();
else closeProjectMenu();
return;
}
if (gameState === "Menu" && !selectedProject) {
if (e.key === "ArrowLeft" || e.key === "ArrowRight") {
e.preventDefault();
handlePageNavigation(e.key === "ArrowLeft" ? -1 : 1);
drawMenu();
}
if (e.key === "1" || e.key === "2") {
const project = getProjectsForCurrentPage()[Number(e.key) - 1];
if (project) openDetailsMenu(project);
}
return;
}
if (gameState === "Menu" && selectedProject && e.key === "Enter" && !e.repeat) {
if (e.target === canvas && selectedProject.links[0]) {
window.gameAudio.play('select');
window.open(selectedProject.links[0].url, "_blank", "noopener,noreferrer");
}
return;
}
if (e.key === "Enter" && !e.repeat && gameState === "outside" && message.visible && e.target === canvas) openProjectMenu();
});
function handleQuestBoardInteraction() {
const nearQuestBoard =
character.x + character.width > questBoard.x - 50 &&
character.x < questBoard.x + questBoard.width + 50 &&
character.y + character.height > questBoard.y - 50 &&
character.y < questBoard.y + questBoard.height + 50;
if (nearQuestBoard && !menuVisible) {
message.visible = true;
} else if (!nearQuestBoard) {
message.visible = false;
}
}
for (let i = 0; i < projectDivs.length; i++) {
const projectDiv = projectDivs[i];
const title = projectDiv.querySelector('h3').textContent;
const description = projectDiv.querySelector('p').textContent;
const imageElement = projectDiv.querySelector('img');
const imageSrc = imageElement ? imageElement.src : '';
const languageElement = projectDiv.querySelector('.tags');
const language = languageElement ? languageElement.textContent.trim() : '';
projects.push({name: title, description: description, image: imageSrc, language: language});
}
let currentPage = 0;
let projectsPerPage = window.innerWidth < 768 ? 1 : 2;
let projectFontSize = window.innerWidth < 768 ? 14 : 18;
window.addEventListener("resize", () => {
const isMobile = window.innerWidth < 768;
projectsPerPage = isMobile ? 1 : 2; // Update projects per page
projectFontSize = isMobile ? 14 : 18; // Update font size
currentPage = 0; // Reset to the first page
drawMenu(); // Redraw the menu
});
function getProjectsForCurrentPage() {
const startIndex = currentPage * projectsPerPage;
return projects.slice(startIndex, startIndex + projectsPerPage);
}
function handlePageNavigation(direction) {
const maxPage = Math.ceil(projects.length / projectsPerPage) - 1;
const nextPage = Math.max(0, Math.min(currentPage + direction, maxPage));
if (nextPage !== currentPage) window.gameAudio.play('page');
currentPage = nextPage;
}
function preloadProjectImages() {
projects.forEach(project => {
if (project.image) {
const img = new Image();
img.src = project.image;
img.onload = () => {
project.loadedImage = img; // Store the loaded image
};
}
});
}
let selectedProject = null;
function wrapText(text, maxWidth) {
const lines = [];
let line = "";
for (let word of text.split(/\s+/)) {
const candidate = line ? `${line} ${word}` : word;
if (ctx.measureText(candidate).width <= maxWidth) {
line = candidate;
continue;
}
if (line) {
lines.push(line);
line = "";
}
while (ctx.measureText(word).width > maxWidth) {
let split = 1;
while (split < word.length && ctx.measureText(word.slice(0, split + 1)).width <= maxWidth) split++;
lines.push(word.slice(0, split));
word = word.slice(split);
}
line = word;
}
if (line) lines.push(line);
return lines.length ? lines : [""];
}
function toggleHouse() {
if (gameState === "outside" || gameState === "inside") window.gameAudio.play('door');
if (gameState === "outside") {
const width = viewportWidth();
const height = viewportHeight();
const room = getInteriorLayout();
gameState = "inside";
document.getElementById('block-info').classList.add('hidden');
message.visible = false;
houseMessage.visible = false;
snowContainer.classList.add('paused');
removeSnowflakes();
ctx.save();
ctx.filter = "blur(3px)";
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.restore();
ctx.fillStyle = "rgba(0, 0, 0, 0.5)";
ctx.fillRect(0, 0, width, height);
camera.x = 0;
camera.y = 0;
const minX = room.x + 12;
const maxX = Math.max(minX, room.x + room.width - frameWidth * interiorCharacterScale(room) - 12);
character.x = Math.min(maxX, Math.max(minX, room.x + room.width * 0.58));
character.y = room.floorY - character.height;
character.dy = 0;
isJumping = false;
NPC.x = room.x + Math.max(20, room.width * 0.12);
NPC.y = room.floorY - NPC.height * interiorNpcScale(room);
drawImageSafe(interiorBackground, room.x, room.y, room.width, room.height);
const gameStateChangeEvent = new Event("gameStateChange");
window.dispatchEvent(gameStateChangeEvent);
} else if (gameState === "inside") {
gameState = "outside";
snowContainer.classList.remove('paused');
closeNpcDialog();
npcMessage.visible = false;
npcMessage.text = "Press E to talk";
initSnow();
ctx.clearRect(0, 0, canvas.width, canvas.height);
character.x = 1975;
character.y = floorHeight - character.height;
character.dy = 0;
isJumping = false;
updateCamera();
const gameStateChangeEvent = new Event("gameStateChange");
window.dispatchEvent(gameStateChangeEvent);
}
}
function removeSnowflakes() {
while (snowContainer.firstChild) {
snowContainer.removeChild(snowContainer.firstChild);
}
}
function handleHouseInteraction() {
const nearHouse =
character.x + character.width > house.x - 50 &&
character.x < house.x + house.width + 50 &&
character.y + character.height > house.y - 50 &&
character.y < house.y + house.height + 50;
if (nearHouse) {
houseMessage.visible = true;
houseMessage.x = (house.x + house.width / 2) - camera.x;
houseMessage.y = house.y - 40;
} else {
houseMessage.visible = false;
}
}
window.addEventListener("keydown", (e) => {
if (e.key === "Enter" && !e.repeat && gameStarted && gameState !== "Menu") {
if (e.target instanceof Element && e.target.closest("button, a, input, textarea, #npc-dialog")) return;
if (gameState === "outside") {
const nearHouse =
character.x + character.width > house.x - 50 &&
character.x < house.x + house.width + 50 &&
character.y + character.height > house.y - 50 &&
character.y < house.y + house.height + 50;
if (nearHouse) {
houseMessage.visible = true;
toggleHouse();
} else {
houseMessage.visible = false;
}
} else if (gameState === "inside") {
houseMessage.visible = false;
toggleHouse();
}
}
});
function drawHouseMessage() {
if (!houseMessage.visible) return;
drawPrompt(houseMessage.text, houseMessage.x, houseMessage.y - 20);
}
function drawMenu() {
const width = viewportWidth();
const height = viewportHeight();
if (menuVisible && !selectedProject) {
ctx.fillStyle = "rgba(8, 17, 31, 0.58)";
ctx.fillRect(0, 0, width, height);
const isMobile = width <= 768;
const horizontalMargin = isMobile ? 18 : 90;
const menuX = horizontalMargin;
const menuY = isMobile ? 24 : 42;
const menuWidth = width - horizontalMargin * 2;
const menuHeight = height - menuY * 2;
drawPanel(menuX, menuY, menuWidth, menuHeight, 30, uiTheme.panel);
ctx.save();
ctx.fillStyle = uiTheme.text;
ctx.font = `700 ${isMobile ? 22 : 30}px ${uiTheme.font}`;
ctx.textAlign = "left";
ctx.textBaseline = "top";
ctx.fillText("Projects", menuX + 28, menuY + 24);
ctx.font = `${isMobile ? 13 : 15}px ${uiTheme.font}`;
ctx.fillStyle = uiTheme.mutedText;
ctx.fillText(isMobile ? `${projects.length} projects` : `${projects.length} projects · choose one to explore`, menuX + 30, menuY + (isMobile ? 56 : 64));
ctx.restore();
closeButton.width = 42;
closeButton.height = 42;
closeButton.x = menuX + menuWidth - closeButton.width - 18;
closeButton.y = menuY + 18;
drawButton(closeButton.x, closeButton.y, closeButton.width, closeButton.height, "X", {
fill: "rgba(15, 23, 42, 0.9)",
radius: 14,
size: 18
});
const columns = projectsPerPage === 1 ? 1 : 2;
const compact = height < 620;
const topOffset = compact ? 86 : (isMobile ? 96 : 116);
const navSpace = compact ? 70 : 78;
const cardGap = isMobile ? 16 : 26;
const projectWidth = (menuWidth - 56 - (columns - 1) * cardGap) / columns;
const projectHeight = menuHeight - topOffset - navSpace;
const projectsToDisplay = getProjectsForCurrentPage();
for (let i = 0; i < projectsToDisplay.length; i++) {
const project = projectsToDisplay[i];
const row = Math.floor(i / columns);
const col = i % columns;
const projectBoxX = menuX + 28 + col * (projectWidth + cardGap);
const projectBoxY = menuY + topOffset + row * (projectHeight + cardGap);
drawPanel(projectBoxX, projectBoxY, projectWidth, projectHeight, 24,
document.body.classList.contains('dark-mode') ? "rgba(34, 57, 81, 0.95)" : "rgba(255, 255, 255, 0.82)");
fillRoundedRect(projectBoxX + 18, projectBoxY + 18, projectWidth - 36, 5, 0, uiTheme.accent);
ctx.fillStyle = uiTheme.text;
const cardFontSize = compact ? Math.max(12, projectFontSize - 2) : projectFontSize;
ctx.font = `700 ${cardFontSize + 8}px ${uiTheme.font}`;
ctx.textAlign = "left";
ctx.textBaseline = "alphabetic";
const textX = projectBoxX + 24;
const textY = projectBoxY + 58;
const lines = wrapText(project.name, projectWidth - 48);
let lineHeight = cardFontSize + 12;
lines.forEach((line, index) => {
ctx.fillText(line, textX, textY + index * lineHeight);
});
ctx.fillStyle = uiTheme.mutedText;
ctx.font = `${cardFontSize}px ${uiTheme.font}`;
const descriptionLines = wrapText(project.description, projectWidth - 48).slice(0, compact ? 1 : (isMobile ? 3 : 4));
let descriptionY = textY + lines.length * lineHeight + 16;
descriptionLines.forEach((line, index) => {
ctx.fillText(line, textX, descriptionY + index * (cardFontSize + 7));
});
const fixedImageWidth = projectWidth - 48;
const fixedImageHeight = Math.max(120, projectHeight * 0.34);
const imageX = projectBoxX + 24;
const imageY = projectBoxY + projectHeight - fixedImageHeight - 88;
if (project.loadedImage) {
ctx.imageSmoothingEnabled = true;
ctx.imageSmoothingQuality = "high";
ctx.save();
roundedRectPath(imageX, imageY, fixedImageWidth, fixedImageHeight, 18);
ctx.clip();
drawImageSafe(project.loadedImage, imageX, imageY, fixedImageWidth, fixedImageHeight);
ctx.restore();
strokeRoundedRect(imageX, imageY, fixedImageWidth, fixedImageHeight, 18, "rgba(15, 23, 42, 0.12)", 1);
} else if (imageY > descriptionY + descriptionLines.length * (cardFontSize + 7) + 8) {
fillRoundedRect(imageX, imageY, fixedImageWidth, fixedImageHeight, 18, "rgba(47, 125, 211, 0.12)");
strokeRoundedRect(imageX, imageY, fixedImageWidth, fixedImageHeight, 18, "rgba(47, 125, 211, 0.24)", 1);
ctx.save();
ctx.fillStyle = uiTheme.accent;
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.font = `700 34px ${uiTheme.font}`;
ctx.fillText("{ }", imageX + fixedImageWidth / 2, imageY + fixedImageHeight * 0.42);
ctx.font = `600 15px ${uiTheme.font}`;
ctx.fillText(project.language || "Gitea repository", imageX + fixedImageWidth / 2, imageY + fixedImageHeight * 0.7);
ctx.restore();
}
const buttonWidth = Math.min(projectWidth - 48, 220);
const buttonHeight = 48;
const buttonX = projectBoxX + (projectWidth - buttonWidth) / 2;
const buttonY = projectBoxY + projectHeight - 64;
drawButton(buttonX, buttonY, buttonWidth, buttonHeight, "See More", {
fill: "#102033",
radius: 16,
size: 16
});
project.seeMoreButton = {
x: buttonX - 10,
y: buttonY - 10,
width: buttonWidth + 20,
height: buttonHeight + 20,
projectIndex: i,
};
}
const buttonWidth = isMobile ? 104 : 128;
const buttonHeight = 46;
const buttonPadding = 28;
const prevButtonX = menuX + buttonPadding;
const nextButtonX = menuX + menuWidth - buttonWidth - buttonPadding;
const navY = menuY + menuHeight - buttonHeight - 20;
drawButton(prevButtonX, navY, buttonWidth, buttonHeight, "Previous", {
fill: currentPage === 0 ? "rgba(73, 96, 113, 0.45)" : "#102033",
radius: 15,
size: 15
});
const maxPage = Math.ceil(projects.length / projectsPerPage) - 1;
drawButton(nextButtonX, navY, buttonWidth, buttonHeight, "Next", {
fill: currentPage === maxPage ? "rgba(73, 96, 113, 0.45)" : "#102033",
radius: 15,
size: 15
});
ctx.save();
ctx.fillStyle = uiTheme.mutedText;
ctx.font = `14px ${uiTheme.font}`;
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.fillText(`${currentPage + 1} / ${maxPage + 1}`, menuX + menuWidth / 2, navY + buttonHeight / 2);
ctx.restore();
menuNavigation.prev = {
x: prevButtonX - 10,
y: navY - 10,
width: buttonWidth + 20,
height: buttonHeight + 20
};
menuNavigation.next = {
x: nextButtonX - 10,
y: navY - 10,
width: buttonWidth + 20,
height: buttonHeight + 20
};
} else if (selectedProject) {
ctx.fillStyle = "rgba(8, 17, 31, 0.62)";
ctx.fillRect(0, 0, width, height);
const isMobile = width <= 768;
const horizontalMargin = isMobile ? 18 : 120;
const menuX = horizontalMargin;
const menuY = isMobile ? 24 : 42;
const menuWidth = width - horizontalMargin * 2;
const menuHeight = height - menuY * 2;
drawPanel(menuX, menuY, menuWidth, menuHeight, 30, uiTheme.panel);
ctx.fillStyle = uiTheme.text;
const titleFontSize = isMobile ? 24 : 34;
const titleLineHeight = titleFontSize + 5;
ctx.font = `700 ${titleFontSize}px ${uiTheme.font}`;
ctx.textAlign = "left";
ctx.textBaseline = "top";
const titleLines = wrapText(selectedProject.name, menuWidth - 64);
titleLines.forEach((line, index) => ctx.fillText(line, menuX + 32, menuY + 28 + index * titleLineHeight));
ctx.font = `${isMobile ? 15 : 17}px ${uiTheme.font}`;
ctx.textAlign = "left";
const detailsTextX = menuX + 34;
const detailsTextY = menuY + 28 + titleLines.length * titleLineHeight + 26;
const detailsTextWidth = menuWidth - 68;
const contentTop = detailsTextY - 24;
const contentBottom = menuY + menuHeight - 82;
const longDescriptionLines = wrapText(selectedProject.longDescription, detailsTextWidth);
const totalTextHeight = longDescriptionLines.length * 26;
ctx.save();
ctx.beginPath();
ctx.rect(menuX + 24, contentTop, menuWidth - 48, Math.max(0, contentBottom - contentTop));
ctx.clip();
ctx.fillStyle = uiTheme.mutedText;
ctx.font = `${isMobile ? 15 : 17}px ${uiTheme.font}`;
ctx.textBaseline = "alphabetic";
longDescriptionLines.forEach((line, index) => {
const lineY = detailsTextY + index * 26 - detailsScrollPosition;
if (lineY > contentTop && lineY < contentBottom) {
ctx.fillText(line, detailsTextX, lineY);
}
});
if (selectedProject.links.length) {
const linkY = detailsTextY + totalTextHeight + 32 - detailsScrollPosition;
const linkSpacing = 52;
const linkPadding = 14;
ctx.font = `700 16px ${uiTheme.font}`;
selectedProject.linkPositions = selectedProject.links.map((link, index) => {
const buttonY = linkY - 26 + index * linkSpacing;
const buttonWidth = ctx.measureText(link.text).width + linkPadding * 2;
if (buttonY <= contentTop || buttonY + 42 >= contentBottom) return null;
drawButton(detailsTextX, buttonY, buttonWidth, 42, link.text, {
fill: uiTheme.accent,
radius: 15,
size: 16
});
return {
x: detailsTextX,
y: buttonY,
width: buttonWidth,
height: 42,
url: link.url
};
}).filter(Boolean);
if (selectedProject.loadedImage) {
const maxImageWidth = menuWidth - 68;
const maxImageHeight = menuHeight * 0.38;
let imageWidth = selectedProject.loadedImage.width;
let imageHeight = selectedProject.loadedImage.height;
const widthScale = maxImageWidth / imageWidth;
const heightScale = maxImageHeight / imageHeight;
const scale = Math.min(widthScale, heightScale, 1);
imageWidth *= scale;
imageHeight *= scale;
const imageX = menuX + (menuWidth - imageWidth) / 2;
const imageY = linkY + 44 + (selectedProject.links.length - 1) * linkSpacing;
if (imageY + imageHeight > menuY + 88 && imageY < menuY + menuHeight - 82) {
const visibleImageY = Math.max(imageY, menuY + 88);
const visibleImageHeight = Math.min(imageHeight, menuY + menuHeight - 90 - visibleImageY);
if (visibleImageHeight > 0) {
ctx.imageSmoothingEnabled = true;
ctx.imageSmoothingQuality = 'high';
ctx.save();
roundedRectPath(imageX, visibleImageY, imageWidth, visibleImageHeight, 20);
ctx.clip();
drawImageSafe(
selectedProject.loadedImage,
imageX,
visibleImageY,
imageWidth,
visibleImageHeight
);
ctx.restore();
}
}
}
}
ctx.restore();
const backButtonWidth = 120;
const backButtonHeight = 48;
const backButtonX = width / 2 - backButtonWidth / 2;
const backButtonY = menuY + menuHeight - backButtonHeight - 22;
drawButton(backButtonX, backButtonY, backButtonWidth, backButtonHeight, "Back", {
fill: "#102033",
radius: 16,
size: 16
});
selectedProject.backButton = {
x: backButtonX,
y: backButtonY,
width: backButtonWidth,
height: backButtonHeight
};
detailsMaxScroll = Math.max(
detailsTextY + totalTextHeight + 32 + selectedProject.links.length * 52 +
(selectedProject.loadedImage ? selectedProject.loadedImage.height : 0) - contentBottom,
0
);
}
if (message.visible && !menuVisible && !selectedProject) {
const textX = questBoard.x - camera.x + questBoard.width / 2;
const textY = questBoard.y - 20;
drawPrompt(message.text, textX, textY);
}
}
function scrollDetails(delta) {
if (!selectedProject) return;
detailsScrollPosition = Math.max(0, Math.min(detailsScrollPosition + delta, detailsMaxScroll));
}
function openDetailsMenu(project) {
window.gameAudio.play('select');
const longDescriptionElement = document.querySelector(`.long-description[data-project-name="${project.name}"]`);
if (longDescriptionElement) {
project.longDescription = longDescriptionElement.textContent;
} else {
project.longDescription = "";
}
project.links = Array.from(document.querySelectorAll(`.link[data-project-name="${project.name}"]`), linkElement => ({
text: linkElement.textContent,
url: linkElement.href
}));
menuVisible = false;
selectedProject = project;
detailsScrollPosition = 0;
drawMenu();
}
function closeDetailsMenu() {
window.gameAudio.play('back');
selectedProject = null;
menuVisible = true;
detailsScrollPosition = 0;
drawMenu();
}
function pointInside(x, y, bounds) {
return bounds && x >= bounds.x && x <= bounds.x + bounds.width && y >= bounds.y && y <= bounds.y + bounds.height;
}
canvas.addEventListener("click", event => {
if (gameState !== "Menu") return;
const rect = canvas.getBoundingClientRect();
const x = event.clientX - rect.left;
const y = event.clientY - rect.top;
if (selectedProject) {
const link = selectedProject.linkPositions?.find(bounds => pointInside(x, y, bounds));
if (link) {
window.gameAudio.play('select');
window.open(link.url, "_blank", "noopener,noreferrer");
}
else if (pointInside(x, y, selectedProject.backButton)) closeDetailsMenu();
return;
}
if (pointInside(x, y, closeButton)) {
closeProjectMenu();
} else if (pointInside(x, y, menuNavigation.prev)) {
handlePageNavigation(-1);
} else if (pointInside(x, y, menuNavigation.next)) {
handlePageNavigation(1);
} else {
const project = getProjectsForCurrentPage().find(item => pointInside(x, y, item.seeMoreButton));
if (project) openDetailsMenu(project);
}
});
canvas.addEventListener("wheel", event => {
if (!selectedProject) return;
event.preventDefault();
scrollDetails(Math.sign(event.deltaY) * detailsScrollStep);
}, {passive: false});
let detailTouchY = null;
canvas.addEventListener("touchstart", event => {
detailTouchY = selectedProject ? event.touches[0].clientY : null;
}, {passive: true});
canvas.addEventListener("touchmove", event => {
if (detailTouchY === null || !selectedProject) return;
event.preventDefault();
const nextY = event.touches[0].clientY;
scrollDetails(detailTouchY - nextY);
detailTouchY = nextY;
}, {passive: false});
canvas.addEventListener("touchend", () => { detailTouchY = null; });
const moveLeftButton = document.getElementById("move-left-button");
const jumpButton = document.getElementById("jump-button");
const interactButton = document.getElementById("interact-button");
const moveRightButton = document.getElementById("move-right-button");
const interactCharacterButton = document.getElementById("interact-character-button");
function closeNpcDialog() {
const wasOpen = !npcDialog.classList.contains("hidden");
npcDialog.classList.add("hidden");
npcMessage.text = "Press E to talk";
if (wasOpen) window.gameAudio.play('back');
if (wasOpen && gameStarted) canvas.focus({preventScroll: true});
}
npcDialogClose.addEventListener("click", closeNpcDialog);
function bindMovementButton(button, key, facesLeft) {
button.addEventListener("pointerdown", event => {
if (!gameStarted || gameState === "Menu") return;
event.preventDefault();
button.setPointerCapture(event.pointerId);
keyState[key] = true;
character.facingRight = facesLeft;
});
const release = () => {
keyState[key] = false;
if (!keyState.ArrowLeft && !keyState.ArrowRight && !keyState.a && !keyState.d) character.dx = 0;
};
button.addEventListener("pointerup", release);
button.addEventListener("pointercancel", release);
button.addEventListener("lostpointercapture", release);
}
bindMovementButton(moveLeftButton, "ArrowLeft", true);
bindMovementButton(moveRightButton, "ArrowRight", false);
jumpButton.addEventListener("pointerdown", event => {
if (!gameStarted || gameState === "Menu" || isJumping) return;
event.preventDefault();
character.dy = -15;
isJumping = true;
window.gameAudio.play('jump');
});
interactButton.addEventListener("pointerdown", event => {
if (!gameStarted || gameState === "Menu") return;
event.preventDefault();
window.dispatchEvent(new KeyboardEvent("keydown", {key: "Enter"}));
});
interactCharacterButton.addEventListener("pointerdown", event => {
if (!gameStarted || gameState !== "inside") return;
event.preventDefault();
window.dispatchEvent(new KeyboardEvent("keydown", {key: "e"}));
});
function updateMobileControls() {
interactCharacterButton.classList.toggle("hidden", gameState !== "inside");
if (gameState !== "inside") closeNpcDialog();
}
window.addEventListener("gameStateChange", updateMobileControls);
updateMobileControls();