Add packed stack blocks and chain timer loss
This commit is contained in:
@@ -38,3 +38,9 @@ Physilinks is a browser-based physics linking game built with Matter.js. Match a
|
|||||||
- No build step. Open `index.html` directly in the browser.
|
- No build step. Open `index.html` directly in the browser.
|
||||||
- Key files: `index.html` (layout), `styles.css` (styling), `src/ui.js` (DOM/HUD), `src/main.js` (physics/game logic), `src/scenes/index.js` (scene registration).
|
- Key files: `index.html` (layout), `styles.css` (styling), `src/ui.js` (DOM/HUD), `src/main.js` (physics/game logic), `src/scenes/index.js` (scene registration).
|
||||||
- Adjust or add scenes by extending the files in `src/scenes/` with config and a `createBodies(width, height)` function.
|
- Adjust or add scenes by extending the files in `src/scenes/` with config and a `createBodies(width, height)` function.
|
||||||
|
|
||||||
|
## Adding a new scene
|
||||||
|
- Create `src/scenes/scene-<your-id>.js` based on `src/scenes/scene-template.js` or an existing scene, and keep the `id` aligned to the filename suffix.
|
||||||
|
- Add the script tag to `index.html` with the other scene files so it loads in the browser.
|
||||||
|
- Register the scene id in `src/scenes/index.js` (append to `desiredOrder` or rely on the unordered fallback).
|
||||||
|
- If the scene uses new DOM ids/classes or storage keys, document them in your commit notes per the repo guidelines.
|
||||||
|
|||||||
11
index.html
11
index.html
@@ -57,6 +57,16 @@
|
|||||||
<div class="progress__bar" id="goal-progress"></div>
|
<div class="progress__bar" id="goal-progress"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="card progress-card timer-card" id="chain-timer-card">
|
||||||
|
<strong>Chain timer</strong>
|
||||||
|
<span id="chain-timer-label">—</span>
|
||||||
|
<div class="progress">
|
||||||
|
<div
|
||||||
|
class="progress__bar timer-bar"
|
||||||
|
id="chain-timer-progress"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<strong>Score</strong> <span id="score">0</span>
|
<strong>Score</strong> <span id="score">0</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -104,6 +114,7 @@
|
|||||||
<script src="./src/scenes/scene-storm-grid.js"></script>
|
<script src="./src/scenes/scene-storm-grid.js"></script>
|
||||||
<script src="./src/scenes/scene-stack-blocks-chaos.js"></script>
|
<script src="./src/scenes/scene-stack-blocks-chaos.js"></script>
|
||||||
<script src="./src/scenes/scene-stack-blocks.js"></script>
|
<script src="./src/scenes/scene-stack-blocks.js"></script>
|
||||||
|
<script src="./src/scenes/scene-stack-blocks-packed.js"></script>
|
||||||
<script src="./src/scenes/index.js"></script>
|
<script src="./src/scenes/index.js"></script>
|
||||||
<script src="./src/config.js"></script>
|
<script src="./src/config.js"></script>
|
||||||
<script src="./src/engine.js"></script>
|
<script src="./src/engine.js"></script>
|
||||||
|
|||||||
@@ -243,6 +243,18 @@
|
|||||||
updateLongestChain(chainLength);
|
updateLongestChain(chainLength);
|
||||||
const { gain, isNegativeProgress } = getChainScoreState();
|
const { gain, isNegativeProgress } = getChainScoreState();
|
||||||
state.score += gain;
|
state.score += gain;
|
||||||
|
const chainLose = currentScene?.config?.chainLose;
|
||||||
|
if (chainLose && Number.isFinite(chainLose.idleSec)) {
|
||||||
|
const now = Date.now();
|
||||||
|
const maxMs = chainLose.idleSec * 1000;
|
||||||
|
const bumpMs = maxMs / 3;
|
||||||
|
state.chainTimerDurationSec = chainLose.idleSec;
|
||||||
|
const currentEnd = state.chainTimerEndMs || now + maxMs;
|
||||||
|
const nextEnd = Math.min(now + maxMs, currentEnd + bumpMs);
|
||||||
|
state.chainTimerEndMs = nextEnd;
|
||||||
|
state.chainTimerLastDisplay = null;
|
||||||
|
state.chainTimerLastWarnSec = null;
|
||||||
|
}
|
||||||
const clearTargets = collectClearTargets();
|
const clearTargets = collectClearTargets();
|
||||||
state.clearedCount += chainLength;
|
state.clearedCount += chainLength;
|
||||||
if (state.score > state.highScore) {
|
if (state.score > state.highScore) {
|
||||||
|
|||||||
56
src/loop.js
56
src/loop.js
@@ -12,6 +12,7 @@
|
|||||||
getMaxLinkDistance,
|
getMaxLinkDistance,
|
||||||
updateHud,
|
updateHud,
|
||||||
checkWinCondition,
|
checkWinCondition,
|
||||||
|
triggerGameOver,
|
||||||
ui,
|
ui,
|
||||||
}) => {
|
}) => {
|
||||||
const runSceneBeforeUpdateHook = () => {
|
const runSceneBeforeUpdateHook = () => {
|
||||||
@@ -95,6 +96,60 @@
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const stepChainLoseTimer = () => {
|
||||||
|
const chainLose = getCurrentScene()?.config?.chainLose;
|
||||||
|
if (!chainLose || !Number.isFinite(chainLose.idleSec)) return;
|
||||||
|
if (!state.chainTimerEndMs) return;
|
||||||
|
if (state.chainTimerFrozen) return;
|
||||||
|
const duration = state.chainTimerDurationSec ?? chainLose.idleSec;
|
||||||
|
if (!Number.isFinite(duration) || duration <= 0) return;
|
||||||
|
const now = Date.now();
|
||||||
|
const remainingMs = Math.max(0, state.chainTimerEndMs - now);
|
||||||
|
const remainingSec = Math.ceil(remainingMs / 1000);
|
||||||
|
const progress = (100 * remainingSec) / duration;
|
||||||
|
const warnAt = chainLose.warnAtSec ?? 3;
|
||||||
|
const urgent = remainingSec <= warnAt;
|
||||||
|
if (state.chainTimerLastDisplay !== remainingSec) {
|
||||||
|
state.chainTimerLastDisplay = remainingSec;
|
||||||
|
if (ui.setChainTimer) {
|
||||||
|
ui.setChainTimer({
|
||||||
|
label: `${remainingSec}s left`,
|
||||||
|
progress,
|
||||||
|
urgent,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
urgent &&
|
||||||
|
remainingSec > 0 &&
|
||||||
|
state.chainTimerLastWarnSec !== remainingSec
|
||||||
|
) {
|
||||||
|
state.chainTimerLastWarnSec = remainingSec;
|
||||||
|
const text =
|
||||||
|
chainLose.countdownMessage ||
|
||||||
|
(remainingSec <= 1 ? "Chain now!" : `${remainingSec}`);
|
||||||
|
ui.showFloatingMessage(
|
||||||
|
{ text },
|
||||||
|
{
|
||||||
|
durationMs: chainLose.countdownDurationMs ?? 700,
|
||||||
|
position: chainLose.countdownPosition || {
|
||||||
|
xPercent: 50,
|
||||||
|
yPercent: 18,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else if (ui.setChainTimer) {
|
||||||
|
ui.setChainTimer({
|
||||||
|
label: `${remainingSec}s left`,
|
||||||
|
progress,
|
||||||
|
urgent,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (remainingMs <= 0) {
|
||||||
|
triggerGameOver?.({ force: true });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const keepBallsInBounds = () => {
|
const keepBallsInBounds = () => {
|
||||||
const currentScene = getCurrentScene();
|
const currentScene = getCurrentScene();
|
||||||
state.balls.forEach((ball) => {
|
state.balls.forEach((ball) => {
|
||||||
@@ -140,6 +195,7 @@
|
|||||||
stepRotators(dt, timeScale);
|
stepRotators(dt, timeScale);
|
||||||
stepOscillators();
|
stepOscillators();
|
||||||
stepTimer();
|
stepTimer();
|
||||||
|
stepChainLoseTimer();
|
||||||
};
|
};
|
||||||
|
|
||||||
const linkSparkles = [];
|
const linkSparkles = [];
|
||||||
|
|||||||
140
src/main.js
140
src/main.js
@@ -142,6 +142,16 @@
|
|||||||
levelWon: false,
|
levelWon: false,
|
||||||
timerEndMs: null,
|
timerEndMs: null,
|
||||||
lastTimerDisplay: null,
|
lastTimerDisplay: null,
|
||||||
|
chainTimerEndMs: null,
|
||||||
|
chainTimerDurationSec: null,
|
||||||
|
chainTimerLastDisplay: null,
|
||||||
|
chainTimerLastWarnSec: null,
|
||||||
|
chainTimerIntroTimeoutId: null,
|
||||||
|
chainTimerIntroAtMs: null,
|
||||||
|
chainTimerIntroRemainingMs: null,
|
||||||
|
chainTimerIntroText: null,
|
||||||
|
chainTimerFrozen: false,
|
||||||
|
pauseStartMs: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
const BALL_BASELINE = 680; // reference height used for relative ball sizing
|
const BALL_BASELINE = 680; // reference height used for relative ball sizing
|
||||||
@@ -271,11 +281,19 @@
|
|||||||
const isGridScene = () =>
|
const isGridScene = () =>
|
||||||
currentScene && currentScene.config && currentScene.config.gridLayouts;
|
currentScene && currentScene.config && currentScene.config.gridLayouts;
|
||||||
|
|
||||||
const triggerGameOver = () => {
|
const triggerGameOver = (options = {}) => {
|
||||||
if (currentScene?.config?.noGameOver) return;
|
const force = options.force === true;
|
||||||
|
if (currentScene?.config?.noGameOver && !force) return;
|
||||||
if (state.gameOver) return;
|
if (state.gameOver) return;
|
||||||
state.gameOver = true;
|
state.gameOver = true;
|
||||||
state.paused = false;
|
state.paused = false;
|
||||||
|
if (state.chainTimerIntroTimeoutId) {
|
||||||
|
clearTimeout(state.chainTimerIntroTimeoutId);
|
||||||
|
state.chainTimerIntroTimeoutId = null;
|
||||||
|
state.chainTimerIntroAtMs = null;
|
||||||
|
state.chainTimerIntroRemainingMs = null;
|
||||||
|
state.chainTimerIntroText = null;
|
||||||
|
}
|
||||||
resetChainVisuals();
|
resetChainVisuals();
|
||||||
spawnSystem?.stopSpawner();
|
spawnSystem?.stopSpawner();
|
||||||
stopRunner();
|
stopRunner();
|
||||||
@@ -314,10 +332,23 @@
|
|||||||
const createLoop = load("PhysilinksLoop", { create: "create" });
|
const createLoop = load("PhysilinksLoop", { create: "create" });
|
||||||
|
|
||||||
const restartGame = () => {
|
const restartGame = () => {
|
||||||
|
if (state.chainTimerIntroTimeoutId) {
|
||||||
|
clearTimeout(state.chainTimerIntroTimeoutId);
|
||||||
|
state.chainTimerIntroTimeoutId = null;
|
||||||
|
state.chainTimerIntroAtMs = null;
|
||||||
|
state.chainTimerIntroRemainingMs = null;
|
||||||
|
state.chainTimerIntroText = null;
|
||||||
|
}
|
||||||
spawnSystem.stopSpawner();
|
spawnSystem.stopSpawner();
|
||||||
state.gameOver = false;
|
state.gameOver = false;
|
||||||
state.paused = false;
|
state.paused = false;
|
||||||
|
state.pauseStartMs = null;
|
||||||
state.levelWon = false;
|
state.levelWon = false;
|
||||||
|
state.chainTimerFrozen = false;
|
||||||
|
if (currentScene?.id === "stack-blocks-packed") {
|
||||||
|
engine.plugin ??= {};
|
||||||
|
engine.plugin.stackBlocksPacked = null;
|
||||||
|
}
|
||||||
spawnSystem.resetSpawnState();
|
spawnSystem.resetSpawnState();
|
||||||
state.score = 0;
|
state.score = 0;
|
||||||
state.clearedCount = 0;
|
state.clearedCount = 0;
|
||||||
@@ -333,6 +364,59 @@
|
|||||||
state.timerEndMs = null;
|
state.timerEndMs = null;
|
||||||
state.lastTimerDisplay = null;
|
state.lastTimerDisplay = null;
|
||||||
}
|
}
|
||||||
|
const chainLose = currentScene?.config?.chainLose;
|
||||||
|
if (
|
||||||
|
chainLose &&
|
||||||
|
Number.isFinite(chainLose.idleSec) &&
|
||||||
|
chainLose.idleSec > 0
|
||||||
|
) {
|
||||||
|
state.chainTimerDurationSec = chainLose.idleSec;
|
||||||
|
state.chainTimerEndMs = Date.now() + chainLose.idleSec * 1000;
|
||||||
|
state.chainTimerLastDisplay = null;
|
||||||
|
state.chainTimerLastWarnSec = null;
|
||||||
|
state.chainTimerFrozen = false;
|
||||||
|
if (ui.setChainTimerVisibility) {
|
||||||
|
ui.setChainTimerVisibility(true);
|
||||||
|
}
|
||||||
|
if (ui.setChainTimer) {
|
||||||
|
ui.setChainTimer({
|
||||||
|
label: `${chainLose.idleSec}s left`,
|
||||||
|
progress: 100,
|
||||||
|
urgent: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const introDelay = chainLose.introDelayMs ?? 1200;
|
||||||
|
const introText =
|
||||||
|
chainLose.introMessage || `Make a chain in ${chainLose.idleSec}s`;
|
||||||
|
if (introDelay >= 0) {
|
||||||
|
state.chainTimerIntroText = introText;
|
||||||
|
state.chainTimerIntroAtMs = Date.now() + introDelay;
|
||||||
|
state.chainTimerIntroTimeoutId = setTimeout(() => {
|
||||||
|
state.chainTimerIntroAtMs = null;
|
||||||
|
state.chainTimerIntroText = null;
|
||||||
|
ui.showFloatingMessage(
|
||||||
|
{ text: introText },
|
||||||
|
{
|
||||||
|
durationMs: chainLose.introDurationMs ?? 2600,
|
||||||
|
position: chainLose.introPosition || {
|
||||||
|
xPercent: 50,
|
||||||
|
yPercent: 12,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}, introDelay);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
state.chainTimerDurationSec = null;
|
||||||
|
state.chainTimerEndMs = null;
|
||||||
|
state.chainTimerLastDisplay = null;
|
||||||
|
state.chainTimerLastWarnSec = null;
|
||||||
|
state.chainTimerIntroText = null;
|
||||||
|
state.chainTimerFrozen = false;
|
||||||
|
if (ui.setChainTimerVisibility) {
|
||||||
|
ui.setChainTimerVisibility(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
resetChainVisuals();
|
resetChainVisuals();
|
||||||
ui.clearMessages();
|
ui.clearMessages();
|
||||||
state.balls.forEach((ball) => {
|
state.balls.forEach((ball) => {
|
||||||
@@ -367,11 +451,54 @@
|
|||||||
state.paused = nextState;
|
state.paused = nextState;
|
||||||
ui.setPauseState(state.paused);
|
ui.setPauseState(state.paused);
|
||||||
if (state.paused) {
|
if (state.paused) {
|
||||||
|
state.pauseStartMs = Date.now();
|
||||||
|
if (state.chainTimerIntroTimeoutId && state.chainTimerIntroAtMs) {
|
||||||
|
state.chainTimerIntroRemainingMs = Math.max(
|
||||||
|
0,
|
||||||
|
state.chainTimerIntroAtMs - Date.now(),
|
||||||
|
);
|
||||||
|
clearTimeout(state.chainTimerIntroTimeoutId);
|
||||||
|
state.chainTimerIntroTimeoutId = null;
|
||||||
|
state.chainTimerIntroAtMs = null;
|
||||||
|
}
|
||||||
resetChainVisuals();
|
resetChainVisuals();
|
||||||
spawnSystem.stopSpawner();
|
spawnSystem.stopSpawner();
|
||||||
stopRunner();
|
stopRunner();
|
||||||
engine.timing.timeScale = 0;
|
engine.timing.timeScale = 0;
|
||||||
} else {
|
} else {
|
||||||
|
if (state.pauseStartMs) {
|
||||||
|
const pausedFor = Date.now() - state.pauseStartMs;
|
||||||
|
state.pauseStartMs = null;
|
||||||
|
if (state.timerEndMs) state.timerEndMs += pausedFor;
|
||||||
|
if (state.chainTimerEndMs) state.chainTimerEndMs += pausedFor;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
state.chainTimerIntroRemainingMs &&
|
||||||
|
state.chainTimerIntroRemainingMs > 0
|
||||||
|
) {
|
||||||
|
const introText = state.chainTimerIntroText;
|
||||||
|
const chainLose = currentScene?.config?.chainLose;
|
||||||
|
state.chainTimerIntroAtMs =
|
||||||
|
Date.now() + state.chainTimerIntroRemainingMs;
|
||||||
|
state.chainTimerIntroTimeoutId = setTimeout(() => {
|
||||||
|
state.chainTimerIntroAtMs = null;
|
||||||
|
state.chainTimerIntroRemainingMs = null;
|
||||||
|
if (introText) {
|
||||||
|
ui.showFloatingMessage(
|
||||||
|
{ text: introText },
|
||||||
|
{
|
||||||
|
durationMs: chainLose?.introDurationMs ?? 2600,
|
||||||
|
position: chainLose?.introPosition || {
|
||||||
|
xPercent: 50,
|
||||||
|
yPercent: 12,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
state.chainTimerIntroText = null;
|
||||||
|
}, state.chainTimerIntroRemainingMs);
|
||||||
|
state.chainTimerIntroRemainingMs = null;
|
||||||
|
}
|
||||||
startRunner();
|
startRunner();
|
||||||
spawnSystem.startSpawner();
|
spawnSystem.startSpawner();
|
||||||
engine.timing.timeScale = 1;
|
engine.timing.timeScale = 1;
|
||||||
@@ -436,6 +563,14 @@
|
|||||||
const goal = goals.getGoalState();
|
const goal = goals.getGoalState();
|
||||||
ui.setGoal(goal || { label: "—", progress: 0 }, config.goalEffects);
|
ui.setGoal(goal || { label: "—", progress: 0 }, config.goalEffects);
|
||||||
if (!goal || !goal.met) return;
|
if (!goal || !goal.met) return;
|
||||||
|
if (state.chainTimerIntroTimeoutId) {
|
||||||
|
clearTimeout(state.chainTimerIntroTimeoutId);
|
||||||
|
state.chainTimerIntroTimeoutId = null;
|
||||||
|
state.chainTimerIntroAtMs = null;
|
||||||
|
state.chainTimerIntroRemainingMs = null;
|
||||||
|
state.chainTimerIntroText = null;
|
||||||
|
}
|
||||||
|
state.chainTimerFrozen = true;
|
||||||
applyWinEffects();
|
applyWinEffects();
|
||||||
state.levelWon = true;
|
state.levelWon = true;
|
||||||
spawnSystem.stopSpawner();
|
spawnSystem.stopSpawner();
|
||||||
@@ -547,6 +682,7 @@
|
|||||||
getMaxLinkDistance,
|
getMaxLinkDistance,
|
||||||
updateHud,
|
updateHud,
|
||||||
checkWinCondition,
|
checkWinCondition,
|
||||||
|
triggerGameOver,
|
||||||
ui,
|
ui,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
"scene-grid",
|
"scene-grid",
|
||||||
"balanced",
|
"balanced",
|
||||||
"stack-blocks",
|
"stack-blocks",
|
||||||
|
"stack-blocks-packed",
|
||||||
"scene-lava",
|
"scene-lava",
|
||||||
"swirl-arena",
|
"swirl-arena",
|
||||||
"relax",
|
"relax",
|
||||||
|
|||||||
237
src/scenes/scene-stack-blocks-packed.js
Normal file
237
src/scenes/scene-stack-blocks-packed.js
Normal file
@@ -0,0 +1,237 @@
|
|||||||
|
(() => {
|
||||||
|
const { Bodies, Query } = Matter;
|
||||||
|
const scenes = (window.PhysilinksSceneDefs =
|
||||||
|
window.PhysilinksSceneDefs || []);
|
||||||
|
|
||||||
|
const getSquareArea = ({ width, height, world }) => {
|
||||||
|
const size = Math.min(width, height);
|
||||||
|
const offset = world?.plugin?.squareOffset || 0;
|
||||||
|
return {
|
||||||
|
size,
|
||||||
|
left: (width - size) / 2 + offset,
|
||||||
|
top: (height - size) / 2 + offset,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const getSpawnInsets = ({ config, width, height, world }) => {
|
||||||
|
let left = 0;
|
||||||
|
let right = 0;
|
||||||
|
const insetVal = config.spawnInset;
|
||||||
|
if (Number.isFinite(insetVal)) {
|
||||||
|
left = insetVal;
|
||||||
|
right = insetVal;
|
||||||
|
}
|
||||||
|
if (typeof config.spawnInsets === "function") {
|
||||||
|
try {
|
||||||
|
const res = config.spawnInsets({ width, height, world });
|
||||||
|
if (res && Number.isFinite(res.left)) left = res.left;
|
||||||
|
if (res && Number.isFinite(res.right)) right = res.right;
|
||||||
|
} catch (err) {
|
||||||
|
console.error("spawnInsets function failed", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { left, right };
|
||||||
|
};
|
||||||
|
|
||||||
|
const getColumnCenters = ({ config, width, height, world }) => {
|
||||||
|
const columns = config.spawnColumns || 0;
|
||||||
|
if (!Number.isFinite(columns) || columns <= 0) return [];
|
||||||
|
const squareArea = getSquareArea({ width, height, world });
|
||||||
|
const insets = getSpawnInsets({ config, width, height, world });
|
||||||
|
const usableWidth = Math.max(
|
||||||
|
0,
|
||||||
|
squareArea.size - insets.left - insets.right,
|
||||||
|
);
|
||||||
|
const columnWidth = usableWidth / columns;
|
||||||
|
const minX = squareArea.left + insets.left + config.ballRadius + 2;
|
||||||
|
const maxX =
|
||||||
|
squareArea.left +
|
||||||
|
squareArea.size -
|
||||||
|
insets.right -
|
||||||
|
config.ballRadius -
|
||||||
|
2;
|
||||||
|
return Array.from({ length: columns }, (_, index) => {
|
||||||
|
const x =
|
||||||
|
squareArea.left +
|
||||||
|
insets.left +
|
||||||
|
columnWidth * (index + 0.5);
|
||||||
|
return Math.max(minX, Math.min(maxX, x));
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const getRowCount = ({ config, width, height, world }) => {
|
||||||
|
const squareArea = getSquareArea({ width, height, world });
|
||||||
|
const side = config.ballRadius * 2;
|
||||||
|
const rowGap = side * (config.rowGapMultiplier ?? 1);
|
||||||
|
const usableHeight = squareArea.size - config.ballRadius * 2;
|
||||||
|
return Math.max(1, Math.floor(usableHeight / rowGap) + 1);
|
||||||
|
};
|
||||||
|
|
||||||
|
const findOpenTopSlot = ({ config, width, height, world, balls }) => {
|
||||||
|
const squareArea = getSquareArea({ width, height, world });
|
||||||
|
const centers = getColumnCenters({ config, width, height, world });
|
||||||
|
const y = squareArea.top + config.ballRadius;
|
||||||
|
const halfSize = config.ballRadius * 1.05;
|
||||||
|
for (let i = 0; i < centers.length; i += 1) {
|
||||||
|
const x = centers[i];
|
||||||
|
const region = {
|
||||||
|
min: { x: x - halfSize, y: y - halfSize },
|
||||||
|
max: { x: x + halfSize, y: y + halfSize },
|
||||||
|
};
|
||||||
|
const hits = Query.region(balls, region);
|
||||||
|
if (!hits || hits.length === 0) {
|
||||||
|
return { x, y };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
scenes.push({
|
||||||
|
id: "stack-blocks-packed",
|
||||||
|
name: "Stack Blocks Packed",
|
||||||
|
config: {
|
||||||
|
gravity: 1,
|
||||||
|
spawnIntervalMs: 900,
|
||||||
|
autoSpawn: false,
|
||||||
|
minChain: 3,
|
||||||
|
palette: ["#38bdf8", "#f97316", "#facc15", "#22c55e"],
|
||||||
|
ballRadius: 18,
|
||||||
|
ballShape: "rect",
|
||||||
|
spawnColumns: 10,
|
||||||
|
sizeFromColumns: true,
|
||||||
|
initialRows: null,
|
||||||
|
requireClearSpawn: false,
|
||||||
|
squarePlayArea: true,
|
||||||
|
rowGapMultiplier: 1,
|
||||||
|
noGameOver: true,
|
||||||
|
chainLose: {
|
||||||
|
idleSec: 15,
|
||||||
|
warnAtSec: 3,
|
||||||
|
introDelayMs: 1200,
|
||||||
|
},
|
||||||
|
ballPhysics: {
|
||||||
|
restitution: 0.35,
|
||||||
|
friction: 0.18,
|
||||||
|
frictionAir: 0.02,
|
||||||
|
frictionStatic: 0.45,
|
||||||
|
density: 0.003,
|
||||||
|
},
|
||||||
|
spawnInsets: ({ width }) => {
|
||||||
|
const wallThickness = Math.max(20, width * 0.02);
|
||||||
|
return { left: 0, right: 0 };
|
||||||
|
},
|
||||||
|
winCondition: {
|
||||||
|
type: "score",
|
||||||
|
target: 30000,
|
||||||
|
},
|
||||||
|
link: {
|
||||||
|
stiffness: 0.85,
|
||||||
|
lengthScale: 1.15,
|
||||||
|
damping: 0.08,
|
||||||
|
lineWidth: 3,
|
||||||
|
rope: true,
|
||||||
|
renderType: "line",
|
||||||
|
maxLengthMultiplier: 2.5,
|
||||||
|
clearAnimation: {
|
||||||
|
type: "shatter",
|
||||||
|
durationMs: 380,
|
||||||
|
sizeScale: 2.2,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
onBeforeUpdate: ({
|
||||||
|
engine,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
state,
|
||||||
|
spawnSystem,
|
||||||
|
config,
|
||||||
|
}) => {
|
||||||
|
if (!state || !spawnSystem || state.paused || state.levelWon) return;
|
||||||
|
engine.plugin ??= {};
|
||||||
|
const packedState = engine.plugin.stackBlocksPacked || {};
|
||||||
|
if (packedState.sceneId !== "stack-blocks-packed") {
|
||||||
|
packedState.sceneId = "stack-blocks-packed";
|
||||||
|
packedState.seeded = false;
|
||||||
|
}
|
||||||
|
if (!packedState.seeded) {
|
||||||
|
const rows = getRowCount({
|
||||||
|
config,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
world: engine.world,
|
||||||
|
});
|
||||||
|
const squareArea = getSquareArea({
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
world: engine.world,
|
||||||
|
});
|
||||||
|
const rowGap = config.ballRadius * 2 * (config.rowGapMultiplier ?? 1);
|
||||||
|
for (let r = 0; r < rows; r += 1) {
|
||||||
|
const y = squareArea.top + config.ballRadius + r * rowGap;
|
||||||
|
spawnSystem.spawnRowAtY({ y, markEntered: true });
|
||||||
|
}
|
||||||
|
packedState.seeded = true;
|
||||||
|
engine.plugin.stackBlocksPacked = packedState;
|
||||||
|
}
|
||||||
|
if (state.gameOver) return;
|
||||||
|
const openSlot = findOpenTopSlot({
|
||||||
|
config,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
world: engine.world,
|
||||||
|
balls: state.balls,
|
||||||
|
});
|
||||||
|
if (openSlot) {
|
||||||
|
spawnSystem.spawnAtPosition({
|
||||||
|
x: openSlot.x,
|
||||||
|
y: openSlot.y,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
createBodies: (w, h) => {
|
||||||
|
const squareSize = Math.min(w, h);
|
||||||
|
const left = (w - squareSize) / 2;
|
||||||
|
const wallThickness = Math.max(20, w * 0.02);
|
||||||
|
const floorHeight = Math.max(30, squareSize * 0.06);
|
||||||
|
const wallRender = {
|
||||||
|
fillStyle: "#1e293b",
|
||||||
|
strokeStyle: "#94a3b8",
|
||||||
|
lineWidth: 2,
|
||||||
|
};
|
||||||
|
return [
|
||||||
|
Bodies.rectangle(
|
||||||
|
left - wallThickness / 2,
|
||||||
|
h / 2,
|
||||||
|
wallThickness,
|
||||||
|
h + wallThickness * 2,
|
||||||
|
{
|
||||||
|
isStatic: true,
|
||||||
|
render: wallRender,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
Bodies.rectangle(
|
||||||
|
left + squareSize + wallThickness / 2,
|
||||||
|
h / 2,
|
||||||
|
wallThickness,
|
||||||
|
h + wallThickness * 2,
|
||||||
|
{
|
||||||
|
isStatic: true,
|
||||||
|
render: wallRender,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
Bodies.rectangle(
|
||||||
|
w / 2,
|
||||||
|
h + floorHeight / 2,
|
||||||
|
w + wallThickness * 2,
|
||||||
|
floorHeight,
|
||||||
|
{
|
||||||
|
isStatic: true,
|
||||||
|
restitution: 0.2,
|
||||||
|
render: wallRender,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
];
|
||||||
|
},
|
||||||
|
});
|
||||||
|
})();
|
||||||
61
src/spawn.js
61
src/spawn.js
@@ -119,10 +119,13 @@
|
|||||||
|
|
||||||
const createBallBodies = (x, y, color) => {
|
const createBallBodies = (x, y, color) => {
|
||||||
const scene = getCurrentScene();
|
const scene = getCurrentScene();
|
||||||
|
const ballPhysics = scene?.config?.ballPhysics || {};
|
||||||
const commonOpts = {
|
const commonOpts = {
|
||||||
restitution: 0.72,
|
restitution: ballPhysics.restitution ?? 0.72,
|
||||||
friction: 0.01,
|
friction: ballPhysics.friction ?? 0.01,
|
||||||
frictionAir: 0.012,
|
frictionAir: ballPhysics.frictionAir ?? 0.012,
|
||||||
|
frictionStatic: ballPhysics.frictionStatic ?? 0,
|
||||||
|
density: ballPhysics.density ?? 0.001,
|
||||||
render: {
|
render: {
|
||||||
fillStyle: color,
|
fillStyle: color,
|
||||||
strokeStyle: "#0b1222",
|
strokeStyle: "#0b1222",
|
||||||
@@ -320,6 +323,57 @@
|
|||||||
spawnCount += batchCount;
|
spawnCount += batchCount;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const spawnAtPosition = ({
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
color,
|
||||||
|
markEntered = false,
|
||||||
|
enforceSpawnLimit = true,
|
||||||
|
} = {}) => {
|
||||||
|
if (isGameOver()) return 0;
|
||||||
|
const scene = getCurrentScene();
|
||||||
|
const sceneConfig = scene?.config || {};
|
||||||
|
const spawnLimit = sceneConfig.spawnLimit;
|
||||||
|
if (
|
||||||
|
enforceSpawnLimit &&
|
||||||
|
Number.isFinite(spawnLimit) &&
|
||||||
|
spawnCount >= spawnLimit
|
||||||
|
) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
const spawnColor =
|
||||||
|
color ??
|
||||||
|
config.palette[Math.floor(Math.random() * config.palette.length)];
|
||||||
|
const blob = createBallBodies(x, y, spawnColor);
|
||||||
|
if (blob.constraints.length > 0 && blob.blobId) {
|
||||||
|
blobConstraints.set(blob.blobId, blob.constraints);
|
||||||
|
}
|
||||||
|
blob.bodies.forEach((body) => {
|
||||||
|
body.plugin = body.plugin || {};
|
||||||
|
body.plugin.hasEntered = !!markEntered;
|
||||||
|
if (body.plugin.entryCheckId) {
|
||||||
|
clearTimeout(body.plugin.entryCheckId);
|
||||||
|
body.plugin.entryCheckId = null;
|
||||||
|
}
|
||||||
|
balls.push(body);
|
||||||
|
World.add(world, body);
|
||||||
|
if (!sceneConfig.noGameOver && !markEntered) {
|
||||||
|
body.plugin.entryCheckId = setTimeout(() => {
|
||||||
|
body.plugin.entryCheckId = null;
|
||||||
|
if (isGameOver()) return;
|
||||||
|
if (!body.plugin.hasEntered && Math.abs(body.velocity.y) < 0.2) {
|
||||||
|
triggerGameOver();
|
||||||
|
}
|
||||||
|
}, 1500);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (blob.constraints.length > 0) {
|
||||||
|
World.add(world, blob.constraints);
|
||||||
|
}
|
||||||
|
spawnCount += blob.bodies.length;
|
||||||
|
return blob.bodies.length;
|
||||||
|
};
|
||||||
|
|
||||||
const startSpawner = () => {
|
const startSpawner = () => {
|
||||||
if (isGridScene()) return;
|
if (isGridScene()) return;
|
||||||
const scene = getCurrentScene();
|
const scene = getCurrentScene();
|
||||||
@@ -551,6 +605,7 @@
|
|||||||
removeBlob,
|
removeBlob,
|
||||||
resetSpawnState,
|
resetSpawnState,
|
||||||
spawnRowAtY,
|
spawnRowAtY,
|
||||||
|
spawnAtPosition,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
23
src/ui.js
23
src/ui.js
@@ -17,6 +17,11 @@
|
|||||||
const pauseOverlay = document.getElementById("pause-overlay");
|
const pauseOverlay = document.getElementById("pause-overlay");
|
||||||
const goalLabelEl = document.getElementById("goal-label");
|
const goalLabelEl = document.getElementById("goal-label");
|
||||||
const goalProgressEl = document.getElementById("goal-progress");
|
const goalProgressEl = document.getElementById("goal-progress");
|
||||||
|
const chainTimerCard = document.getElementById("chain-timer-card");
|
||||||
|
const chainTimerLabelEl = document.getElementById("chain-timer-label");
|
||||||
|
const chainTimerProgressEl = document.getElementById(
|
||||||
|
"chain-timer-progress",
|
||||||
|
);
|
||||||
const winEl = document.getElementById("win-overlay");
|
const winEl = document.getElementById("win-overlay");
|
||||||
const winMessageEl = document.getElementById("win-message");
|
const winMessageEl = document.getElementById("win-message");
|
||||||
const winNextBtn = document.getElementById("win-next");
|
const winNextBtn = document.getElementById("win-next");
|
||||||
@@ -178,6 +183,22 @@
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const setChainTimerVisibility = (visible) => {
|
||||||
|
if (!chainTimerCard) return;
|
||||||
|
chainTimerCard.classList.toggle("hidden", !visible);
|
||||||
|
};
|
||||||
|
|
||||||
|
const setChainTimer = ({ label, progress, urgent } = {}) => {
|
||||||
|
if (chainTimerLabelEl) {
|
||||||
|
chainTimerLabelEl.textContent = label || "—";
|
||||||
|
}
|
||||||
|
if (chainTimerProgressEl) {
|
||||||
|
const clamped = Math.max(0, Math.min(100, progress ?? 0));
|
||||||
|
chainTimerProgressEl.style.width = `${clamped}%`;
|
||||||
|
chainTimerProgressEl.classList.toggle("timer-urgent", !!urgent);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const showWin = (message) => {
|
const showWin = (message) => {
|
||||||
if (winMessageEl) winMessageEl.textContent = message || "You win!";
|
if (winMessageEl) winMessageEl.textContent = message || "You win!";
|
||||||
if (winEl) winEl.classList.add("visible");
|
if (winEl) winEl.classList.add("visible");
|
||||||
@@ -541,6 +562,8 @@
|
|||||||
setSceneSelection,
|
setSceneSelection,
|
||||||
setHandlers,
|
setHandlers,
|
||||||
setGoal,
|
setGoal,
|
||||||
|
setChainTimer,
|
||||||
|
setChainTimerVisibility,
|
||||||
showFloatingMessage,
|
showFloatingMessage,
|
||||||
setMessageDefaults,
|
setMessageDefaults,
|
||||||
clearMessages,
|
clearMessages,
|
||||||
|
|||||||
24
styles.css
24
styles.css
@@ -324,6 +324,30 @@ canvas {
|
|||||||
transform: scale(1.06);
|
transform: scale(1.06);
|
||||||
animation: goalAura 760ms ease-in-out infinite;
|
animation: goalAura 760ms ease-in-out infinite;
|
||||||
}
|
}
|
||||||
|
.timer-card.hidden {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.timer-bar {
|
||||||
|
background: linear-gradient(135deg, #f97316, #ef4444);
|
||||||
|
}
|
||||||
|
.timer-bar.timer-urgent {
|
||||||
|
animation: timerPulse 600ms ease-in-out infinite;
|
||||||
|
box-shadow: 0 0 12px rgba(248, 113, 113, 0.5);
|
||||||
|
}
|
||||||
|
@keyframes timerPulse {
|
||||||
|
0% {
|
||||||
|
transform: scaleY(1);
|
||||||
|
filter: brightness(1);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
transform: scaleY(1.25);
|
||||||
|
filter: brightness(1.2);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: scaleY(1);
|
||||||
|
filter: brightness(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
@keyframes goalPulse {
|
@keyframes goalPulse {
|
||||||
0% {
|
0% {
|
||||||
transform: scaleY(1);
|
transform: scaleY(1);
|
||||||
|
|||||||
Reference in New Issue
Block a user