地图手感打磨:动作粒子 + 昼夜色温 + 成熟呼吸金光

为什么:地图功能已全通但缺"游戏手感"——点击有反馈(toast)却看不到
画面上的动静,时间流动也只在倒计时数字里。这一轮全部补上。

- 动作粒子特效(webui):act() 成功后在目标位置喷粒子 + 上浮表情小字
  (浇水💧水花、播种🌱扬土、收获/收取✨金点、喂食🍽)。粒子受重力、
  按寿命衰减,失败动作不触发(api() 返回 null 时 mapJuice 不执行)。
- 昼夜色温:一天的进度(1 - seconds_to_tick/tick_seconds)映射成
  清晨淡金 → 正午不叠色 → 黄昏橙 → 入夜靛紫的半透明覆盖层,
  让"40 分钟一天"在画面光色上也流动。tick_seconds 完整快照里有、
  brief 里没有,靠 refreshFromBrief 的合并语义保留。
- 成熟作物金光改呼吸式(shadowBlur 随 sin 起伏),悬停可交互对象时
  指针变手型。

验证:532 项测试全绿、ruff/pyright 干净;真机 headless Chrome 实测
粒子注入 23 枚 2 秒内衰减归零、失败动作不产生特效、dayTint 三时段
数值正确、截图目检清晨色调正常、控制台零报错。
This commit is contained in:
snowy-yang committed 2026-09-25 23:38:40 +08:00
1 parent 15d290a60a
commit 4bc7729715
2 files changed
+105 -5

No files matched your search

