diff --git a/README.md b/README.md index e9e416c..9639549 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ Physilinks is a browser-based physics linking game built with Matter.js. Match a ## Tech notes - **Engine**: Matter.js (via CDN). Canvas rendering with custom overlays for HUD, pause, game over, and score popups. -- **Scenes**: Defined in `main.js` as an array of presets (config + `createBodies` factory). Applying a scene updates gravity, spawn rate, ball radius, palette, and static bodies, then restarts the game. +- **Scenes**: Defined in `main.js` as an array of presets (config + `createBodies` factory). Applying a scene updates gravity, spawn rate, ball radius, palette, link constraints (stiffness/stretch/damping/width), and static bodies, then restarts the game. - **Physics entities**: Falling balls (`Bodies.circle`) with gentle restitution/friction; static boundaries/obstacles per scene. The top is open; sides and floor are static bodies. - **Input**: Pointer/touch events mapped to scene coords; chain state tracks bodies and a dashed preview line to the pointer. Undo by dragging back to the previous node. - **Scoring**: `10 × length²` per cleared chain. Score popup rendered as DOM element near release point. @@ -22,5 +22,5 @@ Physilinks is a browser-based physics linking game built with Matter.js. Match a ## Development quick start - No build step. Open `index.html` directly in the browser. -- Key files: `index.html` (layout/styles), `main.js` (game logic). +- Key files: `index.html` (layout), `styles.css` (styling), `main.js` (game logic). - Adjust or add scenes in `main.js` by extending the `scenes` array with config and a `createBodies(width, height)` function. diff --git a/index.html b/index.html index 3968c12..3becc5b 100644 --- a/index.html +++ b/index.html @@ -10,289 +10,8 @@ href="https://fonts.googleapis.com/css2?family=Manrope:wght@500;700&display=swap" rel="stylesheet" /> - + +
diff --git a/main.js b/main.js index 807aab9..4c61321 100644 --- a/main.js +++ b/main.js @@ -17,6 +17,7 @@ minChain: 3, palette: ["#ff595e", "#ffca3a", "#8ac926", "#1982c4", "#6a4c93"], ballRadius: 18, + link: { stiffness: 0.9, lengthScale: 1, damping: 0.05, lineWidth: 3 }, }; const scenes = [ @@ -24,11 +25,12 @@ id: "scene1", name: "Balanced (default)", config: { - gravity: 0.88, - spawnIntervalMs: 720, + gravity: 1, + spawnIntervalMs: 520, minChain: 3, palette: ["#ff595e", "#ffca3a", "#8ac926", "#1982c4", "#6a4c93"], - ballRadius: 38, + ballRadius: 18, + link: { stiffness: 0.9, lengthScale: 1, damping: 0.05, lineWidth: 3 }, }, createBodies: (w, h) => [ Bodies.rectangle(w / 2, h + 40, w, 80, { @@ -56,6 +58,7 @@ minChain: 3, palette: ["#fb7185", "#fbbf24", "#34d399", "#38bdf8"], ballRadius: 22, + link: { stiffness: 0.6, lengthScale: 1.2, damping: 0.01, lineWidth: 4 }, }, createBodies: (w, h) => [ Bodies.rectangle(w / 2, h + 50, w, 100, { @@ -87,6 +90,7 @@ minChain: 3, palette: ["#e879f9", "#38bdf8", "#f97316", "#22c55e"], ballRadius: 16, + link: { stiffness: 1, lengthScale: 0.85, damping: 0.15, lineWidth: 3 }, }, createBodies: (w, h) => { const bodies = [ @@ -211,6 +215,7 @@ currentScene = next; if (sceneSelectEl) sceneSelectEl.value = next.id; Object.assign(config, next.config); + config.link = { ...next.config.link }; engine.gravity.y = config.gravity; highScore = loadHighScore(next.id); rebuildSceneBodies(); @@ -353,14 +358,16 @@ const addToChain = (body) => { const last = chain.bodies[chain.bodies.length - 1]; const dist = Vector.magnitude(Vector.sub(last.position, body.position)); + const linkCfg = config.link || {}; const constraint = Constraint.create({ bodyA: last, bodyB: body, - length: dist, - stiffness: 0.9, + length: dist * (linkCfg.lengthScale ?? 1), + stiffness: linkCfg.stiffness ?? 0.9, + damping: linkCfg.damping ?? 0, render: { strokeStyle: chain.color, - lineWidth: 3, + lineWidth: linkCfg.lineWidth ?? 3, }, }); chain.constraints.push(constraint); diff --git a/styles.css b/styles.css new file mode 100644 index 0000000..5fb5c5b --- /dev/null +++ b/styles.css @@ -0,0 +1,238 @@ +:root { + --bg: #0f172a; + --panel: #111827; + --accent: #22d3ee; + --text: #e2e8f0; + --muted: #94a3b8; +} +* { box-sizing: border-box; } +body { + margin: 0; + font-family: 'Manrope', system-ui, -apple-system, sans-serif; + background: radial-gradient(circle at 25% 20%, rgba(56,189,248,0.12), transparent 25%), + radial-gradient(circle at 80% 10%, rgba(167,139,250,0.16), transparent 30%), + radial-gradient(circle at 40% 80%, rgba(52,211,153,0.12), transparent 25%), + var(--bg); + color: var(--text); + min-height: 100vh; + display: flex; + align-items: center; + justify-content: center; + padding: 20px; +} +.shell { + width: min(1100px, 100%); + background: rgba(17, 24, 39, 0.72); + border: 1px solid rgba(148, 163, 184, 0.1); + box-shadow: 0 20px 60px rgba(0,0,0,0.35); + border-radius: 18px; + overflow: hidden; + position: relative; + backdrop-filter: blur(10px); +} +header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 14px 18px; + background: rgba(255, 255, 255, 0.02); + border-bottom: 1px solid rgba(148, 163, 184, 0.08); +} +header h1 { + font-size: 20px; + margin: 0; + letter-spacing: 0.5px; +} +header .meta { + display: flex; + gap: 14px; + align-items: center; + font-size: 13px; + color: var(--muted); +} +header .pill { + padding: 6px 10px; + border-radius: 8px; + background: rgba(34, 211, 238, 0.12); + color: #67e8f9; + border: 1px solid rgba(34, 211, 238, 0.35); + font-weight: 700; + font-size: 12px; + letter-spacing: 0.6px; + text-transform: uppercase; +} +.pause-btn { + background: rgba(34, 211, 238, 0.14); + color: #67e8f9; + border: 1px solid rgba(34, 211, 238, 0.4); + border-radius: 10px; + padding: 8px 12px; + font-weight: 700; + cursor: pointer; + transition: transform 120ms ease, filter 120ms ease; +} +.pause-btn:hover { + filter: brightness(1.05); + transform: translateY(-1px); +} +.pause-btn:active { + transform: translateY(0); +} +#scene-wrapper { + position: relative; + width: 100%; + height: 680px; + background: radial-gradient(circle at 30% 30%, rgba(255,255,255,0.02), transparent 40%), + radial-gradient(circle at 75% 60%, rgba(255,255,255,0.02), transparent 45%), + #0b1222; +} +canvas { + display: block; + width: 100%; + height: 100%; +} +.hud-bar { + display: flex; + gap: 10px; + flex-wrap: wrap; + padding: 12px 16px; + background: rgba(255, 255, 255, 0.03); + border-top: 1px solid rgba(148, 163, 184, 0.08); +} +.hud-bar .card { + background: rgba(255, 255, 255, 0.04); + padding: 10px 12px; + border: 1px solid rgba(148, 163, 184, 0.14); + border-radius: 10px; + font-size: 13px; + color: var(--muted); + display: flex; + align-items: center; + gap: 6px; +} +.hud-bar .card strong { + color: var(--text); + font-size: 13px; +} +.selector { + background: rgba(0, 0, 0, 0.25); + color: var(--text); + border: 1px solid rgba(148, 163, 184, 0.3); + border-radius: 8px; + padding: 6px 8px; + font-size: 13px; +} +.legend { + position: absolute; + top: 16px; + right: 16px; + background: rgba(255, 255, 255, 0.04); + padding: 10px 12px; + border: 1px solid rgba(148, 163, 184, 0.14); + border-radius: 10px; + display: flex; + gap: 8px; + flex-wrap: wrap; + align-items: center; + pointer-events: none; +} +.legend span { + width: 18px; + height: 18px; + border-radius: 50%; + border: 1px solid rgba(255, 255, 255, 0.12); + display: inline-block; +} +.pause-overlay { + position: absolute; + top: 12px; + left: 50%; + transform: translateX(-50%); + background: rgba(255, 255, 255, 0.08); + border: 1px solid rgba(148, 163, 184, 0.3); + color: #e2e8f0; + padding: 8px 14px; + border-radius: 12px; + font-weight: 800; + letter-spacing: 0.5px; + opacity: 0; + pointer-events: none; + transition: opacity 160ms ease; +} +.pause-overlay.visible { + opacity: 1; +} +.game-over { + position: absolute; + inset: 0; + background: rgba(10, 13, 25, 0.72); + backdrop-filter: blur(8px); + display: flex; + align-items: center; + justify-content: center; + pointer-events: none; + opacity: 0; + transition: opacity 200ms ease; +} +.game-over.visible { + pointer-events: auto; + opacity: 1; +} +.game-over__card { + background: rgba(255, 255, 255, 0.04); + border: 1px solid rgba(148, 163, 184, 0.18); + border-radius: 14px; + padding: 20px 24px; + text-align: center; + min-width: 240px; + box-shadow: 0 12px 35px rgba(0, 0, 0, 0.35); +} +.game-over__card .title { + font-size: 22px; + font-weight: 700; + margin-bottom: 10px; +} +.game-over__card .score-line { + font-size: 14px; + color: var(--muted); + margin-bottom: 14px; +} +.game-over__card button { + background: linear-gradient(135deg, #22d3ee, #0ea5e9); + border: none; + color: #0b1222; + font-weight: 700; + padding: 10px 16px; + border-radius: 10px; + cursor: pointer; + font-size: 14px; +} +.game-over__card button:hover { + filter: brightness(1.08); +} +.floating-score { + position: absolute; + color: #e0f2fe; + font-weight: 800; + font-size: 18px; + pointer-events: none; + text-shadow: 0 8px 20px rgba(0, 0, 0, 0.35); + animation: floatUp 900ms ease-out forwards; +} +@keyframes floatUp { + 0% { opacity: 1; transform: translate(-50%, 0); } + 60% { opacity: 0.9; transform: translate(-50%, -22px); } + 100% { opacity: 0; transform: translate(-50%, -38px); } +} +.instructions { + padding: 14px 18px; + font-size: 14px; + color: var(--muted); + background: rgba(255, 255, 255, 0.03); + border-top: 1px solid rgba(148, 163, 184, 0.08); + line-height: 1.6; +} +@media (max-width: 800px) { + #scene-wrapper { height: 520px; } + header h1 { font-size: 18px; } +}