|
import { createServer } from "node:http"; |
|
import { joinSession, createCanvas } from "@github/copilot-sdk/extension"; |
|
|
|
const servers = new Map(); |
|
const gameStates = new Map(); |
|
|
|
function renderHtml() { |
|
return `<!doctype html> |
|
<html> |
|
<head> |
|
<meta charset="utf-8" /> |
|
<title>Downhill Ski</title> |
|
<style> |
|
* { margin: 0; padding: 0; box-sizing: border-box; } |
|
html, body { width: 100%; height: 100%; overflow: hidden; } |
|
body { |
|
background: var(--background-color-default, #f0f8ff); |
|
font-family: var(--font-sans, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif); |
|
} |
|
canvas { display: block; width: 100%; height: 100%; } |
|
#overlay { |
|
position: absolute; top: 0; left: 0; width: 100%; height: 100%; |
|
display: flex; flex-direction: column; align-items: center; justify-content: center; |
|
background: rgba(0,0,0,0.6); color: white; z-index: 10; |
|
} |
|
#overlay.hidden { display: none; } |
|
#overlay h1 { font-size: 2.5rem; margin-bottom: 0.5rem; } |
|
#overlay p { font-size: 1.1rem; margin-bottom: 1rem; opacity: 0.8; } |
|
#overlay button { |
|
padding: 12px 32px; font-size: 1.1rem; border: none; border-radius: 6px; |
|
background: #2ea44f; color: white; cursor: pointer; font-weight: 600; |
|
} |
|
#overlay button:hover { background: #2c974b; } |
|
#hud { |
|
position: absolute; top: 12px; left: 12px; right: 12px; |
|
display: flex; justify-content: space-between; |
|
font-size: 14px; font-weight: 600; color: #1f2328; |
|
pointer-events: none; z-index: 5; |
|
} |
|
#hud span { |
|
background: rgba(255,255,255,0.85); padding: 4px 10px; border-radius: 4px; |
|
} |
|
</style> |
|
</head> |
|
<body> |
|
<div id="hud"><span id="score-display">Score: 0</span><span id="lives-display">Lives: 3</span><span id="speed-display">Speed: 1x</span></div> |
|
<div id="overlay"> |
|
<h1>⛷️ Downhill Ski</h1> |
|
<p>Dodge trees and rocks. Hit ramps for air time bonus!<br/>← → or A/D to steer. Space/Enter to start.</p> |
|
<button id="start-btn">Start Game</button> |
|
</div> |
|
<canvas id="game"></canvas> |
|
<script> |
|
const cvs = document.getElementById("game"); |
|
const ctx = cvs.getContext("2d"); |
|
const overlay = document.getElementById("overlay"); |
|
const startBtn = document.getElementById("start-btn"); |
|
const scoreDisplay = document.getElementById("score-display"); |
|
const livesDisplay = document.getElementById("lives-display"); |
|
const speedDisplay = document.getElementById("speed-display"); |
|
|
|
let W, H; |
|
function resize() { W = cvs.width = window.innerWidth; H = cvs.height = window.innerHeight; } |
|
resize(); |
|
window.addEventListener("resize", resize); |
|
|
|
// Game state |
|
let running = false; |
|
let score = 0; |
|
let lives = 3; |
|
let highScore = 0; |
|
let speed = 0.5; |
|
let speedBonus = 0; |
|
let skier = { x: 0, y: 0, angle: 0, crashed: false, airborne: false, airTime: 0, airHeight: 0, shadow: 0 }; |
|
let obstacles = []; |
|
let ramps = []; |
|
let otherSkiers = []; |
|
let slalomGates = []; |
|
let particles = []; |
|
let trails = []; |
|
let frameCount = 0; |
|
let airBonusFloaters = []; // floating "+N AIR!" text |
|
|
|
const keys = {}; |
|
window.addEventListener("keydown", e => { |
|
keys[e.key] = true; |
|
// Restart/continue with Space or Enter when not running |
|
if ((e.key === " " || e.key === "Enter") && !running) { |
|
e.preventDefault(); |
|
if (lives > 0 && skier.crashed) { |
|
startRun(); // continue with remaining lives |
|
} else { |
|
startGame(); // new game |
|
} |
|
} |
|
}); |
|
window.addEventListener("keyup", e => { keys[e.key] = false; }); |
|
|
|
function startGame() { |
|
// Fresh game: reset lives and score |
|
lives = 3; |
|
score = 0; |
|
startRun(); |
|
} |
|
|
|
function startRun() { |
|
overlay.classList.add("hidden"); |
|
running = true; |
|
speed = 0.5; |
|
speedBonus = 0; |
|
frameCount = 0; |
|
skier = { x: W / 2, y: H * 0.25, angle: 0, crashed: false, airborne: false, airTime: 0, airHeight: 0, shadow: 0 }; |
|
obstacles = []; |
|
ramps = []; |
|
otherSkiers = []; |
|
slalomGates = []; |
|
particles = []; |
|
trails = []; |
|
airBonusFloaters = []; |
|
livesDisplay.textContent = "Lives: " + lives; |
|
for (let i = 0; i < 15; i++) { |
|
spawnObstacle(Math.random() * H); |
|
} |
|
for (let i = 0; i < 3; i++) { |
|
spawnRamp(H * 0.5 + Math.random() * H); |
|
} |
|
} |
|
|
|
startBtn.addEventListener("click", startGame); |
|
|
|
function spawnObstacle(y) { |
|
const type = Math.random() < 0.65 ? "tree" : "rock"; |
|
obstacles.push({ |
|
x: Math.random() * (W - 40) + 20, |
|
y: y !== undefined ? y : H + 20 + Math.random() * 100, |
|
type, |
|
size: type === "tree" ? 14 + Math.random() * 10 : 10 + Math.random() * 8, |
|
}); |
|
} |
|
|
|
function spawnRamp(y) { |
|
ramps.push({ |
|
x: Math.random() * (W - 100) + 50, |
|
y: y !== undefined ? y : H + 50 + Math.random() * 300, |
|
width: 50 + Math.random() * 30, |
|
hit: false, |
|
}); |
|
} |
|
|
|
function spawnOtherSkier(y) { |
|
const isSnowboarder = Math.random() < 0.4; |
|
otherSkiers.push({ |
|
x: Math.random() * (W - 60) + 30, |
|
y: y !== undefined ? y : H + 30 + Math.random() * 200, |
|
vx: (Math.random() - 0.5) * 2, // slight lateral drift |
|
type: isSnowboarder ? "snowboarder" : "skier", |
|
color: isSnowboarder ? "#e91e63" : ["#ff5722", "#9c27b0", "#00bcd4"][Math.floor(Math.random() * 3)], |
|
speedOffset: (Math.random() - 0.5) * 1.5, // some go slightly faster/slower than you |
|
}); |
|
} |
|
|
|
function spawnSlalomTrail(y) { |
|
// A slalom trail is a sequence of gates snaking left-right |
|
const startY = y !== undefined ? y : H + 100; |
|
const startX = W * 0.3 + Math.random() * W * 0.4; |
|
const gateCount = 4 + Math.floor(Math.random() * 3); // 4-6 gates |
|
const trailId = Date.now() + Math.random(); |
|
const gateSpacing = 70; |
|
let x = startX; |
|
|
|
for (let i = 0; i < gateCount; i++) { |
|
const direction = (i % 2 === 0) ? 1 : -1; |
|
x += direction * (40 + Math.random() * 30); |
|
x = Math.max(60, Math.min(W - 60, x)); |
|
slalomGates.push({ |
|
x: x, |
|
y: startY + i * gateSpacing, |
|
trailId: trailId, |
|
index: i, |
|
total: gateCount, |
|
passed: false, |
|
missed: false, |
|
width: 40, |
|
}); |
|
} |
|
} |
|
|
|
function update() { |
|
if (!running || skier.crashed) return; |
|
frameCount++; |
|
|
|
// Start slow and gradually increase speed |
|
// Begins at 0.5, accelerates smoothly toward a cap of 14 |
|
const baseSpeed = 0.5 + frameCount * 0.003; |
|
|
|
// Snowplow brake (up arrow / W) and tuck boost (down arrow / S) — only on ground |
|
if (!skier.airborne) { |
|
if (keys["ArrowUp"] || keys["w"] || keys["W"]) { |
|
speedBonus -= 0.04; |
|
if (speedBonus < -baseSpeed * 0.6) speedBonus = -baseSpeed * 0.6; |
|
} else if (keys["ArrowDown"] || keys["s"] || keys["S"]) { |
|
speedBonus += 0.02; |
|
} |
|
} |
|
|
|
speed = Math.max(0.3, Math.min(baseSpeed, 14) + speedBonus); |
|
|
|
// Steering (reduced control while airborne) |
|
const turnSpeed = skier.airborne ? 1.5 : 4; |
|
if (keys["ArrowLeft"] || keys["a"] || keys["A"]) { |
|
skier.x -= turnSpeed; |
|
skier.angle = -0.3; |
|
} else if (keys["ArrowRight"] || keys["d"] || keys["D"]) { |
|
skier.x += turnSpeed; |
|
skier.angle = 0.3; |
|
} else { |
|
skier.angle *= 0.8; |
|
} |
|
|
|
// Keep skier in bounds |
|
if (skier.x < 20) skier.x = 20; |
|
if (skier.x > W - 20) skier.x = W - 20; |
|
|
|
// Airborne physics — faster = higher and longer air |
|
if (skier.airborne) { |
|
skier.airTime++; |
|
const totalFlight = 50 + speed * 8; |
|
const progress = skier.airTime / totalFlight; |
|
skier.airHeight = Math.sin(progress * Math.PI) * (20 + speed * 5); |
|
skier.shadow = skier.airHeight * 0.4; |
|
|
|
if (skier.airTime >= totalFlight) { |
|
// Land! Bonus scales with air time and speed |
|
const bonus = Math.floor(skier.airTime * speed * 0.5); |
|
score += bonus; |
|
airBonusFloaters.push({ x: skier.x, y: skier.y - 40, text: "+" + bonus + " AIR!", age: 0 }); |
|
skier.airborne = false; |
|
skier.airTime = 0; |
|
skier.airHeight = 0; |
|
skier.shadow = 0; |
|
// Boost speed by 5% after landing a jump |
|
speedBonus += speed * 0.05; |
|
} |
|
} |
|
|
|
// Move obstacles up (skier moves down the hill) |
|
for (let i = obstacles.length - 1; i >= 0; i--) { |
|
obstacles[i].y -= speed; |
|
if (obstacles[i].y < -50) { |
|
obstacles.splice(i, 1); |
|
score += 10; |
|
} |
|
} |
|
|
|
// Move ramps up |
|
for (let i = ramps.length - 1; i >= 0; i--) { |
|
ramps[i].y -= speed; |
|
if (ramps[i].y < -80) { |
|
ramps.splice(i, 1); |
|
} |
|
} |
|
|
|
// Spawn new obstacles and ramps |
|
while (obstacles.length < 12 + Math.floor(speed)) { |
|
spawnObstacle(H + 20 + Math.random() * 200); |
|
} |
|
if (ramps.length < 2 && Math.random() < 0.02) { |
|
spawnRamp(); |
|
} |
|
|
|
// Spawn other skiers occasionally |
|
if (otherSkiers.length < 3 && Math.random() < 0.008) { |
|
spawnOtherSkier(); |
|
} |
|
|
|
// Spawn slalom trails occasionally |
|
const activeTrails = new Set(slalomGates.map(g => g.trailId)); |
|
if (activeTrails.size < 1 && Math.random() < 0.005) { |
|
spawnSlalomTrail(); |
|
} |
|
|
|
// Move other skiers |
|
for (let i = otherSkiers.length - 1; i >= 0; i--) { |
|
const other = otherSkiers[i]; |
|
other.y -= (speed + other.speedOffset); |
|
other.x += other.vx; |
|
// Bounce off edges |
|
if (other.x < 30 || other.x > W - 30) other.vx *= -1; |
|
if (other.y < -60) { |
|
otherSkiers.splice(i, 1); |
|
} |
|
} |
|
|
|
// Move slalom gates |
|
for (let i = slalomGates.length - 1; i >= 0; i--) { |
|
slalomGates[i].y -= speed; |
|
if (slalomGates[i].y < -60) { |
|
slalomGates.splice(i, 1); |
|
} |
|
} |
|
|
|
// Slalom gate passing detection |
|
if (!skier.airborne) { |
|
for (const gate of slalomGates) { |
|
if (!gate.passed && !gate.missed && Math.abs(skier.y - gate.y) < 12) { |
|
if (Math.abs(skier.x - gate.x) < gate.width * 0.6) { |
|
gate.passed = true; |
|
// Check if all gates in this trail are passed |
|
const trailGates = slalomGates.filter(g => g.trailId === gate.trailId); |
|
const allPassed = trailGates.filter(g => g.index <= gate.index).every(g => g.passed); |
|
if (allPassed) { |
|
const bonus = 25 * (gate.index + 1); |
|
score += bonus; |
|
airBonusFloaters.push({ x: skier.x, y: skier.y - 30, text: "+" + bonus + " GATE!", age: 0 }); |
|
// Extra bonus for completing entire trail |
|
if (gate.index === gate.total - 1) { |
|
const trailBonus = 200; |
|
score += trailBonus; |
|
airBonusFloaters.push({ x: skier.x, y: skier.y - 55, text: "+" + trailBonus + " TRAIL COMPLETE!", age: 0 }); |
|
} |
|
} |
|
} else { |
|
gate.missed = true; |
|
} |
|
} |
|
} |
|
} |
|
|
|
// Other skier collision (they're obstacles too!) |
|
if (!skier.airborne) { |
|
for (const other of otherSkiers) { |
|
const dx = skier.x - other.x; |
|
const dy = skier.y - other.y; |
|
const dist = Math.sqrt(dx * dx + dy * dy); |
|
if (dist < 18) { |
|
crash(); |
|
return; |
|
} |
|
} |
|
} |
|
|
|
// Ramp collision |
|
if (!skier.airborne) { |
|
for (const ramp of ramps) { |
|
if (!ramp.hit) { |
|
const dx = skier.x - ramp.x; |
|
const dy = skier.y - ramp.y; |
|
if (Math.abs(dx) < ramp.width * 0.5 && Math.abs(dy) < 20) { |
|
ramp.hit = true; |
|
skier.airborne = true; |
|
skier.airTime = 0; |
|
} |
|
} |
|
} |
|
} |
|
|
|
// Snow trail (only when on ground) |
|
if (!skier.airborne && frameCount % 3 === 0) { |
|
trails.push({ x: skier.x, y: skier.y + 16, age: 0 }); |
|
} |
|
for (let i = trails.length - 1; i >= 0; i--) { |
|
trails[i].y -= speed; |
|
trails[i].age++; |
|
if (trails[i].age > 30) trails.splice(i, 1); |
|
} |
|
|
|
// Collision detection (skip if airborne - you fly over obstacles!) |
|
if (!skier.airborne) { |
|
const skierRadius = 10; |
|
for (const obs of obstacles) { |
|
const dx = skier.x - obs.x; |
|
const dy = skier.y - obs.y; |
|
const dist = Math.sqrt(dx * dx + dy * dy); |
|
if (dist < skierRadius + obs.size * 0.5) { |
|
crash(); |
|
return; |
|
} |
|
} |
|
} |
|
|
|
// Bonus floaters |
|
for (let i = airBonusFloaters.length - 1; i >= 0; i--) { |
|
airBonusFloaters[i].age++; |
|
airBonusFloaters[i].y -= 1.5; |
|
if (airBonusFloaters[i].age > 60) airBonusFloaters.splice(i, 1); |
|
} |
|
|
|
// Snow particles |
|
if (frameCount % 2 === 0) { |
|
particles.push({ |
|
x: Math.random() * W, |
|
y: H + 5, |
|
vx: (Math.random() - 0.5) * 0.5, |
|
size: 1.5 + Math.random() * 2, |
|
age: 0, |
|
}); |
|
} |
|
for (let i = particles.length - 1; i >= 0; i--) { |
|
particles[i].y -= speed * 0.7; |
|
particles[i].x += particles[i].vx; |
|
particles[i].age++; |
|
if (particles[i].y < -10) particles.splice(i, 1); |
|
} |
|
|
|
scoreDisplay.textContent = "Score: " + score; |
|
speedDisplay.textContent = skier.airborne |
|
? "🚀 AIR TIME! +" + Math.floor(skier.airTime * speed * 0.5) |
|
: "Speed: " + speed.toFixed(1) + "x"; |
|
} |
|
|
|
function crash() { |
|
skier.crashed = true; |
|
running = false; |
|
lives--; |
|
|
|
if (lives > 0) { |
|
// Still have lives — show continue screen |
|
overlay.classList.remove("hidden"); |
|
overlay.querySelector("h1").textContent = "💥 Crashed!"; |
|
overlay.querySelector("p").textContent = "Score: " + score + " — " + lives + " " + (lives === 1 ? "life" : "lives") + " left. Press Space/Enter to continue."; |
|
startBtn.textContent = "Continue"; |
|
startBtn.onclick = () => { startBtn.onclick = startGame; startRun(); }; |
|
} else { |
|
// Game over |
|
if (score > highScore) highScore = score; |
|
overlay.classList.remove("hidden"); |
|
overlay.querySelector("h1").textContent = "💀 Game Over"; |
|
overlay.querySelector("p").textContent = "Final Score: " + score + (highScore > 0 ? " | High Score: " + highScore : "") + "\\nPress Space/Enter to play again."; |
|
startBtn.textContent = "New Game"; |
|
startBtn.onclick = startGame; |
|
} |
|
} |
|
|
|
function drawSkier() { |
|
ctx.save(); |
|
ctx.translate(skier.x, skier.y); |
|
|
|
// Shadow when airborne |
|
if (skier.airborne) { |
|
ctx.fillStyle = "rgba(0, 0, 0, 0.15)"; |
|
ctx.beginPath(); |
|
ctx.ellipse(skier.shadow * 0.3, skier.shadow + 10, 12, 5, 0, 0, Math.PI * 2); |
|
ctx.fill(); |
|
} |
|
|
|
// Lift skier up when airborne |
|
const lift = skier.airborne ? -skier.airHeight : 0; |
|
ctx.translate(0, lift); |
|
ctx.rotate(skier.angle); |
|
|
|
// Body |
|
ctx.fillStyle = "#1a73e8"; |
|
ctx.beginPath(); |
|
ctx.ellipse(0, 0, 8, 14, 0, 0, Math.PI * 2); |
|
ctx.fill(); |
|
|
|
// Head |
|
ctx.fillStyle = "#ffcc80"; |
|
ctx.beginPath(); |
|
ctx.arc(0, -16, 7, 0, Math.PI * 2); |
|
ctx.fill(); |
|
|
|
// Skis (angled when airborne for style) |
|
ctx.strokeStyle = "#333"; |
|
ctx.lineWidth = 3; |
|
ctx.lineCap = "round"; |
|
if (skier.airborne) { |
|
ctx.beginPath(); |
|
ctx.moveTo(-8, 10); |
|
ctx.lineTo(-4, 24); |
|
ctx.moveTo(8, 10); |
|
ctx.lineTo(4, 24); |
|
ctx.stroke(); |
|
} else { |
|
ctx.beginPath(); |
|
ctx.moveTo(-6, 12); |
|
ctx.lineTo(-6, 22); |
|
ctx.moveTo(6, 12); |
|
ctx.lineTo(6, 22); |
|
ctx.stroke(); |
|
} |
|
|
|
ctx.restore(); |
|
} |
|
|
|
function drawTree(x, y, size) { |
|
ctx.save(); |
|
ctx.translate(x, y); |
|
// Trunk |
|
ctx.fillStyle = "#5d4037"; |
|
ctx.fillRect(-3, size * 0.3, 6, size * 0.5); |
|
// Foliage layers |
|
const greens = ["#2e7d32", "#388e3c", "#43a047"]; |
|
for (let i = 0; i < 3; i++) { |
|
ctx.fillStyle = greens[i]; |
|
ctx.beginPath(); |
|
ctx.moveTo(0, -size + i * size * 0.3); |
|
ctx.lineTo(-size * 0.6 + i * 2, size * 0.1 + i * size * 0.2); |
|
ctx.lineTo(size * 0.6 - i * 2, size * 0.1 + i * size * 0.2); |
|
ctx.closePath(); |
|
ctx.fill(); |
|
} |
|
ctx.restore(); |
|
} |
|
|
|
function drawRock(x, y, size) { |
|
ctx.save(); |
|
ctx.translate(x, y); |
|
ctx.fillStyle = "#78909c"; |
|
ctx.beginPath(); |
|
ctx.ellipse(0, 0, size * 0.7, size * 0.5, 0.2, 0, Math.PI * 2); |
|
ctx.fill(); |
|
ctx.fillStyle = "#546e7a"; |
|
ctx.beginPath(); |
|
ctx.ellipse(-size * 0.2, -size * 0.1, size * 0.3, size * 0.25, -0.1, 0, Math.PI * 2); |
|
ctx.fill(); |
|
ctx.restore(); |
|
} |
|
|
|
function drawRamp(ramp) { |
|
ctx.save(); |
|
ctx.translate(ramp.x, ramp.y); |
|
const w = ramp.width; |
|
// Ramp base - orange/brown wedge shape |
|
const grad = ctx.createLinearGradient(0, 10, 0, -12); |
|
grad.addColorStop(0, "#e65100"); |
|
grad.addColorStop(1, "#ff8f00"); |
|
ctx.fillStyle = grad; |
|
ctx.beginPath(); |
|
ctx.moveTo(-w / 2, 10); |
|
ctx.lineTo(-w / 2 + 8, -12); |
|
ctx.lineTo(w / 2 - 8, -12); |
|
ctx.lineTo(w / 2, 10); |
|
ctx.closePath(); |
|
ctx.fill(); |
|
// Top edge highlight |
|
ctx.strokeStyle = "#fff59d"; |
|
ctx.lineWidth = 2; |
|
ctx.beginPath(); |
|
ctx.moveTo(-w / 2 + 8, -12); |
|
ctx.lineTo(w / 2 - 8, -12); |
|
ctx.stroke(); |
|
// Arrow indicators |
|
ctx.fillStyle = "#fff59d"; |
|
ctx.font = "bold 14px sans-serif"; |
|
ctx.textAlign = "center"; |
|
ctx.fillText("▲", 0, 5); |
|
ctx.restore(); |
|
} |
|
|
|
function drawOtherSkier(other) { |
|
ctx.save(); |
|
ctx.translate(other.x, other.y); |
|
if (other.type === "snowboarder") { |
|
// Snowboarder: wider stance, single board |
|
ctx.fillStyle = other.color; |
|
ctx.beginPath(); |
|
ctx.ellipse(0, 0, 7, 12, 0, 0, Math.PI * 2); |
|
ctx.fill(); |
|
ctx.fillStyle = "#ffcc80"; |
|
ctx.beginPath(); |
|
ctx.arc(0, -14, 6, 0, Math.PI * 2); |
|
ctx.fill(); |
|
// Board |
|
ctx.strokeStyle = "#333"; |
|
ctx.lineWidth = 4; |
|
ctx.lineCap = "round"; |
|
ctx.beginPath(); |
|
ctx.moveTo(-10, 14); |
|
ctx.lineTo(10, 14); |
|
ctx.stroke(); |
|
} else { |
|
// Other skier |
|
ctx.fillStyle = other.color; |
|
ctx.beginPath(); |
|
ctx.ellipse(0, 0, 7, 12, 0, 0, Math.PI * 2); |
|
ctx.fill(); |
|
ctx.fillStyle = "#ffcc80"; |
|
ctx.beginPath(); |
|
ctx.arc(0, -14, 6, 0, Math.PI * 2); |
|
ctx.fill(); |
|
// Skis |
|
ctx.strokeStyle = "#333"; |
|
ctx.lineWidth = 2.5; |
|
ctx.lineCap = "round"; |
|
ctx.beginPath(); |
|
ctx.moveTo(-5, 10); |
|
ctx.lineTo(-5, 20); |
|
ctx.moveTo(5, 10); |
|
ctx.lineTo(5, 20); |
|
ctx.stroke(); |
|
} |
|
ctx.restore(); |
|
} |
|
|
|
function drawSlalomGate(gate) { |
|
ctx.save(); |
|
ctx.translate(gate.x, gate.y); |
|
const w = gate.width; |
|
|
|
// Gate poles |
|
const poleColor = gate.passed ? "#4caf50" : gate.missed ? "#9e9e9e" : "#2196f3"; |
|
ctx.strokeStyle = poleColor; |
|
ctx.lineWidth = 3; |
|
ctx.beginPath(); |
|
ctx.moveTo(-w / 2, -15); |
|
ctx.lineTo(-w / 2, 15); |
|
ctx.moveTo(w / 2, -15); |
|
ctx.lineTo(w / 2, 15); |
|
ctx.stroke(); |
|
|
|
// Banner between poles |
|
const bannerColor = gate.passed ? "rgba(76, 175, 80, 0.3)" : gate.missed ? "rgba(158, 158, 158, 0.2)" : "rgba(33, 150, 243, 0.3)"; |
|
ctx.fillStyle = bannerColor; |
|
ctx.fillRect(-w / 2, -4, w, 8); |
|
|
|
// Pole flags |
|
ctx.fillStyle = poleColor; |
|
ctx.beginPath(); |
|
ctx.moveTo(-w / 2, -15); |
|
ctx.lineTo(-w / 2 + 10, -12); |
|
ctx.lineTo(-w / 2, -9); |
|
ctx.fill(); |
|
ctx.beginPath(); |
|
ctx.moveTo(w / 2, -15); |
|
ctx.lineTo(w / 2 - 10, -12); |
|
ctx.lineTo(w / 2, -9); |
|
ctx.fill(); |
|
|
|
ctx.restore(); |
|
} |
|
|
|
function draw() { |
|
// Snow background |
|
ctx.fillStyle = "#f5f9ff"; |
|
ctx.fillRect(0, 0, W, H); |
|
|
|
// Ski tracks (trails) |
|
for (const t of trails) { |
|
const alpha = 1 - t.age / 30; |
|
ctx.fillStyle = "rgba(200, 215, 230, " + alpha + ")"; |
|
ctx.fillRect(t.x - 3, t.y, 2, 4); |
|
ctx.fillRect(t.x + 2, t.y, 2, 4); |
|
} |
|
|
|
// Snow particles |
|
for (const p of particles) { |
|
ctx.fillStyle = "rgba(180, 200, 220, 0.5)"; |
|
ctx.beginPath(); |
|
ctx.arc(p.x, p.y, p.size, 0, Math.PI * 2); |
|
ctx.fill(); |
|
} |
|
|
|
// Ramps |
|
for (const ramp of ramps) { |
|
if (ramp.y > -80 && ramp.y < H + 80) drawRamp(ramp); |
|
} |
|
|
|
// Slalom gates |
|
for (const gate of slalomGates) { |
|
if (gate.y > -60 && gate.y < H + 60) drawSlalomGate(gate); |
|
} |
|
|
|
// Obstacles |
|
for (const obs of obstacles) { |
|
if (obs.y < -50 || obs.y > H + 50) continue; |
|
if (obs.type === "tree") drawTree(obs.x, obs.y, obs.size); |
|
else drawRock(obs.x, obs.y, obs.size); |
|
} |
|
|
|
// Other skiers/snowboarders |
|
for (const other of otherSkiers) { |
|
if (other.y > -60 && other.y < H + 60) drawOtherSkier(other); |
|
} |
|
|
|
// Skier |
|
if (!skier.crashed) drawSkier(); |
|
|
|
// Air bonus floaters |
|
for (const f of airBonusFloaters) { |
|
const alpha = 1 - f.age / 60; |
|
ctx.save(); |
|
ctx.globalAlpha = alpha; |
|
ctx.fillStyle = "#ff6d00"; |
|
ctx.font = "bold 18px sans-serif"; |
|
ctx.textAlign = "center"; |
|
ctx.fillText(f.text, f.x, f.y); |
|
ctx.restore(); |
|
} |
|
} |
|
|
|
function loop() { |
|
update(); |
|
draw(); |
|
requestAnimationFrame(loop); |
|
} |
|
loop(); |
|
</script> |
|
</body> |
|
</html>`; |
|
} |
|
|
|
async function startServer(instanceId) { |
|
const server = createServer((req, res) => { |
|
res.setHeader("Content-Type", "text/html; charset=utf-8"); |
|
res.end(renderHtml()); |
|
}); |
|
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); |
|
const address = server.address(); |
|
const port = typeof address === "object" && address ? address.port : 0; |
|
return { server, url: `http://127.0.0.1:${port}/` }; |
|
} |
|
|
|
const session = await joinSession({ |
|
canvases: [ |
|
createCanvas({ |
|
id: "ski-game", |
|
displayName: "Downhill Ski", |
|
description: "A downhill skiing game where you dodge trees and rocks while racing down the mountain.", |
|
actions: [ |
|
{ |
|
name: "reset_game", |
|
description: "Reset the skiing game to start fresh", |
|
handler: async (ctx) => { |
|
return { message: "Reload the canvas to reset the game" }; |
|
}, |
|
}, |
|
], |
|
open: async (ctx) => { |
|
let entry = servers.get(ctx.instanceId); |
|
if (!entry) { |
|
entry = await startServer(ctx.instanceId); |
|
servers.set(ctx.instanceId, entry); |
|
} |
|
return { title: "⛷️ Downhill Ski", url: entry.url }; |
|
}, |
|
onClose: async (ctx) => { |
|
const entry = servers.get(ctx.instanceId); |
|
if (entry) { |
|
servers.delete(ctx.instanceId); |
|
await new Promise((resolve) => entry.server.close(() => resolve())); |
|
} |
|
}, |
|
}), |
|
], |
|
}); |