diff --git a/README.md b/README.md index b8fb690..3510bf4 100644 Binary files a/README.md and b/README.md differ diff --git a/darkmode.js b/darkmode.js index 5f94293..66ac0f6 100644 --- a/darkmode.js +++ b/darkmode.js @@ -1,26 +1,31 @@ -const darkModeToggle = document.getElementById('dark-mode-toggle'); -localStorage.setItem('darkMode', 'disabled'); -if (localStorage.getItem('darkMode') === 'enabled') { - document.body.classList.add('dark-mode'); -} -darkModeToggle.addEventListener('click', () => { - document.body.classList.toggle('dark-mode'); - darkModeToggle.classList.toggle('dark-mode'); - if (document.body.classList.contains('dark-mode')) { - localStorage.setItem('darkMode', 'enabled'); - darkModeToggle.style.color = "black"; - darkModeToggle.innerHTML = "☀️
Toggle Light Mode"; - } else { - localStorage.setItem('darkMode', 'disabled'); - darkModeToggle.style.color = "white"; - darkModeToggle.innerHTML = "🌙
Toggle Dark Mode"; - } -}); - -enterButton.addEventListener('click', () => { - if (document.body.classList.contains('dark-mode')) { - document.body.style.backgroundColor = "black"; - } else { - document.body.style.backgroundColor = "white"; - } -}); \ No newline at end of file +const darkModeToggle = document.getElementById('dark-mode-toggle'); +const themeIcon = darkModeToggle.querySelector('.theme-icon'); +const themeLabel = darkModeToggle.querySelector('.theme-label'); + +function applyTheme(isDark) { + document.body.classList.toggle('dark-mode', isDark); + darkModeToggle.setAttribute('aria-pressed', String(isDark)); + themeIcon.textContent = isDark ? '☀' : '☾'; + themeLabel.textContent = isDark ? 'Light mode' : 'Dark mode'; + document.querySelector('meta[name="theme-color"]').content = isDark ? '#0d1828' : '#e9f3fb'; + window.dispatchEvent(new Event('themechange')); +} + +let savedTheme; +try { + savedTheme = localStorage.getItem('darkMode'); +} catch { + savedTheme = null; +} + +applyTheme(savedTheme === 'enabled' || (savedTheme === null && window.matchMedia('(prefers-color-scheme: dark)').matches)); + +darkModeToggle.addEventListener('click', () => { + const isDark = !document.body.classList.contains('dark-mode'); + applyTheme(isDark); + try { + localStorage.setItem('darkMode', isDark ? 'enabled' : 'disabled'); + } catch { + // The chosen theme remains active for this visit when storage is unavailable. + } +}); diff --git a/img/house-interior-v2.png b/img/house-interior-v2.png new file mode 100644 index 0000000..1f17f37 Binary files /dev/null and b/img/house-interior-v2.png differ diff --git a/index.html b/index.html index 1be9c87..789accf 100644 --- a/index.html +++ b/index.html @@ -1,111 +1,200 @@ - - - - - - Fretux - - - - - -
-
-

Welcome! Click the image below to begin your journey through my site!

- Enter Website - Enter Website - -
-
- - -
- -
-

-

-
- - - -
- -
-
-

TRLStats

-

An interactive JavaScript and Python application designed to calculate damage statistics for the popular Roblox game 'The Red Lake'.

- TRLStats -

TRLStats was my first project, a damage calculator for the Roblox game 'The Red Lake'. This project combines Python, Flask for backend processing, and JavaScript to dynamically display data extracted from JSON files.

- Link to the Website -

HTML

-

CSS

-

Javascript

-

Python

-

JSON

-
-
-

SBB-History

-

A Website about the creation of the SBB (Swiss Train Corporation) with an - interactable map!

- SBB-History -

For a School Project, me and 2 friends had to create a page about the economic history of a part of the swiss, which we chose to do about the "SBB", the Swiss Train Coorporation, founded in 1902. It is a simple Website, using HTML, CSS, and JavaScript, alongside the "Maptiler" API for the interactive map.

- Link to the Website -

HTML

-

CSS

-

Javascript

-
-
-

KnockedBack

-

A minecraft forge mod for 1.20.1 that revamps dying!

- KnockedBack -

For a long time, I had wanted a mod that really changed how dying worked in PVP, to make it more tactical. However, there was none. So, I looked into the possibility of making my own minecraft mod, initially with neoforge, but after multiple setbacks, I decided to go with normal forge, as it was easier.

- Link to the Mod -

Java

-
-
- - -
- -
- - - - - -
- - - - - -
- - - -
- - - - - \ No newline at end of file + + + + + + + + Fretux — Interactive Portfolio + + + + + +
+
+

Fretux

+

A little world to explore

+ +

← → / A D to move  ·  ↑ / W to jump  ·  Enter to interact

+
+
+ + + + + +
+ + +
+ +
+
+

The_Tower

+

A simple tower defense game made in Unity.

+

A Unity tower defense game where enemies attack a tower. The C# project includes enemy spawning, bullets, cannon effects, and upgrade cards.

+ Source code +

C#

+
+
+

PortfolioWebsite

+

An interactive portfolio built as a snowy 2D game.

+

This website is a small canvas platform game. Walk through the snowy world to discover projects and enter the house to learn about its creator.

+ Source code +

JavaScript

+
+
+

anomaly-front-stats

+

A web calculator for Anomaly Front stats and weapons.

+

A Flask website for calculating Anomaly Front stats, browsing and filtering weapon data, and sending suggestions. It follows the earlier TRL Stats project.

+ Source code +

Python / Flask

+
+
+

AdapativeMobs

+

A Minecraft mod that makes hostile mobs adapt to world progression.

+

A server-side Minecraft Forge mod that scales vanilla hostile mobs as the world advances. Mobs can gain stronger attributes, better equipment, extra spawn pressure, and tactical behavior.

+ Source code +

Java

+
+
+

WebsitePortfolio2

+

A Next.js portfolio with project, about, and contact pages.

+

A separate portfolio website built with Next.js. It presents Frederik's work through dedicated project, about, and contact pages.

+ Source code +

JavaScript

+
+
+

Shoppy

+

A mobile shopping assistant for checking products in the store.

+

An Expo and React Native app concept for scanning product barcodes and judging them against personal goals such as allergies, nutrition, budget, and sustainability.

+ Source code +

JavaScript

+
+
+

linux_distro_roguelite_promotion

+

The pre-registration site and backend for Linux Distro Roguelite.

+

A Quarkus backend and static pre-registration page for a free game. Players sign up, choose platforms, and unlock rewards through community milestones.

+ Source code +

Java

+
+
+

linuxdistro-roguelite

+

A Linux distribution themed roguelite made in Unity.

+

The Unity game project for Linux Distro Roguelite. Its scenes and scripts cover combat, game runs, progression, and a gambling scene.

+ Source code +

Unity / C#

+
+
+

MindMotion

+

A Minecraft mod adding Sanity, Insanity, and Tempo systems.

+

A configurable Minecraft gameplay mod inspired by Deepwoken. Sanity, Insanity, and Tempo respond to the player's surroundings and actions, adding decisions to exploration and combat.

+ Source code +

Java

+
+
+

Ailments

+

A Forge library for combat effects such as Bleed and Fear.

+

A Minecraft Forge library mod that provides reusable effects including Soul Rot, Bleed, Fracture, Fear, Charm, Taunt, Overcharm, and Winded for other mods.

+ Source code +

Java

+
+
+

KnockedBack

+

A Minecraft mod that turns lethal hits into knockdowns.

+

A Minecraft Forge mod that replaces many instant deaths with a temporary knocked-out state. Teammates can rescue and carry a downed player, while opponents can execute them.

+ Source code + Download mod +

Java

+
+
+

Ascend

+

A soulslike Minecraft RPG progression mod.

+

A Minecraft mod inspired by Deepwoken. Players earn Ascend levels, invest skill points in attributes, and gain Knowledge through play.

+ Source code +

Java

+
+
+

SkillEngine

+

A framework for adding custom skill trees to Forge mods.

+

A modular Minecraft Forge framework for other mods to define skill nodes, requirements, icons, and unlocks. It also handles the skill tree interface and server synchronization.

+ Source code +

Java

+
+
+

KrisHD_AI

+

Elsewhere, an AI chat app with personas and roleplay sessions.

+

Elsewhere is an Express and SQLite chat app with user accounts, custom personas, roleplay conversations, and a persona market. Conversations can branch and be compared.

+ Source code + Live website +

JavaScript

+
+
+

Discord_bot

+

A Discord bot with moderation, roles, and virtual currency.

+

A Discord.js bot for community servers. Its commands cover moderation, role management, utility features, and a virtual currency system.

+ Source code +

JavaScript

+
+
+

Portfolio2

+

Another repo of the Next.js portfolio website.

+

A second repository for the Next.js portfolio site, with the same home page as WebsitePortfolio2 and dedicated project, about, and contact pages.

+ Source code +

JavaScript

+
+
+

Autoclicker

+

A desktop autoclicker with a configurable hotkey.

+

A Python desktop autoclicker built with Tkinter and pynput. Configure a hotkey, then start or stop clicking from the app or the keyboard.

+ Source code +

Python

+
+
+

Gambling_API

+

A multiplayer virtual-chip poker game for the web.

+

A browser-based multiplayer poker game with Express, WebSockets, and SQLite. Players register accounts, join lobbies, and play with virtual chips.

+ Source code +

JavaScript

+
+
+

TARS

+

A Discord bot for a private server's moderation and utilities.

+

A Python Discord bot with moderation tools, reports, reminders, server utilities, fun commands, and a perk shop for server boosters.

+ Source code +

Python

+
+
+ + + + +
+ + + + + +
+ + + +
+ + + + + diff --git a/script1.js b/script1.js index 6ef87b5..9948775 100644 --- a/script1.js +++ b/script1.js @@ -1,32 +1,35 @@ //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'); -const overlay = document.getElementById('content-overlay'); -const sectionTitle = document.getElementById('section-title'); -const sectionContent = document.getElementById('section-content'); -const projectSection = document.getElementById('projects'); +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 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.png"; -let lastInputTime = performance.now(); -let isPaused = false; +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 menuBackground = new Image(); -menuBackground.src = "img/menu-background-projects-1.png"; +const dialogOption3 = document.getElementById("dialog-option-3"); +const npcDialogClose = document.getElementById("npc-dialog-close"); let menuVisible = false; const message = { x: 0, @@ -46,28 +49,6 @@ const npcMessage = { x: 0, y: 0 }; -const controlsImage = new Image(200, 200); -controlsImage.src = 'img/controls.png'; -const backgrounds = [ - 'img/project-background-1.png', - 'img/project-background-2.png', - 'img/project-background-3.png' -]; -let scrollPosition = 0; -const backgroundImages = backgrounds.map(src => { - const img = new Image(); - img.src = src; - return img; -}); -let selectedBackgrounds = []; - -function resetInactivityTimer() { - lastInputTime = performance.now(); -} - -window.addEventListener("keydown", resetInactivityTimer); -window.addEventListener("mousedown", resetInactivityTimer); -window.addEventListener("touchstart", resetInactivityTimer); 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; @@ -148,69 +129,41 @@ const trees = Array.from({length: treeCount}, () => ({ height: treeHeight, })); -function initializeProjectBackgrounds() { - selectedBackgrounds = projects.map(() => { - return backgroundImages[Math.floor(Math.random() * backgroundImages.length)]; - }); -} - -window.addEventListener("keydown", (e) => { - if (e.key === "ArrowRight") { - keyState.ArrowRight = true; - character.facingRight = false; - character.dx = character.speed; - } - if (e.key === "ArrowLeft") { - keyState.ArrowLeft = true; - character.facingRight = true; - character.dx = -character.speed; - } - if (e.key === "ArrowUp" && !isJumping) { - character.dy = -15; - isJumping = true; - } - if (e.key === "d") { - keyState.d = true; - character.facingRight = false; - character.dx = character.speed; - } - if (e.key === "a") { - keyState.a = true; - character.facingRight = true; - character.dx = -character.speed; - } - if (e.key === "w" && !isJumping) { - character.dy = -15; - isJumping = true; - } -}); - -window.addEventListener("keyup", (e) => { - if (e.key === "ArrowRight") { - keyState.ArrowRight = false; - if (!keyState.ArrowLeft) { - character.dx = 0; - } - } - if (e.key === "ArrowLeft") { - keyState.ArrowLeft = false; - if (!keyState.ArrowRight) { - character.dx = 0; - } - } - if (e.key === "d") { - keyState.d = false; - if (!keyState.a) { - character.dx = 0; - } - } - if (e.key === "a") { - keyState.a = false; - if (!keyState.d) { - character.dx = 0; - } - } -}); +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, @@ -219,17 +172,42 @@ const closeButton = { }; const uiTheme = { - font: "'Trebuchet MS', Verdana, sans-serif", + font: "'Courier New', monospace", text: "#102033", mutedText: "#496071", - panel: "rgba(248, 252, 255, 0.92)", + panel: "rgba(239, 247, 249, 0.98)", panelDark: "rgba(12, 22, 36, 0.9)", accent: "#2f7dd3", accentWarm: "#f0b84f", - border: "rgba(255, 255, 255, 0.7)", - shadow: "rgba(9, 24, 43, 0.22)" + 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; } @@ -261,12 +239,20 @@ function getInteriorLayout() { }; } +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, width / 2, height / 2); + 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); @@ -289,28 +275,20 @@ function strokeRoundedRect(x, y, width, height, radius, strokeStyle, lineWidth = ctx.stroke(); } -function drawPanel(x, y, width, height, radius = 24, fillStyle = uiTheme.panel) { - ctx.save(); - ctx.shadowColor = uiTheme.shadow; - ctx.shadowBlur = 30; - ctx.shadowOffsetY = 16; - fillRoundedRect(x, y, width, height, radius, fillStyle); - ctx.shadowColor = "transparent"; - strokeRoundedRect(x, y, width, height, radius, uiTheme.border, 1.5); +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 = {}) { +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(); - ctx.shadowColor = "rgba(9, 24, 43, 0.18)"; - ctx.shadowBlur = 16; - ctx.shadowOffsetY = 8; - fillRoundedRect(x, y, width, height, radius, fill); - ctx.shadowColor = "transparent"; - strokeRoundedRect(x, y, width, height, radius, options.border || "rgba(255,255,255,0.18)", 1); + 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"; @@ -333,14 +311,6 @@ function drawPrompt(text, x, y) { ctx.restore(); } -//Sections -const sections = [ - {x: 200, title: "Section 1", content: "This is the first section of the website."}, - {x: 600, title: "Section 2", content: "Here you learn more about me!"}, - {x: 1000, title: "Section 3", content: "Thanks for visiting my interactive site!"}, - {x: 2000, title: "Section 4", content: "Potato!"}, -]; - function drawCharacter(deltaTime) { frameTimer += deltaTime; const frameDurationForState = 1000 / framesPerState[characterState]; @@ -356,7 +326,7 @@ function drawCharacter(deltaTime) { const scaledHeight = frameHeight * 1.5; if (character.facingRight) { - ctx.drawImage( + drawImageSafe( spriteSheet, frameX, 0, frameWidth, frameHeight, character.x - camera.x, character.y, scaledWidth, scaledHeight @@ -364,7 +334,7 @@ function drawCharacter(deltaTime) { } else { ctx.save(); ctx.scale(-1, 1); - ctx.drawImage( + drawImageSafe( spriteSheet, frameX, 0, frameWidth, frameHeight, -(character.x - camera.x + scaledWidth), character.y, scaledWidth, scaledHeight @@ -373,19 +343,20 @@ function drawCharacter(deltaTime) { } } -function drawCharacterBig() { - frameTimer++; - if (frameTimer >= frameDuration) { +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 scaledWidth = frameWidth * 3; - const scaledHeight = frameHeight * 3; - const drawY = currentFloorHeight() - scaledHeight; + const scale = interiorCharacterScale(getInteriorLayout()); + const scaledWidth = frameWidth * scale; + const scaledHeight = frameHeight * scale; + const drawY = character.y + character.height - scaledHeight; if (character.facingRight) { - ctx.drawImage( + drawImageSafe( spriteSheet, frameX, 0, frameWidth, frameHeight, character.x - camera.x, drawY, scaledWidth, scaledHeight @@ -393,7 +364,7 @@ function drawCharacterBig() { } else { ctx.save(); ctx.scale(-1, 1); - ctx.drawImage( + drawImageSafe( spriteSheet, frameX, 0, frameWidth, frameHeight, -(character.x - camera.x + scaledWidth), drawY, scaledWidth, scaledHeight @@ -404,51 +375,47 @@ function drawCharacterBig() { 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'); } - if (keyState.ArrowRight) character.dx = character.speed * (deltaTime / frameDuration); - if (keyState.ArrowLeft) character.dx = -character.speed * (deltaTime / frameDuration); - if (keyState.d) character.dx = character.speed * (deltaTime / frameDuration); - if (keyState.a) character.dx = -character.speed * (deltaTime / frameDuration); -} + 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() { - character.x += character.dx; -} +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 = MAP_WIDTH - camera.width; - } -} - -function handleInteractions() { - sections.forEach((section) => { - if (character.x > section.x - 50 && character.x < section.x + 50) { - sectionTitle.textContent = section.title; - sectionContent.textContent = section.content; - overlay.classList.add('visible'); - } - }); -} - -function drawSections() { + 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(); - ctx.drawImage( + drawImageSafe( treeImage, tree.x - camera.x, tree.y, @@ -502,11 +469,14 @@ function drawDistantHills() { ctx.restore(); } -//Spawn Snowflakes -function createSnowflakes() { - const numberOfSnowflakes = 0.01; - for (let i = 0; i < numberOfSnowflakes; i++) { - snowflakes.push({ +//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, @@ -515,13 +485,13 @@ function createSnowflakes() { } } -function drawSnowflakes() { +function drawSnowflakes(deltaTime) { ctx.save(); ctx.beginPath(); snowflakes = snowflakes.filter((snowflake) => { const isVisible = snowflake.x >= camera.x - 50 && - snowflake.x <= camera.x + canvas.width + 50; + snowflake.x <= camera.x + viewportWidth() + 50; if (isVisible) { ctx.moveTo(snowflake.x - camera.x, snowflake.y); ctx.arc( @@ -532,9 +502,9 @@ function drawSnowflakes() { Math.PI * 2 ); } - snowflake.y += snowflake.speed; + snowflake.y += snowflake.speed * (deltaTime / frameDuration); if (snowflake.y + snowflake.radius >= floorHeight) { - if (snowOnGround.length < 1000) { + if (snowOnGround.length < 240) { snowOnGround.push({ x: snowflake.x, y: floorHeight - snowflake.radius, @@ -555,7 +525,7 @@ function drawSnowflakes() { snowOnGround.forEach((snowflake) => { const isVisible = snowflake.x >= camera.x - 50 && - snowflake.x <= camera.x + canvas.width + 50; + snowflake.x <= camera.x + viewportWidth() + 50; if (isVisible) { ctx.moveTo(snowflake.x - camera.x, snowflake.y); ctx.arc( @@ -596,19 +566,19 @@ 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) { - deltaTime = currentTime - lastFrameTime; - if (deltaTime >= frameDuration) { - ctx.clearRect(0, 0, canvas.width, canvas.height); +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); - drawSections(); + drawScenery(); drawHouse(); drawSnowflakes(deltaTime); handlePhysics(deltaTime); moveCharacter(deltaTime); - handleInteractions(); handleQuestionBlockCollision(); handleHouseInteraction(); drawFloor(); @@ -621,9 +591,7 @@ function update(currentTime) { drawMenu(); updateCharacterState(); drawHouseMessage(); - handleScroll(); - isPaused = false; - } else if (gameState === "inside") { + } else if (gameState === "inside") { handlePhysics(deltaTime); drawInterior(); drawNPC(); @@ -632,17 +600,17 @@ function update(currentTime) { drawNPCMessage(); moveCharacter(deltaTime); updateCharacterState(); - isPaused = true; - } else if (gameState === "Menu") { - drawMenu(); + } else if (gameState === "Menu") { + if (menuReturnState === "inside") { + drawInterior(); + } else { + drawSky(); + drawDistantHills(); + drawFloor(); + } + drawMenu(); } - if ((gameState === "outside" || gameState === "inside") && (currentTime - lastInputTime >= 5000)) { - const characterCenterX = (character.x - camera.x) + (64 * 1.5) / 2; - const controlsX = characterCenterX - controlsImage.width / 2; - const controlsY = character.y - controlsImage.height - 10; - ctx.drawImage(controlsImage, controlsX, controlsY); - } - lastFrameTime = currentTime - (deltaTime % frameDuration); + lastFrameTime = currentTime - (elapsed % frameDuration); } requestAnimationFrame(update); } @@ -652,52 +620,16 @@ function drawInterior() { const height = viewportHeight(); const room = getInteriorLayout(); ctx.clearRect(0, 0, canvas.width, canvas.height); - const roomGlow = ctx.createLinearGradient(0, 0, width, height); - roomGlow.addColorStop(0, "#17243a"); - roomGlow.addColorStop(0.55, "#253653"); - roomGlow.addColorStop(1, "#111827"); - ctx.fillStyle = roomGlow; + ctx.fillStyle = "#17243a"; ctx.fillRect(0, 0, width, height); ctx.save(); - ctx.shadowColor = "rgba(0, 0, 0, 0.35)"; - ctx.shadowBlur = 28; - ctx.shadowOffsetY = 12; - ctx.drawImage(interiorBackground, room.x, room.y, room.width, room.height); + ctx.imageSmoothingEnabled = false; + drawImageSafe(interiorBackground, room.x, room.y, room.width, room.height); ctx.restore(); - const vignette = ctx.createRadialGradient( - width / 2, - height / 2, - height * 0.1, - width / 2, - height / 2, - width * 0.75 - ); - vignette.addColorStop(0, "rgba(255,255,255,0)"); - vignette.addColorStop(1, "rgba(3,8,18,0.38)"); - ctx.fillStyle = vignette; - ctx.fillRect(0, 0, width, height); } -window.addEventListener("keydown", (e) => { - if (e.key === "ArrowRight") character.dx = character.speed; - if (e.key === "ArrowLeft") character.dx = -character.speed; - if (e.key === "ArrowUp" && !isJumping) { - character.dy = -15; - isJumping = true; - } - if (e.key === "d") character.dx = character.speed; - if (e.key === "a") character.dx = -character.speed; - if (e.key === "w" && !isJumping) { - character.dy = -15; - isJumping = true; - } -}); - -window.addEventListener("keyup", () => { - character.dx = 0; -}); - -function initSnow() { +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"); @@ -708,35 +640,66 @@ function initSnow() { } } -enterButton.addEventListener("click", () => { - welcomeScreen.classList.add("hidden"); - setTimeout(() => { - gameContainer.style.display = "block"; - resizeCanvas(); - preloadProjectImages(); - initializeProjectBackgrounds(); - initSnow(); - update(); - }, 500); -}); - -window.addEventListener("resize", () => { - if (gameContainer.style.display === "block") { - resizeCanvas(); - } -}); - +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 dpr = window.devicePixelRatio || 1; - canvas.width = window.innerWidth * dpr; - canvas.height = window.innerHeight * dpr; - canvas.style.width = window.innerWidth + "px"; - canvas.style.height = window.innerHeight + "px"; - ctx.setTransform(dpr, 0, 0, dpr, 0, 0); - - camera.width = window.innerWidth; - camera.height = window.innerHeight; - floorHeight = window.innerHeight - 50; // Update floor height + 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); @@ -748,7 +711,7 @@ function resizeCanvas() { // Update house interior elements only when inside if (gameState === "inside") { const room = getInteriorLayout(); - NPC.y = room.floorY - NPC.height * 1.5; + NPC.y = room.floorY - NPC.height * interiorNpcScale(room); character.y = Math.min(character.y, room.floorY - character.height); } } @@ -807,11 +770,11 @@ questBoardImage.src = 'img/quest-board-1.png' questBoard.texture = questBoardImage let particles = []; -function drawNPC() { - const scale = 1.5; +function drawNPC() { + const scale = interiorNpcScale(getInteriorLayout()); const drawWidth = NPC.width * scale; const drawHeight = NPC.height * scale; - ctx.drawImage( + drawImageSafe( NPC.texture, NPC.x, NPC.y, @@ -825,7 +788,7 @@ function handleNPCProximity() { npcMessage.visible = false; return; } - const npcScale = 1.5; + const npcScale = interiorNpcScale(getInteriorLayout()); const npcDrawWidth = NPC.width * npcScale; const npcDrawHeight = NPC.height * npcScale; const talkRadius = 60; @@ -848,43 +811,45 @@ function drawNPCMessage() { drawPrompt(npcMessage.text, npcMessage.x, npcMessage.y - 20); } -window.addEventListener("keydown", (e) => { - if (e.key === "e" || e.key === "E") { - if (gameState === "inside" && npcMessage.visible) { - console.log("open dialogue"); - positionNPCDialog(); - npcDialog.classList.remove("hidden"); - npcMessage.visible = false; - npcMessage.text = "Press ESC to stop talking"; +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", () => { - 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."; -}); +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", () => { - 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'."; -}); +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", () => { - 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 :)"; -}); +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) { - npcDialog.classList.add("hidden"); - } +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")) { - npcDialog.classList.add("hidden"); - npcMessage.text = "Press E to talk" +document.addEventListener("keydown", (event) => { + if (event.key === "Escape" && !npcDialog.classList.contains("hidden")) { + closeNpcDialog(); } }); @@ -917,7 +882,7 @@ function drawParticles() { function drawQuestionBlock() { const texture = questionBlock.activated ? activeImage : inactiveImage; - ctx.drawImage( + drawImageSafe( texture, questionBlock.x - camera.x, questionBlock.y + questionBlock.bounceOffset, @@ -928,7 +893,7 @@ function drawQuestionBlock() { function drawQuestBoard() { const texture = questBoard.texture; - ctx.drawImage( + drawImageSafe( texture, questBoard.x - camera.x, questBoard.y, @@ -938,7 +903,7 @@ function drawQuestBoard() { } function drawHouse() { - ctx.drawImage( + drawImageSafe( house.texture, house.x - camera.x, house.y, @@ -953,8 +918,9 @@ function handleQuestionBlockCollision() { 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; + if (bottomCollision && !questionBlock.activated) { + questionBlock.activated = true; + window.gameAudio.play('select'); createParticles( questionBlock.x + questionBlock.width / 2, questionBlock.y + questionBlock.height / 2 @@ -985,49 +951,83 @@ function handleQuestionBlockCollision() { character.y + character.height > questionBlock.y + questionBlock.bounceOffset && character.y < questionBlock.y + questionBlock.height + questionBlock.bounceOffset; - if (sideCollision) { + 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; - } - } - if (questionBlock.activated) { - const blockInfo = document.getElementById('block-info'); - blockInfo.style.left = `${questionBlock.x - camera.x + questionBlock.width / 2 - 125}px`; - blockInfo.style.top = `${questionBlock.y - 350}px`; - blockInfo.classList.remove('hidden'); - } -} + 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`; + } +} -canvas.addEventListener("click", (e) => { - const rect = canvas.getBoundingClientRect(); - const mouseX = e.clientX - rect.left; - const mouseY = e.clientY - rect.top; - if ( - menuVisible && - mouseX >= closeButton.x && - mouseX <= closeButton.x + closeButton.width && - mouseY >= closeButton.y && - mouseY <= closeButton.y + closeButton.height - ) { - gameState = "outside" - menuVisible = false; - } -}); - -window.addEventListener("keydown", (e) => { - if (e.key === "Enter" && message.visible) { - menuVisible = !menuVisible; - if (menuVisible) { - message.visible = false; - } - } -}); +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 = @@ -1045,91 +1045,87 @@ function handleQuestBoardInteraction() { 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 : ''; - projects.push({name: title, description: description, image: imageSrc}); - console.log(imageElement) + 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 projectPadding = window.innerWidth < 768 ? 15 : 30; -let projectFontSize = window.innerWidth < 768 ? 14 : 18; +let projectFontSize = window.innerWidth < 768 ? 14 : 18; window.addEventListener("resize", () => { const isMobile = window.innerWidth < 768; projectsPerPage = isMobile ? 1 : 2; // Update projects per page - projectPadding = isMobile ? 15 : 30; // Update padding projectFontSize = isMobile ? 14 : 18; // Update font size currentPage = 0; // Reset to the first page drawMenu(); // Redraw the menu }); -window.addEventListener("resize", () => { - if (gameState === "inside" && !npcDialog.classList.contains("hidden")) { - positionNPCDialog(); - } -}); - function getProjectsForCurrentPage() { const startIndex = currentPage * projectsPerPage; return projects.slice(startIndex, startIndex + projectsPerPage); } -const prevButton = document.getElementById('prev-button'); -const nextButton = document.getElementById('next-button'); - -function handlePageNavigation(direction) { - const maxPage = Math.ceil(projects.length / projectsPerPage) - 1; - currentPage = Math.max(0, Math.min(currentPage + direction, maxPage)); +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; } -prevButton.addEventListener("click", () => handlePageNavigation(-1)); -nextButton.addEventListener("click", () => handlePageNavigation(1)); -let listenersAttached = false; -let detailsBackListenerAttached = false; - -function preloadProjectImages() { +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 - console.log('Image loaded for project:', project.name); - }; + img.onload = () => { + project.loadedImage = img; // Store the loaded image + }; } }); } let selectedProject = null; -function wrapText(text, maxWidth) { - const lines = []; - let line = ""; - const words = text.split(" "); - for (let n = 0; n < words.length; n++) { - const testLine = line + (line === "" ? "" : " ") + words[n]; - const testWidth = ctx.measureText(testLine).width; - if (testWidth > maxWidth && line !== "") { - lines.push(line); - line = words[n]; - } else { - line = testLine; - } - } - lines.push(line); - return lines; -} +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(); - console.log("Entering House") gameState = "inside"; + document.getElementById('block-info').classList.add('hidden'); + message.visible = false; + houseMessage.visible = false; snowContainer.classList.add('paused'); removeSnowflakes(); ctx.save(); @@ -1140,39 +1136,35 @@ function toggleHouse() { ctx.fillRect(0, 0, width, height); camera.x = 0; camera.y = 0; - character.x = Math.max(room.x + 250, 170); + 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; - NPC.x = Math.max(room.x + 70, 90); - NPC.y = room.floorY - NPC.height * 1.5; - ctx.drawImage(interiorBackground, room.x, room.y, room.width, room.height); - console.log("Interior Loaded") + 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") { - console.log("Leaving House") gameState = "outside"; snowContainer.classList.remove('paused'); - npcDialog.classList.add("hidden"); + closeNpcDialog(); npcMessage.visible = false; npcMessage.text = "Press E to talk"; - initSnow(); - ctx.clearRect(0, 0, canvas.width, canvas.height); - character.x = 1975 + 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 positionNPCDialog() { - const rect = canvas.getBoundingClientRect(); - const dialogWidth = npcDialog.offsetWidth; - const dialogX = rect.left + (viewportWidth() / 2) - (dialogWidth / 2); - const dialogY = rect.top + 20; - npcDialog.style.left = `${dialogX}px`; - npcDialog.style.top = `${dialogY}px`; -} - - function removeSnowflakes() { while (snowContainer.firstChild) { snowContainer.removeChild(snowContainer.firstChild); @@ -1194,8 +1186,9 @@ function handleHouseInteraction() { } } -window.addEventListener("keydown", (e) => { - if (e.key === "Enter") { +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 && @@ -1203,7 +1196,6 @@ window.addEventListener("keydown", (e) => { character.y + character.height > house.y - 50 && character.y < house.y + house.height + 50; if (nearHouse) { - console.log("Near House"); houseMessage.visible = true; toggleHouse(); } else { @@ -1225,7 +1217,6 @@ function drawMenu() { const width = viewportWidth(); const height = viewportHeight(); if (menuVisible && !selectedProject) { - gameState = "Menu"; ctx.fillStyle = "rgba(8, 17, 31, 0.58)"; ctx.fillRect(0, 0, width, height); @@ -1236,16 +1227,16 @@ function drawMenu() { const menuWidth = width - horizontalMargin * 2; const menuHeight = height - menuY * 2; - drawPanel(menuX, menuY, menuWidth, menuHeight, 30, "rgba(240, 248, 255, 0.9)"); + 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.fillText("Projects", menuX + 28, menuY + 24); ctx.font = `${isMobile ? 13 : 15}px ${uiTheme.font}`; ctx.fillStyle = uiTheme.mutedText; - ctx.fillText("Choose a project to inspect the build, stack, and result.", menuX + 30, menuY + (isMobile ? 56 : 64)); + ctx.fillText(isMobile ? `${projects.length} projects` : `${projects.length} projects · choose one to explore`, menuX + 30, menuY + (isMobile ? 56 : 64)); ctx.restore(); closeButton.width = 42; @@ -1258,9 +1249,10 @@ function drawMenu() { size: 18 }); - const columns = projectsPerPage === 1 ? 1 : 2; - const topOffset = isMobile ? 96 : 116; - const navSpace = 78; + 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; @@ -1271,16 +1263,15 @@ function drawMenu() { const row = Math.floor(i / columns); const col = i % columns; const projectBoxX = menuX + 28 + col * (projectWidth + cardGap); - const projectBoxY = menuY + topOffset + row * (projectHeight + cardGap) - scrollPosition; + const projectBoxY = menuY + topOffset + row * (projectHeight + cardGap); - drawPanel(projectBoxX, projectBoxY, projectWidth, projectHeight, 24, "rgba(255, 255, 255, 0.82)"); - const accent = ctx.createLinearGradient(projectBoxX, projectBoxY, projectBoxX + projectWidth, projectBoxY); - accent.addColorStop(0, "rgba(47, 125, 211, 0.9)"); - accent.addColorStop(1, "rgba(240, 184, 79, 0.9)"); - fillRoundedRect(projectBoxX + 18, projectBoxY + 18, projectWidth - 36, 6, 10, accent); + 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; - ctx.font = `700 ${projectFontSize + 8}px ${uiTheme.font}`; + const cardFontSize = compact ? Math.max(12, projectFontSize - 2) : projectFontSize; + ctx.font = `700 ${cardFontSize + 8}px ${uiTheme.font}`; ctx.textAlign = "left"; ctx.textBaseline = "alphabetic"; @@ -1288,51 +1279,63 @@ function drawMenu() { const textY = projectBoxY + 58; const lines = wrapText(project.name, projectWidth - 48); - let lineHeight = projectFontSize + 12; + let lineHeight = cardFontSize + 12; lines.forEach((line, index) => { ctx.fillText(line, textX, textY + index * lineHeight); }); ctx.fillStyle = uiTheme.mutedText; - ctx.font = `${projectFontSize}px ${uiTheme.font}`; - const descriptionLines = wrapText(project.description, projectWidth - 48).slice(0, isMobile ? 3 : 4); + 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 * (projectFontSize + 7)); + ctx.fillText(line, textX, descriptionY + index * (cardFontSize + 7)); }); - if (project.loadedImage) { - const fixedImageWidth = projectWidth - 48; - const fixedImageHeight = Math.max(120, projectHeight * 0.34); - const imageX = projectBoxX + 24; - const imageY = projectBoxY + projectHeight - fixedImageHeight - 88; - ctx.imageSmoothingEnabled = true; - ctx.imageSmoothingQuality = "high"; - ctx.save(); + 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(); - ctx.drawImage(project.loadedImage, imageX, imageY, fixedImageWidth, fixedImageHeight); - ctx.restore(); - strokeRoundedRect(imageX, imageY, fixedImageWidth, fixedImageHeight, 18, "rgba(15, 23, 42, 0.12)", 1); - - 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, - }; - } + 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; @@ -1361,63 +1364,19 @@ function drawMenu() { ctx.fillText(`${currentPage + 1} / ${maxPage + 1}`, menuX + menuWidth / 2, navY + buttonHeight / 2); ctx.restore(); - this.prevButton = { + menuNavigation.prev = { x: prevButtonX - 10, y: navY - 10, width: buttonWidth + 20, height: buttonHeight + 20 }; - this.nextButton = { + menuNavigation.next = { x: nextButtonX - 10, y: navY - 10, width: buttonWidth + 20, height: buttonHeight + 20 }; - if (!listenersAttached) { - canvas.addEventListener("click", (e) => { - const rect = canvas.getBoundingClientRect(); - const mouseX = e.clientX - rect.left; - const mouseY = e.clientY - rect.top; - - if ( - mouseX >= this.prevButton.x && - mouseX <= this.prevButton.x + this.prevButton.width && - mouseY >= this.prevButton.y && - mouseY <= this.prevButton.y + this.prevButton.height - ) { - handlePageNavigation(-1); - drawMenu(); - } - - if ( - mouseX >= this.nextButton.x && - mouseX <= this.nextButton.x + this.nextButton.width && - mouseY >= this.nextButton.y && - mouseY <= this.nextButton.y + this.nextButton.height - ) { - handlePageNavigation(1); - drawMenu(); - } - - const projectsToDisplay = getProjectsForCurrentPage(); - for (let project of projectsToDisplay) { - if (project.seeMoreButton) { - const btn = project.seeMoreButton; - if ( - mouseX >= btn.x && - mouseX <= btn.x + btn.width && - mouseY >= btn.y && - mouseY <= btn.y + btn.height - ) { - openDetailsMenu(project); - break; - } - } - } - }); - listenersAttached = true; - } } else if (selectedProject) { ctx.fillStyle = "rgba(8, 17, 31, 0.62)"; ctx.fillRect(0, 0, width, height); @@ -1427,62 +1386,68 @@ function drawMenu() { const menuY = isMobile ? 24 : 42; const menuWidth = width - horizontalMargin * 2; const menuHeight = height - menuY * 2; - drawPanel(menuX, menuY, menuWidth, menuHeight, 30, "rgba(250, 253, 255, 0.94)"); + drawPanel(menuX, menuY, menuWidth, menuHeight, 30, uiTheme.panel); - ctx.fillStyle = uiTheme.text; - ctx.font = `700 ${isMobile ? 24 : 34}px ${uiTheme.font}`; - ctx.textAlign = "left"; - ctx.textBaseline = "top"; - ctx.fillText(selectedProject.name, menuX + 32, menuY + 28); - - ctx.font = `${isMobile ? 15 : 17}px ${uiTheme.font}`; - ctx.textAlign = "left"; - - const detailsTextX = menuX + 34; - const detailsTextY = menuY + (isMobile ? 88 : 104); - const detailsTextWidth = menuWidth - 68; + 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, detailsTextY - 24, menuWidth - 48, menuHeight - 150); + 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 > menuY + 78 && lineY < menuY + menuHeight - 82) { + if (lineY > contentTop && lineY < contentBottom) { ctx.fillText(line, detailsTextX, lineY); } }); - if (selectedProject.link) { - const linkText = selectedProject.link.text; - const linkUrl = selectedProject.link.url; - const linkX = detailsTextX; - const linkY = detailsTextY + totalTextHeight + 32 - detailsScrollPosition; - const linkPadding = 14; - ctx.font = `700 16px ${uiTheme.font}`; - const linkWidth = ctx.measureText(linkText).width + linkPadding * 2; - - if (linkY > menuY + 88 && linkY < menuY + menuHeight - 82) { - drawButton(linkX, linkY - 26, linkWidth, 42, linkText, { - fill: uiTheme.accent, - radius: 15, - size: 16 - }); - } - - selectedProject.linkPosition = { - x: linkX, - y: linkY - 26, - width: linkWidth, - height: 42, - url: linkUrl - }; + 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; @@ -1498,7 +1463,7 @@ function drawMenu() { imageHeight *= scale; const imageX = menuX + (menuWidth - imageWidth) / 2; - const imageY = linkY + 44; + 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); @@ -1509,7 +1474,7 @@ function drawMenu() { ctx.save(); roundedRectPath(imageX, visibleImageY, imageWidth, visibleImageHeight, 20); ctx.clip(); - ctx.drawImage( + drawImageSafe( selectedProject.loadedImage, imageX, visibleImageY, @@ -1539,29 +1504,11 @@ function drawMenu() { height: backButtonHeight }; - if (!detailsBackListenerAttached) { - canvas.addEventListener("click", (e) => { - if (!selectedProject || !selectedProject.backButton) return; - const rect = canvas.getBoundingClientRect(); - const mouseX = e.clientX - rect.left; - const mouseY = e.clientY - rect.top; - const btn = selectedProject.backButton; - if ( - mouseX >= btn.x && - mouseX <= btn.x + btn.width && - mouseY >= btn.y && - mouseY <= btn.y + btn.height - ) { - closeDetailsMenu(); - } - }); - detailsBackListenerAttached = true; - } - - detailsMaxScroll = Math.max( - totalTextHeight + 40 + (selectedProject.loadedImage ? selectedProject.loadedImage.height : 0) - menuHeight + 160, - 0 - ); + 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; @@ -1570,191 +1517,148 @@ function drawMenu() { } } -function handleScroll(event) { - if (selectedProject) { - detailsScrollPosition += event.deltaY > 0 ? detailsScrollStep : -detailsScrollStep; - detailsScrollPosition = Math.max(0, Math.min(detailsScrollPosition, detailsMaxScroll)); - drawMenu(); +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 = ""; } -} - -function openDetailsMenu(project) { - console.log("Project Object:", project); - const longDescriptionElement = document.querySelector(`.long-description[data-project-name="${project.name}"]`); - if (longDescriptionElement) { - project.longDescription = longDescriptionElement.innerHTML; - console.log("Long Description:", project.longDescription); - } else { - console.log("Long description element not found for project:", project.name); - project.longDescription = ""; - } - const linkElement = document.querySelector(`.link[data-project-name="${project.name}"]`); - if (linkElement) { - project.link = { - text: linkElement.textContent, - url: linkElement.href - }; - } - menuVisible = false; - selectedProject = project; - drawMenu(); -} - -canvas.addEventListener('click', function (event) { - const rect = canvas.getBoundingClientRect(); - const mouseX = event.clientX - rect.left; - const mouseY = event.clientY - rect.top; - if (menuVisible) { - const projectsToDisplay = getProjectsForCurrentPage(); - for (let project of projectsToDisplay) { - if (project.seeMoreButton) { - const btn = project.seeMoreButton; - if (mouseX >= btn.x && mouseX <= btn.x + btn.width && - mouseY >= btn.y && mouseY <= btn.y + btn.height) { - openDetailsMenu(project); - break; - } - } - } - } -}); - -function closeDetailsMenu() { - selectedProject = null; - menuVisible = true; - drawMenu(); -} - -function handleLinkClick(e) { - const rect = canvas.getBoundingClientRect(); - const mouseX = e.clientX - rect.left; - const mouseY = e.clientY - rect.top; - if (selectedProject && selectedProject.linkPosition) { - const linkPos = selectedProject.linkPosition; - if ( - mouseX >= linkPos.x && mouseX <= linkPos.x + linkPos.width && - mouseY >= linkPos.y && mouseY <= linkPos.y + linkPos.height - ) { - window.open(linkPos.url, "_blank"); - e.stopPropagation(); - } - } -} - -canvas.removeEventListener("click", handleLinkClick); -canvas.addEventListener("click", handleLinkClick); -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"); - -let dialogVisible = false; - -// Move Left Button Logic -moveLeftButton.addEventListener("mousedown", () => { - keyState.ArrowLeft = true; - character.dx = -character.speed; - character.facingRight = true; -}); - -moveLeftButton.addEventListener("mouseup", () => { - keyState.ArrowLeft = false; - character.dx = 0; -}); - -moveLeftButton.addEventListener("touchstart", (e) => { - e.preventDefault(); - keyState.ArrowLeft = true; - character.dx = -character.speed; - character.facingRight = true; -}); - -moveLeftButton.addEventListener("touchend", (e) => { - e.preventDefault(); - keyState.ArrowLeft = false; - character.dx = 0; -}); - -// Move Right Button Logic -moveRightButton.addEventListener("mousedown", () => { - keyState.ArrowRight = true; - character.dx = character.speed; - character.facingRight = false; -}); - -moveRightButton.addEventListener("mouseup", () => { - keyState.ArrowRight = false; - character.dx = 0; -}); - -moveRightButton.addEventListener("touchstart", (e) => { - e.preventDefault(); - keyState.ArrowRight = true; - character.dx = character.speed; - character.facingRight = false; -}); - -moveRightButton.addEventListener("touchend", (e) => { - e.preventDefault(); - keyState.ArrowRight = false; - character.dx = 0; -}); - -// Jump Button Logic -jumpButton.addEventListener("mousedown", () => { - if (!isJumping) { - character.dy = -15; - isJumping = true; - } -}); - -jumpButton.addEventListener("touchstart", (e) => { - e.preventDefault(); - if (!isJumping) { - character.dy = -15; - isJumping = true; - } -}); - -// Interact Button Logic -interactButton.addEventListener("mousedown", () => { - const interactionKeyEvent = new KeyboardEvent("keydown", {key: "Enter"}); // Simulate "Enter" key - window.dispatchEvent(interactionKeyEvent); -}); - -interactButton.addEventListener("touchstart", (e) => { - e.preventDefault(); - const interactionKeyEvent = new KeyboardEvent("keydown", {key: "Enter"}); // Simulate "Enter" key - window.dispatchEvent(interactionKeyEvent); -}); - - -interactCharacterButton.addEventListener("mousedown", () => { - const interactionKeyEvent = new KeyboardEvent("keydown", {key: "e"}); // Simulate "e" key - window.dispatchEvent(interactionKeyEvent); -}); - -interactCharacterButton.addEventListener("touchstart", (e) => { - e.preventDefault(); - const interactionKeyEvent = new KeyboardEvent("keydown", {key: "e"}); // Simulate "e" key - window.dispatchEvent(interactionKeyEvent); -}); - -// Function to toggle the visibility of the "Interact with Character" button -function updateMobileControls() { - if (gameState === "inside") { - interactCharacterButton.classList.remove("hidden"); // Show the button inside the house - } else { - interactCharacterButton.classList.add("hidden"); // Hide the button outside the house - npcDialog.classList.add("hidden"); // Automatically close the dialogue menu when leaving - dialogVisible = false; // Reset the dialogue visibility state - } -} - -// Listen for game state changes to update controls -window.addEventListener("gameStateChange", () => { - updateMobileControls(); -}); - -updateMobileControls(); + 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(); diff --git a/style.css b/style.css index bbc6810..749f675 100644 --- a/style.css +++ b/style.css @@ -1,478 +1,377 @@ -html, body { - margin: 0; - padding: 0; - height: 100%; - overflow-x: hidden; - background: linear-gradient(180deg, #8fc9f2 0%, #eef8ff 100%); - font-family: "Trebuchet MS", Verdana, sans-serif; -} - -#welcome-screen { - position: fixed; - top: 0; - left: 0; - width: 100%; - height: 100%; - color: #fff; - display: flex; - justify-content: center; - align-items: center; - z-index: 10; - background-color: #B3C0CA; - opacity: 1; - transition: opacity 0.5s ease; - overflow: hidden; -} - -#welcome-screen.hidden { - opacity: 0; - pointer-events: none; -} - -#main-content { - opacity: 0; - visibility: hidden; - position: relative; - top: 0; - left: 0; - width: 100%; - height: auto; - background-color: #B3C0CA; - transition: opacity 0.5s ease; - display: flex; - flex-direction: column; - align-items: center; - padding: 20px; - color: #fff; - overflow-y: auto; -} - -#main-content.visible { - opacity: 1; - visibility: visible; - transition: opacity 0.5s ease; -} - -#welcome-content { - display: flex; - flex-direction: column; - align-items: center; - text-align: center; - transition: background-color 0.5s ease; -} - -#welcome-text { - color: white; - font-size: 24px; - margin-bottom: 20px; - text-align: center; - font-family: 'Pacifico', cursive; -} - -#enter-button { - cursor: pointer; - width: 200px; - height: auto; - transition: transform 0.3s ease; -} - -#enter-button:hover { - transform: scale(1.1); -} - -#snow-container { - position: fixed; - top: 0; - left: 0; - width: 100%; - height: 100%; - pointer-events: none; - z-index: 1; - overflow: hidden; -} - -.snowflake { - position: absolute; - top: -10px; - background-color: white; - border-radius: 50%; - opacity: 0.8; - pointer-events: none; - animation: fall linear infinite; - z-index: -1 -} - -.paused .snowflake { - animation-play-state: paused; -} - -@keyframes fall { - to { - transform: translateY(100vh); - } -} - -p { - font-size: 18px -} - -.paused { - animation-play-state: paused; -} - -.hidden { - display: none -} - -.snowflake:nth-child(odd) { - animation-duration: 4s; - width: 10px; - height: 10px; -} - -.snowflake:nth-child(even) { - animation-duration: 6s; - width: 8px; - height: 8px; -} - -.content-section { - max-width: 900px; - margin: 20px auto; - padding: 20px; - background: #f9f9f9; - border-radius: 10px; - box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); - transition: transform 0.5s, opacity 3s; - opacity: 0; - transform: translateY(20px); -} - -.content-section.visible { - transform: translateY(0); - opacity: 1; -} - -h1, h2 { - text-align: center; - color: #333; -} - -p { - text-align: center; - color: #555; -} - -.content-section-top { - max-width: 900px; - margin: 20px auto; - padding: 20px; - background: #f9f9f9; - border-radius: 10px; - box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); - transform: translateY(20px); -} - -.content-section:nth-child(even) { - margin-left: auto; - margin-right: 50px; -} - -.content-section:nth-child(odd) { - margin-right: auto; - margin-left: 50px; -} - -@media (max-width: 768px) { - .content-section { - margin: 20px auto; - } -} - -#game-container { - display: none; - position: relative; - width: 100%; - height: 100vh; - overflow: hidden; - background: #8fc9f2; -} - -#game-canvas { - display: block; - width: 100%; - height: 100%; -} - -.tutorial { - position: fixed; - top: 0; - left: 0; - width: 100vw; - height: 100vh; - background-color: rgba(0, 0, 0, 0.8); - color: white; - display: flex; - align-items: center; - justify-content: center; - z-index: 1000; -} - -.tutorial-content { - background: #333; - padding: 20px; - border-radius: 10px; - text-align: center; - max-width: 500px; -} - -.tutorial-content button { - margin-top: 20px; - padding: 10px 20px; - font-size: 1rem; - color: white; - background: #007bff; - border: none; - border-radius: 5px; - cursor: pointer; -} - -.tutorial.hidden { - display: none; -} - -#block-info.hidden { - display: none; -} - -#block-info { - background: rgba(248, 252, 255, 0.94); - border: 1px solid rgba(255, 255, 255, 0.72); - border-radius: 22px; - padding: 18px; - width: 280px; - text-align: center; - font-family: "Trebuchet MS", Verdana, sans-serif; - color: #102033; - font-size: 14px; - z-index: 10; - animation: fade-in 0.5s ease-out; - position: absolute; - box-shadow: 0 24px 60px rgba(9, 24, 43, 0.24); - backdrop-filter: blur(12px); -} - -#block-info h1 { - margin: 0 0 8px; - color: #102033; -} - -#block-info p { - margin: 0; - color: #496071; - line-height: 1.45; -} - -@keyframes fade-in { - from { - opacity: 0; - transform: translateY(20px); - } - to { - opacity: 1; - transform: translateY(0); - } -} - -#projects { - flex-direction: row; - justify-content: space-between; - align-items: stretch; - flex-wrap: wrap; - padding: 10px; - background-color: #5a322a; - height: 100%; - display: none -} - -.project { - flex: 1; - margin: 10px; - padding: 10px; - background-color: white; - border-radius: 5px; - box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); - text-align: left; - overflow: hidden; - word-wrap: break-word; -} - -.project h3 { - font-size: 1.5em; - margin-bottom: 0.5em; -} - -.project-description { - font-size: 1em; - line-height: 1.4; -} - -button { - margin-top: 10px; - padding: 10px 16px; - color: white; - background: #102033; - border: none; - cursor: pointer; - border-radius: 14px; - box-shadow: 0 10px 24px rgba(9, 24, 43, 0.16); - font-family: inherit; - transition: transform 0.16s ease, box-shadow 0.16s ease, background-color 0.16s ease; -} - -button:hover { - transform: translateY(-1px); - box-shadow: 0 14px 30px rgba(9, 24, 43, 0.22); -} - -#turn-device-sideways { - display: none; -} - -#mobile-controls { - position: fixed; - bottom: 14px; - width: 100%; - display: flex; - justify-content: center; - gap: 10px; - z-index: 20; - pointer-events: none; -} - -#interact-character-button { - position: absolute; - bottom: 66px; - left: 50%; - transform: translateX(-50%); - background: #2f7dd3; -} - -#interact-button, -#move-left-button, -#jump-button, -#move-right-button { - display: inline-block; - padding: 12px 18px; - font-size: 16px; - background: rgba(16, 32, 51, 0.88); - border: 1px solid rgba(255, 255, 255, 0.24); - backdrop-filter: blur(10px); - pointer-events: auto; -} - -#mobile-controls button:active { - background-color: #2f7dd3; - transform: translateY(1px) scale(0.98); -} - -#npc-dialog { - position: fixed; - top: 20px; - left: 50%; - transform: translateX(-50%); - width: 80%; - max-width: 600px; - background: rgba(248, 252, 255, 0.94); - padding: 24px; - border-radius: 26px; - border: 1px solid rgba(255, 255, 255, 0.72); - box-shadow: 0 30px 80px rgba(9, 24, 43, 0.28); - backdrop-filter: blur(16px); - z-index: 1000; -} - -#npc-dialog p { - color: #102033; - line-height: 1.5; -} - -#npc-dialog button { - width: 100%; - background: #102033; -} - -@media (min-width: 768px) { - #mobile-controls { - display: none; - } -} - -@media screen and (max-height: 480px) { - #welcome-text { - display: none; - } - - #enter-button { - display: none; - } - - #turn-device-sideways { - display: block; - } -} - -.dark-mode { - filter: brightness(0.9); -} - -.dark-mode a { - color: #BB86FC; -} - -.dark-mode button { - background-color: #333; - color: #fff; - border-color: #444; -} - -.dark-mode #welcome-screen { - background-color: #333; -} - -.dark-mode #game-container { - background-color: #222; -} - -.dark-mode .content-section, -.dark-mode .project { - background-color: #1e1e1e; - color: #fff; -} - -.dark-mode-toggle { - position: fixed; - top: 20px; - right: 20px; - z-index: 1000; - background: black; - border: none; - font-size: 1.5rem; - cursor: pointer; -} - -.dark-mode-toggle.dark-mode { - background: white; -} - -.dark-mode body { - background-color: #000; -} - -.dark-mode #main-content { - background-color: #000; -} - -.dark-mode #welcome-screen { - background-color: #333; +:root { + color-scheme: light; + --ink: #182b3d; + --muted: #4a6071; + --paper: #eef6f8; + --panel: #f3f8f9; + --line: #547389; + --accent: #265d82; +} + +* { box-sizing: border-box; } + +html, body { + width: 100%; + min-height: 100%; + margin: 0; +} + +body { + overflow: hidden; + background: #b9d9ed; + color: var(--ink); + font-family: "Courier New", monospace; +} + +body.dark-mode { + color-scheme: dark; + --ink: #edf3f5; + --muted: #bdced7; + --paper: #1d2e3d; + --panel: #243747; + --line: #8ba8b9; + --accent: #a9d9ef; + background: #111d2a; +} + +button { font: inherit; cursor: pointer; } +button:focus-visible, a:focus-visible, canvas:focus-visible { + outline: 3px solid #d48439; + outline-offset: 3px; +} + +.hidden { display: none; } + +.no-script { + position: fixed; + inset: 0; + z-index: 100; + display: grid; + place-content: center; + gap: 12px; + padding: 24px; + background: #e6f3fa; + color: #182b3d; + text-align: center; +} +.no-script a { color: #265d82; } + +#welcome-screen { + position: fixed; + inset: 0; + z-index: 10; + display: grid; + place-items: center; + overflow-y: auto; + padding: 70px 22px 32px; + background: + radial-gradient(circle at 20% 18%, rgba(255,255,255,.56) 0 2px, transparent 3px), + radial-gradient(circle at 76% 32%, rgba(255,255,255,.55) 0 2px, transparent 3px), + linear-gradient(#91bfdc, #d9edf7 78%, #f5fafb); + transition: opacity .7s ease; +} +body.dark-mode #welcome-screen { + background: + radial-gradient(circle at 20% 18%, rgba(255,255,255,.48) 0 2px, transparent 3px), + radial-gradient(circle at 76% 32%, rgba(255,255,255,.42) 0 2px, transparent 3px), + linear-gradient(#152738, #304b60 78%, #627d8c); +} +#welcome-screen.hidden { + display: grid; + opacity: 0; + pointer-events: none; +} + +#welcome-content { + transition: opacity .5s ease, transform .7s ease; +} +#welcome-screen.hidden #welcome-content { + opacity: 0; + transform: translateY(-14px); +} + +#welcome-content { + width: min(620px, 100%); + text-align: center; +} +.welcome-penguin { + display: block; + width: clamp(96px, 15vw, 142px); + height: clamp(96px, 15vw, 142px); + margin: 0 auto 18px; + object-fit: contain; + image-rendering: pixelated; +} +.eyebrow { + margin: 0 0 12px; + font-size: .9rem; + font-weight: bold; + letter-spacing: .28em; + text-transform: uppercase; +} +#welcome-text { + margin: 0; + font-size: clamp(2.25rem, 6vw, 4.4rem); + font-weight: bold; + line-height: 1.08; + letter-spacing: -.055em; +} +.welcome-intro { + margin: 21px 0 28px; + color: var(--muted); + font-size: clamp(.94rem, 1.5vw, 1.08rem); + line-height: 1.6; +} +#enter-button { + min-width: 172px; + min-height: 54px; + padding: 9px 22px; + border: 3px solid #162b3c; + border-radius: 2px; + background: #244d69; + box-shadow: 5px 5px 0 #162b3c; + color: #fff; + font-size: 1.08rem; + font-weight: bold; + transition: transform .12s ease, box-shadow .12s ease; +} +#enter-button:hover { + transform: translate(2px, 2px); + box-shadow: 3px 3px 0 #162b3c; +} +#enter-button span { margin-left: 15px; } +.control-guide { + max-width: 520px; + margin: 34px auto 0; + padding-top: 16px; + border-top: 1px solid rgba(24,43,61,.4); + color: var(--muted); + font-size: .78rem; + line-height: 1.8; +} + +.dark-mode-toggle { + position: fixed; + top: 14px; + right: 14px; + z-index: 40; + display: grid; + place-items: center; + width: 42px; + height: 42px; + padding: 0; + border: 2px solid var(--line); + border-radius: 2px; + background: var(--paper); + color: var(--ink); +} +.dark-mode-toggle:hover { background: var(--panel); } +.theme-icon { font-size: 1.4rem; line-height: 1; } +.theme-label { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip-path: inset(50%); + white-space: nowrap; +} +body.menu-open .dark-mode-toggle { + opacity: 0; + pointer-events: none; +} + +#sound-toggle { + position: fixed; + bottom: 14px; + left: 14px; + z-index: 20; + display: none; + place-items: center; + width: 42px; + height: 42px; + padding: 0; + border: 2px solid var(--line); + border-radius: 2px; + background: var(--paper); + color: var(--ink); + font-size: 1.45rem; +} +body.game-started #sound-toggle { display: grid; } +#sound-toggle[hidden], body.menu-open #sound-toggle { display: none; } +#sound-toggle:hover { background: var(--panel); } +#sound-toggle[aria-pressed="false"]::after { + content: ""; + position: absolute; + width: 29px; + height: 2px; + background: currentColor; + transform: rotate(-45deg); +} + +#game-container { + position: relative; + display: none; + width: 100%; + height: 100vh; + height: 100dvh; + overflow: hidden; + background: #b4d8ed; + opacity: 0; + transition: opacity .75s ease; +} +#game-container.visible { opacity: 1; } +#game-canvas { + display: block; + width: 100%; + height: 100%; + touch-action: none; +} + +#block-info { + position: absolute; + z-index: 5; + width: min(300px, calc(100vw - 30px)); + padding: 16px; + border: 2px solid var(--line); + border-radius: 2px; + background: var(--panel); + color: var(--ink); +} +#block-info h1 { margin: 0 0 8px; font-size: 1.1rem; } +#block-info p { margin: 0; color: var(--muted); font-size: .78rem; line-height: 1.5; } +body.menu-open #block-info { display: none; } + +#projects { display: none; } + +#snow-container { + position: fixed; + inset: 0; + z-index: 2; + overflow: hidden; + pointer-events: none; +} +body.menu-open #snow-container { display: none; } +.snowflake { + position: absolute; + top: -12px; + width: 6px; + height: 6px; + background: #fff; + opacity: .8; + animation: fall linear infinite; +} +.paused .snowflake { animation-play-state: paused; } +@keyframes fall { to { transform: translateY(110vh); } } + +#npc-dialog { + position: fixed; + top: clamp(20px, 8vh, 80px); + left: 50%; + z-index: 30; + width: min(540px, calc(100vw - 30px)); + max-height: calc(100dvh - 40px); + overflow-y: auto; + padding: 20px; + border: 3px solid var(--line); + border-radius: 2px; + background: var(--panel); + color: var(--ink); + transform: translateX(-50%); +} +.dialog-heading { + display: flex; + align-items: center; + justify-content: space-between; + gap: 14px; + font-size: 1.05rem; + font-weight: bold; +} +#npc-dialog-close { + width: 34px; + height: 34px; + border: 2px solid var(--line); + border-radius: 2px; + background: var(--paper); + color: var(--ink); + font-size: 1.3rem; +} +#npc-dialog-text { + margin: 18px 0 20px; + color: var(--muted); + font-size: .86rem; + line-height: 1.6; +} +#npc-dialog-content > button:not(#npc-dialog-close) { + display: block; + width: 100%; + min-height: 42px; + margin-top: 8px; + padding: 9px 12px; + border: 2px solid var(--line); + border-radius: 2px; + background: var(--paper); + color: var(--ink); + font-size: .82rem; + font-weight: bold; + text-align: left; +} +#npc-dialog-content > button:not(#npc-dialog-close):hover { background: var(--panel); } +.dialog-hint { margin: 17px 0 0; color: var(--muted); font-size: .72rem; } + +#mobile-controls { display: none; } + +@media (max-width: 767px) { + #welcome-screen { padding: 70px 20px 26px; } + #welcome-text { font-size: clamp(2.15rem, 11vw, 3.4rem); } + .control-guide { margin-top: 27px; font-size: .72rem; } + #sound-toggle { top: 14px; right: 64px; bottom: auto; left: auto; } + body.game-started #mobile-controls { + position: fixed; + right: 12px; + bottom: max(12px, env(safe-area-inset-bottom)); + left: 12px; + z-index: 6; + display: flex; + align-items: end; + gap: 7px; + pointer-events: none; + } + #mobile-controls button { + display: grid; + place-items: center; + min-width: 52px; + height: 52px; + padding: 0 10px; + border: 2px solid #a4c1d2; + border-radius: 2px; + background: #18374d; + color: #fff; + font-size: 1.25rem; + font-weight: bold; + pointer-events: auto; + touch-action: none; + } + #mobile-controls button:active { background: #326789; } + #mobile-controls .hidden, body.menu-open #mobile-controls { display: none; } + #interact-button { margin-left: auto; font-size: .84rem !important; } + #interact-character-button { + position: absolute; + right: 0; + bottom: 61px; + background: #265d82 !important; + font-size: .84rem !important; + } + #npc-dialog { padding: 16px; } +} + +@media (max-width: 390px) { + #mobile-controls button { min-width: 45px; height: 48px; } +} + +@media (prefers-reduced-motion: reduce) { + *, *::before, *::after { + animation-duration: .01ms !important; + transition-duration: .01ms !important; + } + .snowflake { display: none; } }