diff --git a/src/main.js b/src/main.js
index 4050406..b825d7a 100644
--- a/src/main.js
+++ b/src/main.js
@@ -38,6 +38,13 @@
}
};
+ const defaultMessageConfig = {
+ durationMs: 4200,
+ position: { xPercent: 50, yPercent: 10 },
+ text: null,
+ colors: null,
+ };
+
const config = {
gravity: 1,
spawnIntervalMs: 520,
@@ -54,6 +61,7 @@
renderType: "line",
maxLengthMultiplier: 3.1,
},
+ messages: { ...defaultMessageConfig },
};
const ui = window.PhysilinksUI.create();
@@ -167,7 +175,31 @@
setSceneIdInUrl(next.id);
const prevRadius = config.ballRadius;
Object.assign(config, next.config);
- config.link = { ...next.config.link };
+ config.link = { ...(next.config?.link || {}) };
+ const sceneMessages = next.config?.messages || {};
+ config.messages = {
+ durationMs: Number.isFinite(sceneMessages.durationMs)
+ ? sceneMessages.durationMs
+ : defaultMessageConfig.durationMs,
+ text:
+ typeof sceneMessages.text === "string" && sceneMessages.text.trim()
+ ? sceneMessages.text.trim()
+ : defaultMessageConfig.text,
+ position: {
+ xPercent:
+ typeof sceneMessages.position?.xPercent === "number"
+ ? sceneMessages.position.xPercent
+ : defaultMessageConfig.position.xPercent,
+ yPercent:
+ typeof sceneMessages.position?.yPercent === "number"
+ ? sceneMessages.position.yPercent
+ : defaultMessageConfig.position.yPercent,
+ },
+ colors: Array.isArray(sceneMessages.colors)
+ ? sceneMessages.colors
+ : defaultMessageConfig.colors,
+ };
+ ui.setMessageDefaults(config.messages);
engine.gravity.scale =
typeof next.config.gravityScale === "number"
? next.config.gravityScale
@@ -453,6 +485,7 @@
spawnInitialBurst();
startSpawner();
}
+ announceGoalMessage();
};
const setHighlight = (body, on) => {
@@ -987,6 +1020,25 @@
return null;
};
+ const announceGoalMessage = () => {
+ const goal = getGoalState();
+ const text =
+ config.messages?.text ||
+ (goal && goal.label && goal.label !== "—" ? goal.label : null);
+ if (!text) return;
+ const colors =
+ (Array.isArray(config.messages?.colors) && config.messages.colors) ||
+ goal?.colors ||
+ null;
+ ui.showFloatingMessage(
+ { text, colors },
+ {
+ durationMs: config.messages.durationMs,
+ position: config.messages.position,
+ },
+ );
+ };
+
const clampBodiesIntoView = (prevWidth, prevHeight) => {
const scaleX = width / (prevWidth || width);
const scaleY = height / (prevHeight || height);
diff --git a/src/scenes/scene-relax.js b/src/scenes/scene-relax.js
index 5ff7907..9a6cb6b 100644
--- a/src/scenes/scene-relax.js
+++ b/src/scenes/scene-relax.js
@@ -19,6 +19,9 @@
blobBalls: false,
noGameOver: true,
relaxMode: true,
+ messages: {
+ position: { xPercent: 50, yPercent: 16 },
+ },
winCondition: {
type: "timer",
durationSec: 120,
diff --git a/src/ui.js b/src/ui.js
index 5de8824..6a139ef 100644
--- a/src/ui.js
+++ b/src/ui.js
@@ -1,6 +1,7 @@
(() => {
const create = () => {
const sceneEl = document.getElementById("scene-wrapper");
+ const floatingMessagesEl = document.getElementById("floating-messages");
const activeColorEl = document.getElementById("active-color");
const chainLenEl = document.getElementById("chain-length");
const spawnRateEl = document.getElementById("spawn-rate");
@@ -20,6 +21,10 @@
const winMessageEl = document.getElementById("win-message");
const winNextBtn = document.getElementById("win-next");
const winRestartBtn = document.getElementById("win-restart");
+ let messageDefaults = {
+ durationMs: 4200,
+ position: { xPercent: 50, yPercent: 12 },
+ };
const handlers = {
onPauseToggle: null,
@@ -196,6 +201,66 @@
});
};
+ const setMessageDefaults = (overrides = {}) => {
+ messageDefaults = {
+ ...messageDefaults,
+ ...overrides,
+ position: {
+ ...messageDefaults.position,
+ ...(overrides.position || {}),
+ },
+ };
+ };
+
+ const renderFloatingMessage = (el, text, colors) => {
+ el.innerHTML = "";
+ if (Array.isArray(colors) && colors.length > 0) {
+ colors.forEach((color) => {
+ const swatch = document.createElement("span");
+ swatch.style.background = color;
+ swatch.style.display = "inline-block";
+ swatch.style.width = "14px";
+ swatch.style.height = "14px";
+ swatch.style.borderRadius = "50%";
+ swatch.style.border = "1px solid rgba(255,255,255,0.2)";
+ swatch.style.marginRight = "6px";
+ el.appendChild(swatch);
+ });
+ }
+ const textSpan = document.createElement("span");
+ textSpan.textContent = text;
+ el.appendChild(textSpan);
+ };
+
+ const showFloatingMessage = (message, options = {}) => {
+ if (!floatingMessagesEl) return;
+ const msgObj =
+ typeof message === "string" ? { text: message } : message || {};
+ const text = (msgObj.text || "").trim();
+ if (!text) return;
+ const colors = Array.isArray(msgObj.colors) ? msgObj.colors : null;
+ const durationMs = Number.isFinite(options.durationMs)
+ ? options.durationMs
+ : messageDefaults.durationMs;
+ const position = {
+ ...messageDefaults.position,
+ ...(options.position || {}),
+ };
+ const el = document.createElement("div");
+ el.className = "floating-message";
+ renderFloatingMessage(el, text, colors);
+ el.style.left = `${position.xPercent ?? 50}%`;
+ el.style.top = `${position.yPercent ?? 10}%`;
+ floatingMessagesEl.appendChild(el);
+ requestAnimationFrame(() => {
+ el.classList.add("visible");
+ });
+ setTimeout(() => {
+ el.classList.remove("visible");
+ setTimeout(() => el.remove(), 260);
+ }, durationMs);
+ };
+
return {
sceneEl,
updateHud,
@@ -210,6 +275,8 @@
setSceneSelection,
setHandlers,
setGoal,
+ showFloatingMessage,
+ setMessageDefaults,
};
};
diff --git a/styles.css b/styles.css
index 58e9191..527e6c8 100644
--- a/styles.css
+++ b/styles.css
@@ -195,6 +195,39 @@ canvas {
border: 1px solid rgba(255, 255, 255, 0.12);
display: inline-block;
}
+.floating-messages {
+ position: absolute;
+ inset: 0;
+ pointer-events: none;
+}
+.floating-message {
+ position: absolute;
+ display: inline-flex;
+ align-items: center;
+ gap: 8px;
+ transform: translate(-50%, 0) scale(0.98);
+ background: rgba(15, 23, 42, 0.76);
+ color: #f8fafc;
+ padding: 12px 16px;
+ border-radius: 14px;
+ border: 1px solid rgba(226, 232, 240, 0.16);
+ font-weight: 800;
+ letter-spacing: 0.4px;
+ text-shadow:
+ 0 10px 30px rgba(0, 0, 0, 0.45),
+ 0 2px 6px rgba(0, 0, 0, 0.35);
+ box-shadow: 0 18px 45px rgba(0, 0, 0, 0.35);
+ backdrop-filter: blur(6px);
+ opacity: 0;
+ transition:
+ opacity 220ms ease,
+ transform 220ms ease;
+ filter: drop-shadow(0 12px 30px rgba(0, 0, 0, 0.2));
+}
+.floating-message.visible {
+ opacity: 1;
+ transform: translate(-50%, 0) scale(1);
+}
.pause-overlay {
position: absolute;
top: 12px;