Files
ESP32-WijiBoard/reference/fivebarIKGame.js
T
2026-07-08 20:35:59 +02:00

13 lines
30 KiB
JavaScript

const x = { robot: { groundWidth: 25.8, linkLength1: 85, linkLength2: 110 }, motor1: { min: -330, max: -90 }, motor2: { min: -90, max: 150 }, control: { fk: { normal: 80, fast: 210 }, ik: { normal: 71.5, fast: 169 } }, game: { duration: 3e4, countdownDuration: 3e3, warningTime: 5e3, hitRadius: 3, noGoRadius: 50, targetRegion: { xMin: -136, xMax: 136, yMin: 1, yMax: 65 } }, render: { linkThickness: 3, jointRadius: 1.5, colors: { bg: "#0a0e12", ground: "#3b82f6", link1: "#10b981", link2: "#fbbf24", joint: "#e5e7eb", endEffector: "#fbbf24", target: "#22c55e", targetUrgent: "#ef4444", trail: "rgba(59, 130, 246, 0.3)" } }, minElbowAngleDeg: .5 }, c = { degToRad: r => r * Math.PI / 180, radToDeg: r => r * 180 / Math.PI, clamp: (r, t, e) => Math.max(t, Math.min(e, r)), lerp: (r, t, e) => r + (t - r) * e, dist: (r, t) => Math.hypot(t.x - r.x, t.y - r.y), cross: (r, t) => r.x * t.y - r.y * t.x, magnitude: r => Math.hypot(r.x, r.y), normalize: r => { const t = c.magnitude(r); return t > 0 ? { x: r.x / t, y: r.y / t } : { x: 0, y: 0 } }, unwrapAngle: (r, t) => { let e = r; for (; e - t > 180;)e -= 360; for (; e - t < -180;)e += 360; return e }, intersectCircles: (r, t, e) => { const i = t.x - r.x, s = t.y - r.y, a = Math.hypot(i, s); if (a > 2 * e || a < 1e-9) return null; const o = (r.x + t.x) / 2, n = (r.y + t.y) / 2, h = Math.sqrt(Math.max(0, e * e - a * a / 4)), m = -s / a, l = i / a; return [{ x: o + m * h, y: n + l * h }, { x: o - m * h, y: n - l * h }] } }; class S { constructor(t) { this.config = t, this.minElbowSin = Math.sin(c.degToRad(t.minElbowAngleDeg)) } config; minElbowSin; fkBranchIndex = null; ikElbowSigns = { left: null, right: null }; eeHistory = []; isValidElbow(t, e) { const i = Math.abs(c.cross(t, e)), s = c.magnitude(t) * c.magnitude(e); return i / s >= this.minElbowSin } solveFK(t, e) { const { groundWidth: i, linkLength1: s, linkLength2: a } = this.config.robot, o = { x: -i / 2, y: 0 }, n = { x: i / 2, y: 0 }, h = c.degToRad(t), m = c.degToRad(e), l = { x: o.x + s * Math.cos(h), y: o.y + s * Math.sin(h) }, d = { x: n.x + s * Math.cos(m), y: n.y + s * Math.sin(m) }, y = c.intersectCircles(l, d, a); if (!y) return { valid: !1, baseLeft: o, baseRight: n, joint1: l, joint2: d, endEffector: { x: (l.x + d.x) / 2, y: (l.y + d.y) / 2 } }; const [g, f] = y, p = this.isValidElbow({ x: l.x - o.x, y: l.y - o.y }, { x: g.x - l.x, y: g.y - l.y }) && this.isValidElbow({ x: d.x - n.x, y: d.y - n.y }, { x: g.x - d.x, y: g.y - d.y }), v = this.isValidElbow({ x: l.x - o.x, y: l.y - o.y }, { x: f.x - l.x, y: f.y - l.y }) && this.isValidElbow({ x: d.x - n.x, y: d.y - n.y }, { x: f.x - d.x, y: f.y - d.y }); if (!p && !v) return { valid: !1, baseLeft: o, baseRight: n, joint1: l, joint2: d, endEffector: g }; let u; if (this.fkBranchIndex === 0 && p) u = g; else if (this.fkBranchIndex === 1 && v) u = f; else if (p && !v) this.fkBranchIndex = 0, u = g; else if (v && !p) this.fkBranchIndex = 1, u = f; else if (this.eeHistory.length === 0) this.fkBranchIndex = g.y >= f.y ? 0 : 1, u = this.fkBranchIndex === 0 ? g : f; else { const b = this.eeHistory[this.eeHistory.length - 1], w = c.dist(g, b), k = c.dist(f, b); this.fkBranchIndex = w <= k ? 0 : 1, u = this.fkBranchIndex === 0 ? g : f } return { valid: !0, baseLeft: o, baseRight: n, joint1: l, joint2: d, endEffector: u } } solve2Link(t, e, i, s) { const a = e.x - t.x, o = e.y - t.y, n = Math.hypot(a, o); if (n > i + s || n < Math.abs(i - s) || n < 1e-9) return null; const h = c.clamp((n * n - i * i - s * s) / (2 * i * s), -1, 1), m = Math.acos(h), l = -m, d = []; for (const y of [m, l]) { const g = Math.sin(y); if (Math.abs(g) < this.minElbowSin) continue; const f = i + s * Math.cos(y), p = s * Math.sin(y), v = Math.atan2(o, a) - Math.atan2(p, f); d.push({ angle: v, elbowSign: g > 0 ? 1 : -1 }) } return d.length > 0 ? d : null } solveIK(t, e) { const { groundWidth: i, linkLength1: s, linkLength2: a } = this.config.robot, o = { x: -i / 2, y: 0 }, n = { x: i / 2, y: 0 }, h = this.solve2Link(o, t, s, a), m = this.solve2Link(n, t, s, a); if (!h || !m) return null; let l = null, d = 1 / 0; const y = this.ikElbowSigns.left, g = this.ikElbowSigns.right; for (const f of h) { if (y !== null && f.elbowSign !== y) continue; let p = c.radToDeg(f.angle); if (p = c.unwrapAngle(p, e.a1), !(p < this.config.motor1.min || p > this.config.motor1.max)) for (const v of m) { if (g !== null && v.elbowSign !== g) continue; let u = c.radToDeg(v.angle); if (u = c.unwrapAngle(u, e.a2), u < this.config.motor2.min || u > this.config.motor2.max || !this.solveFK(p, u).valid) continue; const w = Math.abs(p - e.a1) + Math.abs(u - e.a2); w < d && (d = w, l = { angle1: p, angle2: u, leftElbow: f.elbowSign, rightElbow: v.elbowSign }) } } return l && (this.ikElbowSigns.left = l.leftElbow, this.ikElbowSigns.right = l.rightElbow), l } updateHistory(t) { this.eeHistory.push({ ...t }), this.eeHistory.length > 5 && this.eeHistory.shift() } resetBranches() { this.fkBranchIndex = null, this.ikElbowSigns = { left: null, right: null }, this.eeHistory = [] } } class T { constructor(t, e) { this.canvas = t, this.config = e; const i = t.getContext("2d", { alpha: !1 }); if (!i) throw new Error("Could not get canvas context"); this.ctx = i } canvas; config; ctx; scale = 1; offsetX = 0; offsetY = 0; trail = []; targetPulse = 0; resizeObserver = null; onResize; setupResizeObserver(t) { if (this.onResize = t, typeof window < "u" && "ResizeObserver" in window) { this.resizeObserver = new ResizeObserver(() => { this.resize(), this.onResize && this.onResize() }); const e = this.canvas.parentElement; e && this.resizeObserver.observe(e) } else if (typeof window < "u") { const e = () => { this.resize(), this.onResize && this.onResize() }; window.addEventListener("resize", e) } } cleanup() { this.resizeObserver && (this.resizeObserver.disconnect(), this.resizeObserver = null) } resize() { const t = window.devicePixelRatio || 1, e = this.canvas.getBoundingClientRect(); this.canvas.width = e.width * t, this.canvas.height = e.height * t, this.ctx.scale(t, t); const { groundWidth: i, linkLength1: s, linkLength2: a } = this.config.robot, o = i / 2 + s + a, n = s + a, h = e.width < 500 ? .85 : .8, m = e.width * h / (2 * o), l = e.height * h / n; this.scale = Math.min(m, l), this.offsetX = e.width / 2, this.offsetY = e.height * (e.height < 500 ? .65 : .75) } worldToScreen(t) { return { x: this.offsetX + t.x * this.scale, y: this.offsetY - t.y * this.scale } } clear() { const { width: t, height: e } = this.canvas.getBoundingClientRect(), i = this.canvas.closest(".fivebar-ik-container"), s = i ? getComputedStyle(i).getPropertyValue("--fivebar-bg-tertiary").trim() : this.config.render.colors.bg; this.ctx.fillStyle = s || this.config.render.colors.bg, this.ctx.fillRect(0, 0, t, e) } drawLine(t, e, i, s = 2) { const a = this.worldToScreen(t), o = this.worldToScreen(e); this.ctx.strokeStyle = i, this.ctx.lineWidth = s, this.ctx.lineCap = "round", this.ctx.beginPath(), this.ctx.moveTo(a.x, a.y), this.ctx.lineTo(o.x, o.y), this.ctx.stroke() } drawCircle(t, e, i, s) { const a = this.worldToScreen(t), o = e * this.scale; this.ctx.beginPath(), this.ctx.arc(a.x, a.y, o, 0, Math.PI * 2), i && (this.ctx.fillStyle = i, this.ctx.fill()), s && (this.ctx.strokeStyle = s, this.ctx.lineWidth = 2, this.ctx.stroke()) } drawTrail() { if (this.trail.length < 2) return; this.ctx.strokeStyle = this.config.render.colors.trail, this.ctx.lineWidth = 2, this.ctx.lineCap = "round", this.ctx.lineJoin = "round", this.ctx.beginPath(); const t = this.worldToScreen(this.trail[0]); this.ctx.moveTo(t.x, t.y); for (let e = 1; e < this.trail.length; e++) { const i = this.worldToScreen(this.trail[e]); this.ctx.lineTo(i.x, i.y) } this.ctx.stroke() } drawMechanism(t, e) { const { baseLeft: i, baseRight: s, joint1: a, joint2: o, endEffector: n } = t, h = this.config.render.colors; this.drawTrail(), this.drawLine(i, s, h.ground, this.config.render.linkThickness * this.scale), this.drawLine(i, a, h.link1, this.config.render.linkThickness * this.scale), this.drawLine(s, o, h.link1, this.config.render.linkThickness * this.scale), this.drawLine(a, n, h.link2, this.config.render.linkThickness * this.scale), this.drawLine(o, n, h.link2, this.config.render.linkThickness * this.scale); const m = this.config.render.jointRadius; this.drawCircle(i, m, h.ground), this.drawCircle(s, m, h.ground), this.drawCircle(a, m, h.joint), this.drawCircle(o, m, h.joint); const l = e === "IK" ? "#10b981" : "#fbbf24"; this.drawCircle(n, m * 1.2, l), this.drawCoordinates(n, e) } drawCoordinates(t, e) { const i = this.worldToScreen(t), s = `(${t.x.toFixed(1)}, ${t.y.toFixed(1)})`; this.ctx.font = "11px monospace", this.ctx.fillStyle = e === "IK" ? "#10b981" : "#3b82f6", this.ctx.textAlign = "left", this.ctx.fillText(s, i.x + 15, i.y - 10) } drawTarget(t, e = !1) { this.targetPulse += .05; const i = e ? 1 + Math.sin(this.targetPulse * 3) * .3 : 1 + Math.sin(this.targetPulse) * .1, s = e ? this.config.render.colors.targetUrgent : this.config.render.colors.target; this.drawCircle(t, 2 * i, s), this.drawCircle(t, this.config.game.hitRadius * i, void 0, s + "60"), e && this.drawCircle(t, this.config.game.hitRadius * i * 1.5, void 0, s + "30") } addToTrail(t) { this.trail.push({ ...t }), this.trail.length > 50 && this.trail.shift() } clearTrail() { this.trail = [] } worldToScreenPublic(t) { return this.worldToScreen(t) } } class M {
constructor(t, e, i, s) { this.container = t, this.config = e, this.kinematics = i, this.renderer = s, this.loadHighScores(), this.loadGamesPlayed(), this.cacheElements(), this.setupEventListeners(), this.updateUI() } container; config; kinematics; renderer; mode = "freeplay"; controlMode = "FK"; angles = { a1: -180, a2: 0 }; ikTarget = { x: 0, y: 40 }; gameState = { score: 0, startTime: 0, countdownStartTime: 0, timeLeft: x.game.duration, target: null, hitLatch: !1 }; highScores = { FK: 0, IK: 0 }; gamesPlayed = { FK: 0, IK: 0 }; keys = new Set; lastUpdate = performance.now(); animationFrameId = null; elements = {}; tutorialActive = !1; tutorialStep = "motor1"; tutorialProgress = { motor1Moved: !1, motor2Moved: !1, initialM1: -180, initialM2: 0 }; cacheElements() { const t = e => this.container.querySelector(e); this.elements = { header: t("[data-header]"), modeBadge: t("[data-mode-badge]"), timerBadge: t("[data-timer-badge]"), timeLeft: t("[data-time-left]"), scoreBadge: t("[data-score-badge]"), score: t("[data-score]"), hiBadge: t("[data-hi-badge]"), hiFk: t("[data-hi-fk]"), hiIk: t("[data-hi-ik]"), unreachable: t("[data-unreachable]"), helpBtn: t("[data-help-btn]"), playBtn: t("[data-play-btn]"), fkBtn: t("[data-fk-btn]"), ikBtn: t("[data-ik-btn]"), m1Slider: t("[data-m1-slider]"), m2Slider: t("[data-m2-slider]"), m1Value: t("[data-m1-value]"), m2Value: t("[data-m2-value]"), helpOverlay: t("[data-help-overlay]"), closeHelp: t("[data-close-help]"), welcomeOverlay: t("[data-welcome-overlay]"), closeWelcome: t("[data-close-welcome]"), showHelpFromWelcome: t("[data-show-help-from-welcome]"), gameoverOverlay: t("[data-gameover-overlay]"), finalScore: t("[data-final-score]"), gameoverMessage: t("[data-gameover-message]"), modeSuggestion: t("[data-mode-suggestion]"), suggestionText: t("[data-suggestion-text]"), gameoverContinue: t("[data-gameover-continue]"), gameoverSwitch: t("[data-gameover-switch]"), switchModeText: t("[data-switch-mode-text]"), countdownOverlay: t("[data-countdown-overlay]"), countdownNumber: t("[data-countdown-number]"), canvasContainer: t(".fivebar-canvas-container"), loading: t("[data-loading]"), tutorialBanner: t("[data-tutorial-banner]"), replayTutorial: t("[data-replay-tutorial]") } } setupEventListeners() { this.handleKeyDown = this.handleKeyDown.bind(this), this.handleKeyUp = this.handleKeyUp.bind(this), window.addEventListener("keydown", this.handleKeyDown), window.addEventListener("keyup", this.handleKeyUp), this.elements.helpBtn?.addEventListener("click", () => this.showHelp()), this.elements.playBtn?.addEventListener("click", () => this.startGame()), this.elements.fkBtn?.addEventListener("click", () => this.setControlMode("FK")), this.elements.ikBtn?.addEventListener("click", () => this.setControlMode("IK")), this.elements.m1Slider?.addEventListener("input", t => this.handleSlider(1, t.target.value)), this.elements.m2Slider?.addEventListener("input", t => this.handleSlider(2, t.target.value)), this.elements.closeHelp?.addEventListener("click", () => this.hideHelp()), this.elements.replayTutorial?.addEventListener("click", () => { this.hideHelp(), this.replayTutorial() }), this.elements.closeWelcome?.addEventListener("click", () => this.hideWelcome()), this.elements.showHelpFromWelcome?.addEventListener("click", () => { this.hideWelcome(), this.showHelp() }), this.elements.gameoverContinue?.addEventListener("click", () => this.hideGameOver()), this.elements.gameoverSwitch?.addEventListener("click", () => { this.toggleControlMode(), this.hideGameOver(), setTimeout(() => this.startGame(), 300) }), this.elements.helpOverlay?.addEventListener("click", t => { t.target.hasAttribute("data-help-overlay") && this.hideHelp() }) } handleKeyDown(t) { if (t.target.tagName === "INPUT" || t.target.tagName === "TEXTAREA") return; const e = t.key.toLowerCase(); if (this.tutorialActive && this.tutorialStep === "choice") { if (e === "p") { t.preventDefault(), this.advanceTutorialFromChoice("game"), this.startGame(); return } if (e === "tab") { t.preventDefault(), this.advanceTutorialFromChoice("ik"), this.toggleControlMode(); return } } if (this.tutorialActive && this.tutorialStep === "ik-move" && this.controlMode === "IK" && ["a", "d", "w", "s"].includes(e) && setTimeout(() => { this.tutorialActive && this.tutorialStep === "ik-move" && this.completeTutorial() }, 3e3), e === "escape") { t.preventDefault(), this.elements.helpOverlay?.classList.contains("fivebar-visible") ? this.hideHelp() : this.elements.gameoverOverlay?.classList.contains("fivebar-visible") ? this.hideGameOver() : (this.mode === "timed" || this.mode === "countdown") && this.endGame(!0); return } if (e === "h") { t.preventDefault(), this.toggleHelp(); return } if (e === "p") { t.preventDefault(), this.startGame(); return } if (e === "r" && (this.mode === "timed" || this.mode === "countdown")) { t.preventDefault(), this.startGame(); return } if (e === "tab") { t.preventDefault(), this.toggleControlMode(); return } this.keys.add(e), ["a", "d", "w", "s", "j", "l", "shift"].includes(e) && t.preventDefault() } handleKeyUp(t) { this.keys.delete(t.key.toLowerCase()) } handleSlider(t, e) { if (this.controlMode !== "FK") return; const i = parseInt(e) / 1e3; t === 1 ? this.angles.a1 = c.lerp(this.config.motor1.min, this.config.motor1.max, i) : this.angles.a2 = c.lerp(this.config.motor2.min, this.config.motor2.max, i), this.updateSliders(), this.render() } setControlMode(t) { if (this.controlMode !== t) { if (this.controlMode = t, this.kinematics.resetBranches(), t === "IK") { const e = this.kinematics.solveFK(this.angles.a1, this.angles.a2); e.valid && (this.ikTarget = { ...e.endEffector }) } this.renderer.clearTrail(), this.updateUI(), this.render() } } toggleControlMode() { this.setControlMode(this.controlMode === "FK" ? "IK" : "FK") } startGame() { this.mode = "countdown", this.gameState.score = 0, this.gameState.countdownStartTime = performance.now(), this.gameState.hitLatch = !1, this.gameState.target = null, this.showCountdown(), this.updateUI() } showCountdown() { const t = this.elements.countdownOverlay, e = this.elements.countdownNumber; if (!t || !e) return; t.classList.add("fivebar-visible"); const i = () => { const s = performance.now() - this.gameState.countdownStartTime, a = Math.ceil((this.config.game.countdownDuration - s) / 1e3); a > 0 ? (e.textContent = a.toString(), e.className = "fivebar-countdown-number", setTimeout(i, 100)) : (e.textContent = "GO!", e.className = "fivebar-countdown-number fivebar-go", setTimeout(() => { t.classList.remove("fivebar-visible"), this.startTimedMode() }, 800)) }; i() } startTimedMode() { this.mode = "timed", this.gameState.startTime = performance.now(), this.gameState.timeLeft = this.config.game.duration, this.spawnTarget(), this.updateUI() } endGame(t = !1) { if (this.mode !== "timed" && this.mode !== "countdown") return; const e = this.gameState.score; this.mode === "timed" && !t && (this.highScores[this.controlMode] = Math.max(this.highScores[this.controlMode], e), this.saveHighScores(), this.gamesPlayed[this.controlMode]++, this.saveGamesPlayed()), this.mode = "freeplay", this.gameState.target = null, this.elements.header?.classList.remove("fivebar-warning"), this.updateUI(), t || this.showGameOver(e) } showGameOver(t) { this.elements.finalScore && (this.elements.finalScore.textContent = t.toString()); let e = ""; t === 0 ? e = "Don't worry, it takes practice! Try again!" : t < 3 ? e = "Good start! You're getting the hang of it." : t < 5 ? e = "Nice work! You're improving!" : t < 8 ? e = "Great job! You're really getting good at this!" : t < 12 ? e = "Excellent! You're a natural!" : e = "Outstanding! You're a kinematics master!", this.elements.gameoverMessage && (this.elements.gameoverMessage.textContent = e); const i = this.gamesPlayed[this.controlMode], s = this.controlMode === "FK" ? "IK" : "FK", a = this.gamesPlayed[s], o = this.elements.modeSuggestion, n = this.elements.switchModeText; i === 1 && a === 0 ? (o && (o.style.display = "block"), this.controlMode === "FK" ? (this.elements.suggestionText && (this.elements.suggestionText.innerHTML = 'You just played in <strong>FK mode</strong> (controlling motor angles). Try <strong>IK mode</strong> by pressing <span class="fivebar-key">Tab</span> to see how inverse kinematics makes position control much more intuitive!'), n && (n.textContent = "Try IK Mode")) : (this.elements.suggestionText && (this.elements.suggestionText.innerHTML = 'You just played in <strong>IK mode</strong> (controlling position directly). Try <strong>FK mode</strong> by pressing <span class="fivebar-key">Tab</span> to see how much harder it is to control individual motor angles!'), n && (n.textContent = "Try FK Mode"))) : o && (o.style.display = "none"), this.elements.gameoverOverlay?.classList.add("fivebar-visible") } hideGameOver() { this.elements.gameoverOverlay?.classList.remove("fivebar-visible") } spawnTarget() { const t = this.config.game.targetRegion, e = this.config.game.noGoRadius, i = 1e3; for (let o = 0; o < i; o++) { const n = c.lerp(t.xMin, t.xMax, Math.random()), h = c.lerp(t.yMin, t.yMax, Math.random()); if (!(Math.hypot(n, h) < e || Math.hypot(n, h) < 60 && Math.random() > .4)) { this.gameState.target = { x: n, y: h }, this.gameState.hitLatch = !1; return } } const s = Math.random() * Math.PI * 2, a = e + 20 + Math.random() * 30; this.gameState.target = { x: Math.cos(s) * a, y: Math.abs(Math.sin(s) * a) + t.yMin }, this.gameState.hitLatch = !1 } checkHit(t) { if (this.mode !== "timed" || !this.gameState.target) return; const i = c.dist(t, this.gameState.target) <= this.config.game.hitRadius; i && !this.gameState.hitLatch ? (this.gameState.score++, this.gameState.hitLatch = !0, this.spawnTarget(), this.updateUI(), this.createHitEffect(t), this.createScorePopup(t)) : i || (this.gameState.hitLatch = !1) } createHitEffect(t) { const e = this.renderer.worldToScreenPublic(t), i = this.elements.canvasContainer; if (i) for (let s = 0; s < 12; s++) { const a = document.createElement("div"); a.className = "fivebar-particle", a.style.left = e.x + "px", a.style.top = e.y + "px", a.style.width = "8px", a.style.height = "8px", a.style.background = "#22c55e"; const o = s / 12 * Math.PI * 2, n = 30 + Math.random() * 25; a.style.setProperty("--tx", Math.cos(o) * n + "px"), a.style.setProperty("--ty", Math.sin(o) * n + "px"), i.appendChild(a), setTimeout(() => a.remove(), 600) } } createScorePopup(t) { const e = this.renderer.worldToScreenPublic(t), i = this.elements.canvasContainer; if (!i) return; const s = document.createElement("div"); s.className = "fivebar-score-popup", s.textContent = "+1", s.style.left = e.x + "px", s.style.top = e.y + "px", i.appendChild(s), setTimeout(() => s.remove(), 800) } update(t) { if (this.tutorialActive && this.updateTutorial(t), this.mode !== "countdown" && (this.controlMode === "FK" ? this.updateFK(t) : this.updateIK(t), this.mode === "timed")) { const e = performance.now() - this.gameState.startTime; this.gameState.timeLeft = Math.max(0, this.config.game.duration - e); const i = this.gameState.timeLeft <= this.config.game.warningTime; this.elements.header?.classList.toggle("fivebar-warning", i), this.gameState.timeLeft <= 0 && this.endGame(), this.updateUI() } } updateFK(t) { const i = (this.keys.has("shift") ? this.config.control.fk.fast : this.config.control.fk.normal) * t; this.keys.has("a") && (this.angles.a1 += i), this.keys.has("d") && (this.angles.a1 -= i), this.keys.has("j") && (this.angles.a2 += i), this.keys.has("l") && (this.angles.a2 -= i), this.angles.a1 = c.clamp(this.angles.a1, this.config.motor1.min, this.config.motor1.max), this.angles.a2 = c.clamp(this.angles.a2, this.config.motor2.min, this.config.motor2.max), this.updateSliders() } updateIK(t) { const i = (this.keys.has("shift") ? this.config.control.ik.fast : this.config.control.ik.normal) * t; let s = 0, a = 0; if (this.keys.has("a") && (s -= i), this.keys.has("d") && (s += i), this.keys.has("w") && (a += i), this.keys.has("s") && (a -= i), s !== 0 || a !== 0) { this.ikTarget.x += s, this.ikTarget.y += a, this.ikTarget.y = Math.max(1, this.ikTarget.y); const o = this.kinematics.solveIK(this.ikTarget, this.angles); o && (this.angles.a1 = o.angle1, this.angles.a2 = o.angle2, this.updateSliders()) } } updateSliders() { const t = this.elements.m1Slider, e = this.elements.m2Slider, i = this.elements.m1Value, s = this.elements.m2Value; if (!t || !e || !i || !s) return; const a = (this.angles.a1 - this.config.motor1.min) / (this.config.motor1.max - this.config.motor1.min), o = (this.angles.a2 - this.config.motor2.min) / (this.config.motor2.max - this.config.motor2.min); t.value = Math.round(a * 1e3).toString(), e.value = Math.round(o * 1e3).toString(), i.textContent = Math.round(this.angles.a1) + "°", s.textContent = Math.round(this.angles.a2) + "°", t.disabled = this.controlMode === "IK", e.disabled = this.controlMode === "IK" } updateUI() { const t = this.mode === "countdown" ? "Get Ready!" : this.mode === "timed" ? "Game On!" : "Freeplay"; this.elements.modeBadge && (this.elements.modeBadge.textContent = t); const e = this.elements.timerBadge; if (this.mode === "timed" && e) { e.classList.remove("fivebar-hidden"); const a = this.gameState.timeLeft / 1e3; this.elements.timeLeft && (this.elements.timeLeft.textContent = a.toFixed(1)), a <= 5 ? e.classList.add("fivebar-warning") : e.classList.remove("fivebar-warning") } else e && (e.classList.add("fivebar-hidden"), e.classList.remove("fivebar-warning")); const i = this.elements.scoreBadge; this.mode === "timed" && i ? (i.classList.remove("fivebar-hidden"), this.elements.score && (this.elements.score.textContent = this.gameState.score.toString())) : i && i.classList.add("fivebar-hidden"), this.elements.hiFk && (this.elements.hiFk.textContent = this.highScores.FK.toString()), this.elements.hiIk && (this.elements.hiIk.textContent = this.highScores.IK.toString()), this.elements.fkBtn?.classList.toggle("fivebar-active", this.controlMode === "FK"), this.elements.ikBtn?.classList.toggle("fivebar-active", this.controlMode === "IK"), this.elements.ikBtn?.classList.toggle("fivebar-ik", this.controlMode === "IK"); const s = this.elements.playBtn; s && (this.mode === "timed" || this.mode === "countdown" ? s.textContent = "Restart (R)" : s.textContent = "Play (P)") } render() { this.renderer.clear(); const t = this.kinematics.solveFK(this.angles.a1, this.angles.a2); if (this.elements.unreachable?.classList.toggle("fivebar-hidden", t.valid), this.renderer.drawMechanism(t, this.controlMode), this.mode === "timed" && this.gameState.target) { const e = this.gameState.timeLeft <= this.config.game.warningTime; this.renderer.drawTarget(this.gameState.target, e) } t.valid && (this.renderer.addToTrail(t.endEffector), this.kinematics.updateHistory(t.endEffector), this.checkHit(t.endEffector)) } showHelp() { this.elements.helpOverlay?.classList.add("fivebar-visible") } hideHelp() { this.elements.helpOverlay?.classList.remove("fivebar-visible") } toggleHelp() { this.elements.helpOverlay?.classList.toggle("fivebar-visible") } hideWelcome() { this.elements.welcomeOverlay?.classList.remove("fivebar-visible") } loadHighScores() { try { const t = localStorage.getItem("fiveBarHighScores"); if (t) { const e = JSON.parse(t); this.highScores = { ...this.highScores, ...e } } } catch (t) { console.warn("Could not load high scores:", t) } } saveHighScores() { try { localStorage.setItem("fiveBarHighScores", JSON.stringify(this.highScores)) } catch (t) { console.warn("Could not save high scores:", t) } } loadGamesPlayed() { try { const t = localStorage.getItem("fiveBarGamesPlayed"); if (t) { const e = JSON.parse(t); this.gamesPlayed = { ...this.gamesPlayed, ...e } } } catch (t) { console.warn("Could not load games played:", t) } } saveGamesPlayed() { try { localStorage.setItem("fiveBarGamesPlayed", JSON.stringify(this.gamesPlayed)) } catch (t) { console.warn("Could not save games played:", t) } } checkFirstTimeUser() { try { return localStorage.getItem("fiveBarTutorialCompleted") !== "true" } catch { return !0 } } markTutorialComplete() { try { localStorage.setItem("fiveBarTutorialCompleted", "true") } catch (t) { console.warn("Could not save tutorial completion:", t) } } replayTutorial() { try { localStorage.removeItem("fiveBarTutorialCompleted") } catch (t) { console.warn("Could not clear tutorial completion:", t) } this.mode !== "freeplay" && this.endGame(!0), this.startTutorial() } startTutorial() { this.tutorialActive = !0, this.tutorialStep = "motor1", this.tutorialProgress.initialM1 = this.angles.a1, this.tutorialProgress.initialM2 = this.angles.a2, this.tutorialProgress.motor1Moved = !1, this.tutorialProgress.motor2Moved = !1, this.showTutorialOverlay() } showTutorialOverlay() {
const e = { motor1: { title: "Control Motor 1", text: 'Move the <strong>left slider</strong> or press <span class="fivebar-key">A</span> / <span class="fivebar-key">D</span> keys to control Motor 1.', hint: "Try moving it now!" }, motor2: { title: "Great! Now Motor 2", text: 'Move the <strong>right slider</strong> or press <span class="fivebar-key">J</span> / <span class="fivebar-key">L</span> keys to control Motor 2.', hint: "See how both motors work together!" }, explore: { title: "Excellent! Explore a bit", text: "This is <strong>Forward Kinematics (FK)</strong> mode. You control the motor angles directly and see where the end effector (yellow dot) moves.", hint: "Play around for a few seconds..." }, choice: { title: "Ready for more?", text: 'You can either:<br>• Press <span class="fivebar-key">P</span> to play a 30-second challenge game<br>• Press <span class="fivebar-key">Tab</span> to try <strong>Inverse Kinematics (IK)</strong> mode', hint: "IK mode lets you control position directly instead of angles!" }, "ik-move": { title: "Welcome to IK Mode!", text: 'Now you control the <strong>end effector position</strong> directly:<br>• <span class="fivebar-key">A</span> / <span class="fivebar-key">D</span> = Left / Right<br>• <span class="fivebar-key">W</span> / <span class="fivebar-key">S</span> = Up / Down', hint: "Try moving around - notice how much easier it is!" }, complete: { title: "Tutorial Complete!", text: `You've learned the basics! Press <span class="fivebar-key">H</span> anytime for help, or <span class="fivebar-key">P</span> to start a game.`, hint: "Have fun exploring kinematics!" } }[this.tutorialStep], i = this.elements.tutorialBanner; if (!i) return; i.classList.remove("fivebar-hidden"); const s = this.getTutorialStepNumber(), a = this.tutorialStep === "choice" || this.tutorialStep === "complete"; i.innerHTML = `
<div class="fivebar-tutorial-content">
<h3>${e.title}</h3>
<p>${e.text}</p>
<p class="fivebar-tutorial-hint">${e.hint}</p>
${a ? '<button class="fivebar-tutorial-skip" data-skip-tutorial>Skip Tutorial</button>' : `<p class="fivebar-tutorial-progress">Tutorial step ${s} of 6</p>`}
</div>
`, i.querySelector("[data-skip-tutorial]")?.addEventListener("click", () => this.completeTutorial())
} hideTutorialOverlay() { const t = this.elements.tutorialBanner; t && t.classList.add("fivebar-hidden") } getTutorialStepNumber() { return ["motor1", "motor2", "explore", "choice", "ik-move", "complete"].indexOf(this.tutorialStep) + 1 } updateTutorial(t) { if (this.tutorialActive) switch (this.tutorialStep) { case "motor1": Math.abs(this.angles.a1 - this.tutorialProgress.initialM1) > 10 && (this.tutorialProgress.motor1Moved = !0, this.tutorialStep = "motor2", this.tutorialProgress.initialM2 = this.angles.a2, this.showTutorialOverlay()); break; case "motor2": Math.abs(this.angles.a2 - this.tutorialProgress.initialM2) > 10 && (this.tutorialProgress.motor2Moved = !0, this.tutorialStep = "explore", this.hideTutorialOverlay(), setTimeout(() => { this.tutorialStep === "explore" && (this.tutorialStep = "choice", this.showTutorialOverlay()) }, 5e3)); break } } advanceTutorialFromChoice(t) { !this.tutorialActive || this.tutorialStep !== "choice" || (this.hideTutorialOverlay(), t === "ik" ? (this.tutorialStep = "ik-move", setTimeout(() => { this.tutorialActive && this.tutorialStep === "ik-move" && (this.showTutorialOverlay(), setTimeout(() => { this.tutorialActive && this.tutorialStep === "ik-move" && this.completeTutorial() }, 8e3)) }, 500)) : this.completeTutorial()) } completeTutorial() { this.tutorialActive = !1, this.hideTutorialOverlay(), this.markTutorialComplete() } start() { this.renderer.setupResizeObserver(() => this.render()), this.renderer.resize(), this.updateSliders(); const t = e => { const i = Math.min(.05, (e - this.lastUpdate) / 1e3); this.lastUpdate = e, this.update(i), this.render(), this.animationFrameId = requestAnimationFrame(t) }; this.animationFrameId = requestAnimationFrame(t), this.checkFirstTimeUser() ? setTimeout(() => { this.elements.loading?.classList.add("fivebar-hidden"), this.startTutorial() }, 300) : setTimeout(() => { this.elements.loading?.classList.add("fivebar-hidden") }, 300) } cleanup() { this.animationFrameId !== null && (cancelAnimationFrame(this.animationFrameId), this.animationFrameId = null), this.renderer.cleanup(), window.removeEventListener("keydown", this.handleKeyDown), window.removeEventListener("keyup", this.handleKeyUp) }
} function L(r) { const t = r.querySelector("[data-canvas]"); if (!t) throw new Error("FiveBarIKGame: Canvas element not found"); const e = new S(x), i = new T(t, x), s = new M(r, x, e, i); return console.log("FiveBarIKGame: Starting game..."), s.start(), console.log("FiveBarIKGame: Game started, returning cleanup function"), () => s.cleanup() } export { L as createFiveBarIKGame };