+87 -5
View File
@@ -288,6 +288,7 @@ async function act(action, params) {
});
if (data) {
toast(data.message);
mapJuice(action, params);
if (data.state) refreshFromBrief(data.state);
}
}
@@ -493,8 +494,57 @@ async function renderBoard() {
const MAP = {
cv: null, ctx: null, scale: 1, w: 0, h: 0,
field: null, pen: null, builds: [], stripH: 0,
walkers: [], animalIds: "", drops: null, clouds: null, hover: null
walkers: [], animalIds: "", drops: null, clouds: null, hover: null, fx: []
};
/* 动作粒子:浇水溅水花、收获爆金点、播种扬土,配一枚上浮的表情小字。
act() 成功后调 mapJuice(action, params),坐标由地块号/牲畜 id 反查。 */
function plotXY(position) {
const f = MAP.field;
const i = (state.plots || []).findIndex((p) => p.position === position);
if (!f || i < 0) return null;
return { x: f.x + (i % f.cols) * (f.ts + f.gap) + f.ts / 2,
y: f.y + Math.floor(i / f.cols) * (f.ts + f.gap) + f.ts / 2 };
}
function animalXY(id) {
const wd = (MAP.walkers || []).find((w) => w.a.id === id);
return wd ? { x: wd.x, y: wd.y } : null;
}
function mapJuice(action, params) {
if (!MAP.field || !params) return;
let at = null;
if (params.plot !== undefined) at = plotXY(params.plot);
else if (params.animal_id !== undefined) at = animalXY(params.animal_id);
if (!at) return;
const conf = {
water: { color: "#7cc4f5", n: 14, text: "💧" },
plant: { color: "#a97b4f", n: 12, text: "🌱" },
harvest: { color: "#ffd54d", n: 22, text: "✨" },
feed: { color: "#e8b04a", n: 12, text: "🍽" },
collect: { color: "#ffd54d", n: 18, text: "✨" }
}[action];
if (!conf) return;
for (let i = 0; i < conf.n; i++) {
const a = Math.random() * 6.28, v = 34 + Math.random() * 74;
MAP.fx.push({ x: at.x, y: at.y, vx: Math.cos(a) * v, vy: Math.sin(a) * v - 52,
t: 0, life: 0.55 + Math.random() * 0.45, color: conf.color,
r: 1.6 + Math.random() * 2.4 });
}
MAP.fx.push({ x: at.x, y: at.y - 8, floater: true, t: 0, life: 1.1, text: conf.text });
}
/* 一天的进度 → 画面光色:清晨淡金 → 正午不叠色 → 黄昏橙 → 入夜靛紫。
时间不该只在倒计时数字上流动,田里的光也要跟着走。 */
function dayTint(prog) {
if (prog < 0.12) {
const k = prog / 0.12;
return [255, 190, 110, 0.14 * (1 - k)];
}
if (prog < 0.70) return null;
const k = (prog - 0.70) / 0.30;
return [Math.round(mapLerp(255, 104, k)), Math.round(mapLerp(150, 80, k)),
Math.round(mapLerp(72, 190, k)), 0.05 + 0.15 * k];
}
const cropEmoji = { wheat: "🌾", carrot: "🥕", tomato: "🍅", corn: "🌽", pumpkin: "🎃", strawberry: "🍓" };
const animalEmoji = { chicken: "🐔", sheep: "🐑", cow: "🐄" };
const buildingInfo = {
@@ -584,6 +634,14 @@ function mapStep(dt, now) {
}
}
(MAP.clouds || []).forEach((c) => { c.x += c.v * dt; if (c.x > MAP.w + 70) c.x = -70; });
/* 特效粒子:受重力下坠、寿命到了消失;浮字匀速上飘 */
for (let i = MAP.fx.length - 1; i >= 0; i--) {
const f = MAP.fx[i];
f.t += dt;
if (f.t >= f.life) { MAP.fx.splice(i, 1); continue; }
if (f.floater) { f.y -= 26 * dt; }
else { f.vy += 190 * dt; f.x += f.vx * dt; f.y += f.vy * dt; }
}
/* 牲畜漫步 + 边界回弹(pen 未布局时直接跳过) */
const pen = MAP.pen;
if (!pen) return;
@@ -607,7 +665,7 @@ function mapRoundRect(x, y, w, h, r) {
c.closePath();
}
function mapDrawTile(p, x, y, ts) {
function mapDrawTile(p, x, y, ts, now) {
const c = MAP.ctx;
const wet = p.moisture / (p.water_capacity || 3);
const r = Math.round(mapLerp(206, 118, wet));
@@ -628,7 +686,10 @@ function mapDrawTile(p, x, y, ts) {
const ratio = p.grow_days ? p.growth / p.grow_days : 1;
const size = Math.round(16 + ratio * 20);
c.save();
if (p.is_ripe) { c.shadowColor = "rgba(255,200,40,.9)"; c.shadowBlur = 12; }
if (p.is_ripe) {
c.shadowColor = "rgba(255,200,40,.9)";
c.shadowBlur = 10 + Math.sin(now / 320 + p.position * 1.7) * 5;
}
c.font = size + 'px "Segoe UI Emoji","Noto Color Emoji",sans-serif';
c.textAlign = "center"; c.textBaseline = "middle";
c.fillText(cropEmoji[p.crop] || "🌱", x + ts / 2, y + ts / 2 - 3);
@@ -698,7 +759,7 @@ function mapDraw(now) {
const f = MAP.field, ts = f.ts;
(state.plots || []).forEach((p, i) => {
const col = i % f.cols, row = Math.floor(i / f.cols);
mapDrawTile(p, f.x + col * (ts + f.gap), f.y + row * (ts + f.gap), ts);
mapDrawTile(p, f.x + col * (ts + f.gap), f.y + row * (ts + f.gap), ts, now);
});
/* 牧场围栏 */
const pen = MAP.pen;
@@ -756,6 +817,26 @@ function mapDraw(now) {
c.fillText(kv[1] > 1 ? info[1] + " ×" + kv[1] : info[1], cx, y0 + 32);
});
}
/* 昼夜光色 + 动作特效:盖在场地上、悬停提示之下 */
const dayTotal = state.tick_seconds || 2400;
const tint = dayTint(1 - Math.min(1, (state.seconds_to_tick || 0) / dayTotal));
if (tint && tint[3] > 0.004) {
c.fillStyle = "rgba(" + tint[0] + "," + tint[1] + "," + tint[2] + "," + tint[3].toFixed(3) + ")";
c.fillRect(0, 0, MAP.w, MAP.h);
}
(MAP.fx || []).forEach((f) => {
const k = 1 - f.t / f.life;
c.globalAlpha = f.floater ? Math.min(1, k * 1.8) : k;
if (f.floater) {
c.font = '700 16px "Segoe UI Emoji","Noto Color Emoji",sans-serif';
c.textAlign = "center"; c.textBaseline = "middle";
c.fillText(f.text, f.x, f.y);
} else {
c.fillStyle = f.color;
c.beginPath(); c.arc(f.x, f.y, f.r, 0, 6.29); c.fill();
}
c.globalAlpha = 1;
});
/* 悬停提示框 */
const h = MAP.hover;
if (h) {
@@ -821,8 +902,9 @@ function mapBind() {
const pt = mapPoint(ev);
const hit = mapHit(pt.x, pt.y);
MAP.hover = hit ? { type: hit.type, p: hit.p, a: hit.a, x: pt.x, y: pt.y } : null;
MAP.cv.style.cursor = hit ? "pointer" : "default";
});
MAP.cv.addEventListener("pointerleave", () => { MAP.hover = null; });
MAP.cv.addEventListener("pointerleave", () => { MAP.hover = null; MAP.cv.style.cursor = "default"; });
MAP.cv.addEventListener("click", (ev) => {
const pt = mapPoint(ev);
const hit = mapHit(pt.x, pt.y);