Clone
const GRAVITY = 9.81
const MAX_ATTEMPTS = 5
const CANVAS_W = 450
const CANVAS_H = 250
const SCALE = 4 // pixels per meter
export const ProjectileGame = {
start(container, channel) {
while (container.firstChild) container.removeChild(container.firstChild)
let attempts = 0
let bestError = Infinity
let bestScore = 0
let targetX = 0
let angle = 45
let velocity = 20
let animating = false
let totalScore = 0
let round = 0
const TOTAL_ROUNDS = 3
const title = document.createElement("div")
title.className = "mg-title"
title.textContent = "Projectile Pi"
const subtitle = document.createElement("div")
subtitle.className = "mg-subtitle"
subtitle.textContent = "Launch to hit the target! Adjust angle and velocity."
const canvas = document.createElement("canvas")
canvas.width = CANVAS_W
canvas.height = CANVAS_H
canvas.style.cssText = "border-radius:0.75rem;border:2px solid rgba(255,255,255,0.2);max-width:100%;background:#0f0a2e;"
const ctx = canvas.getContext("2d")
const statsEl = document.createElement("div")
statsEl.style.cssText = "color:#a78bfa;font-size:0.85rem;margin:0.5rem 0;min-height:1.5rem;"
// Angle control
const angleRow = createSlider("Angle", 5, 85, 45, "\u00B0", (v) => { angle = v; drawScene() })
// Velocity control
const velRow = createSlider("Velocity", 5, 50, 20, " m/s", (v) => { velocity = v; drawScene() })
const launchBtn = document.createElement("button")
launchBtn.className = "mg-btn"
launchBtn.textContent = "Launch!"
launchBtn.addEventListener("click", () => launch())
const feedbackEl = document.createElement("div")
feedbackEl.style.cssText = "min-height: 2rem; margin: 0.5rem 0; font-size: 1rem;"
const resultArea = document.createElement("div")
resultArea.style.cssText = "margin-top: 0.5rem;"
container.appendChild(title)
container.appendChild(subtitle)
container.appendChild(canvas)
container.appendChild(statsEl)
container.appendChild(angleRow.container)
container.appendChild(velRow.container)
container.appendChild(launchBtn)
container.appendChild(feedbackEl)
container.appendChild(resultArea)
function startRound() {
round++
attempts = 0
bestError = Infinity
bestScore = 0
feedbackEl.textContent = ""
launchBtn.style.display = ""
channel.push("projectile_get_target", {}).receive("ok", (data) => {
targetX = data.target_x
statsEl.textContent = `Round ${round}/${TOTAL_ROUNDS} | Target: ${targetX}m (\u2248 ${(targetX / Math.PI).toFixed(0)}\u03C0) | Attempts: ${attempts}/${MAX_ATTEMPTS}`
drawScene()
})
}
function drawScene(trail) {
ctx.clearRect(0, 0, CANVAS_W, CANVAS_H)
// Sky gradient
const grad = ctx.createLinearGradient(0, 0, 0, CANVAS_H)
grad.addColorStop(0, "#0a0a2e")
grad.addColorStop(1, "#1a1040")
ctx.fillStyle = grad
ctx.fillRect(0, 0, CANVAS_W, CANVAS_H)
// Ground
ctx.fillStyle = "#1a3a1a"
ctx.fillRect(0, CANVAS_H - 20, CANVAS_W, 20)
// Grid markers
ctx.font = "9px monospace"
ctx.fillStyle = "#555"
ctx.textAlign = "center"
for (let m = 20; m <= 120; m += 20) {
const px = m * SCALE
if (px < CANVAS_W) {
ctx.fillText(`${m}m`, px, CANVAS_H - 5)
ctx.strokeStyle = "rgba(255,255,255,0.05)"
ctx.beginPath()
ctx.moveTo(px, 0)
ctx.lineTo(px, CANVAS_H - 20)
ctx.stroke()
}
}
// Target
if (targetX > 0) {
const tx = targetX * SCALE
ctx.fillStyle = "#ef4444"
ctx.fillRect(tx - 3, CANVAS_H - 35, 6, 15)
// Flag
ctx.fillStyle = "#ef4444"
ctx.beginPath()
ctx.moveTo(tx + 3, CANVAS_H - 35)
ctx.lineTo(tx + 18, CANVAS_H - 28)
ctx.lineTo(tx + 3, CANVAS_H - 21)
ctx.fill()
// Label
ctx.font = "10px monospace"
ctx.fillStyle = "#ef4444"
ctx.textAlign = "center"
ctx.fillText(`${targetX}m`, tx, CANVAS_H - 40)
}
// Launcher
const launchX = 15
const launchY = CANVAS_H - 20
ctx.save()
ctx.translate(launchX, launchY)
ctx.rotate(-angle * Math.PI / 180)
ctx.fillStyle = "#06b6d4"
ctx.fillRect(0, -3, 20, 6)
ctx.restore()
ctx.beginPath()
ctx.arc(launchX, launchY, 6, 0, Math.PI * 2)
ctx.fillStyle = "#06b6d4"
ctx.fill()
// Trajectory preview (dotted)
ctx.setLineDash([3, 4])
ctx.strokeStyle = "rgba(6, 182, 212, 0.3)"
ctx.beginPath()
const vx = velocity * Math.cos(angle * Math.PI / 180)
const vy = velocity * Math.sin(angle * Math.PI / 180)
for (let t = 0; t < 8; t += 0.1) {
const px = launchX + vx * t * SCALE
const py = launchY - (vy * t - 0.5 * GRAVITY * t * t) * SCALE
if (t === 0) ctx.moveTo(px, py)
else ctx.lineTo(px, py)
if (py > launchY) break
}
ctx.stroke()
ctx.setLineDash([])
// Draw trails from previous attempts
if (trail) {
ctx.strokeStyle = "rgba(139, 92, 246, 0.6)"
ctx.lineWidth = 2
ctx.beginPath()
trail.forEach((p, i) => {
if (i === 0) ctx.moveTo(p.x, p.y)
else ctx.lineTo(p.x, p.y)
})
ctx.stroke()
ctx.lineWidth = 1
// Impact point
const last = trail[trail.length - 1]
ctx.beginPath()
ctx.arc(last.x, last.y, 4, 0, Math.PI * 2)
ctx.fillStyle = "#8b5cf6"
ctx.fill()
}
}
function launch() {
if (animating || attempts >= MAX_ATTEMPTS) return
animating = true
attempts++
if (window.SoundFX) window.SoundFX.dart()
const launchX = 15
const launchY = CANVAS_H - 20
const vx = velocity * Math.cos(angle * Math.PI / 180)
const vy = velocity * Math.sin(angle * Math.PI / 180)
const trail = []
let t = 0
const dt = 0.03
function animate() {
t += dt
const px = launchX + vx * t * SCALE
const py = launchY - (vy * t - 0.5 * GRAVITY * t * t) * SCALE
trail.push({ x: px, y: py })
drawScene(trail)
// Draw projectile
ctx.beginPath()
ctx.arc(px, py, 5, 0, Math.PI * 2)
ctx.fillStyle = "#f59e0b"
ctx.fill()
ctx.strokeStyle = "#fff"
ctx.lineWidth = 1
ctx.stroke()
if (py < launchY && px < CANVAS_W) {
requestAnimationFrame(animate)
} else {
// Landed
animating = false
const landingM = (px - launchX) / SCALE
const error = Math.abs(landingM - targetX)
const pts = Math.max(0, Math.round(500 - error * 50))
if (error < bestError) {
bestError = error
bestScore = pts
}
statsEl.textContent = `Round ${round}/${TOTAL_ROUNDS} | Target: ${targetX}m | Attempts: ${attempts}/${MAX_ATTEMPTS} | Landing: ${landingM.toFixed(1)}m`
if (error < 1) {
feedbackEl.style.color = "#22c55e"
feedbackEl.textContent = `Bullseye! ${landingM.toFixed(1)}m (error: ${error.toFixed(2)}m) +${pts}pts`
if (window.SoundFX) window.SoundFX.milestone()
} else if (error < 5) {
feedbackEl.style.color = "#f59e0b"
feedbackEl.textContent = `Close! ${landingM.toFixed(1)}m (error: ${error.toFixed(2)}m) +${pts}pts`
if (window.SoundFX) window.SoundFX.correct()
} else {
feedbackEl.style.color = "#ef4444"
feedbackEl.textContent = `${landingM.toFixed(1)}m — ${error.toFixed(1)}m off! +${pts}pts`
if (window.SoundFX) window.SoundFX.wrong()
}
if (attempts >= MAX_ATTEMPTS) {
totalScore += bestScore
setTimeout(() => {
if (round >= TOTAL_ROUNDS) {
endGame()
} else {
feedbackEl.textContent += ` | Best this round: ${bestScore}pts. Next round...`
setTimeout(startRound, 1500)
}
}, 1000)
}
}
}
requestAnimationFrame(animate)
}
function endGame() {
launchBtn.style.display = "none"
channel.push("projectile_submit", {
score: totalScore,
target_x: targetX,
best_error: parseFloat(bestError.toFixed(2))
}).receive("ok", () => {
if (window.SoundFX) window.SoundFX.score()
while (resultArea.firstChild) resultArea.removeChild(resultArea.firstChild)
const result = document.createElement("div")
result.style.cssText = "font-size: 1.5rem; color: #22d3ee; margin-bottom: 0.5rem;"
result.textContent = `Total Score: ${totalScore}`
const playAgain = document.createElement("button")
playAgain.className = "mg-btn"
playAgain.textContent = "Play Again"
playAgain.addEventListener("click", () => ProjectileGame.start(container, channel))
const close = document.createElement("button")
close.className = "mg-btn secondary"
close.textContent = "Back to Hub"
close.addEventListener("click", () => window.piStation.closeMiniGame())
resultArea.appendChild(result)
resultArea.appendChild(playAgain)
resultArea.appendChild(close)
})
}
channel.on("projectile_score", ({ name, score: s }) => {
if (name === window.PLAYER_NAME) return
const el = document.createElement("div")
el.style.cssText = "color: #22d3ee; font-size: 0.8rem; margin-top: 0.25rem;"
el.textContent = `${name} scored ${s} points!`
resultArea.appendChild(el)
setTimeout(() => { if (el.parentNode) el.parentNode.removeChild(el) }, 5000)
})
startRound()
}
}
function createSlider(label, min, max, initial, unit, onChange) {
const container = document.createElement("div")
container.style.cssText = "display:flex;align-items:center;gap:0.75rem;margin:0.4rem auto;max-width:400px;color:white;"
const labelEl = document.createElement("span")
labelEl.style.cssText = "width:60px;font-size:0.85rem;text-align:right;"
labelEl.textContent = label
const slider = document.createElement("input")
slider.type = "range"
slider.min = min
slider.max = max
slider.value = initial
slider.style.cssText = "flex:1;accent-color:#8b5cf6;height:24px;"
const valueEl = document.createElement("span")
valueEl.style.cssText = "width:65px;font-size:0.9rem;color:#22d3ee;font-weight:bold;"
valueEl.textContent = `${initial}${unit}`
slider.addEventListener("input", () => {
const v = parseInt(slider.value)
valueEl.textContent = `${v}${unit}`
onChange(v)
})
container.appendChild(labelEl)
container.appendChild(slider)
container.appendChild(valueEl)
return { container, slider, valueEl }
}