地图手感打磨:动作粒子 + 昼夜色温 + 成熟呼吸金光
为什么:地图功能已全通但缺"游戏手感"——点击有反馈(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:
1 parent
15d290a60a
commit
4bc7729715
2 files changed
+105
-5
No files matched your search
@@ -42,3 +42,21 @@
|
|||||||
- 真机验收:登录、canvas 点击浇水(AP 4→3、水分 2→3、toast)、
|
- 真机验收:登录、canvas 点击浇水(AP 4→3、水分 2→3、toast)、
|
||||||
悬停 tooltip、截图目检全过;errors/console 干净。534 项测试全绿。
|
悬停 tooltip、截图目检全过;errors/console 干净。534 项测试全绿。
|
||||||
|
|
||||||
|
## 2026-09-25 移除行动点系统
|
||||||
|
|
||||||
|
- 用户拍板:「谁家浇水还要体力啊 体力删掉 都已经有时间了」——时间
|
||||||
|
(40 分钟一天、只能自然流动)本身就是唯一节奏阀门,AP 是重复惩罚。
|
||||||
|
- 连根拔的触点:config(START_AP/AP_COST)、models(ap/max_ap 字段,旧档
|
||||||
|
payload 的 ap 键 from_dict 自然忽略)、engine(_spend_ap + 8 调用点 +
|
||||||
|
每日恢复)、bot(AP 分支)、client/TUI/main/README/规则文本。
|
||||||
|
- **SQLite 老库迁移**:ap 是标量 NOT NULL 列,connect() 里
|
||||||
|
`PRAGMA table_info` 发现即 `ALTER TABLE farms DROP COLUMN ap`
|
||||||
|
(Python 3.13 的 SQLite ≥3.35 支持)。实测 saves/farm.db 自动迁完。
|
||||||
|
- 网页端:删"行动点"统计卡;"下一天"倒计时改**客户端本地走秒**
|
||||||
|
(render 记 tickLeft/tickStamp 基点,1s 定时器补 SSE 推送间隙);
|
||||||
|
字体栈补 MiSans/HarmonyOS Sans/PingFang、正文 15px、数字
|
||||||
|
tabular-nums、canvas 小字加粗。
|
||||||
|
- 测试 -2(AP 专属用例没了)改 5,532 项全绿;提交 15d290a 已推送。
|
||||||
|
真机:老库迁移 ✅、无行动点卡 ✅、倒计时 39:04→39:02 走秒 ✅。
|
||||||
|
|
||||||
|
|
||||||
+87
-5
@@ -288,6 +288,7 @@ async function act(action, params) {
|
|||||||
});
|
});
|
||||||
if (data) {
|
if (data) {
|
||||||
toast(data.message);
|
toast(data.message);
|
||||||
|
mapJuice(action, params);
|
||||||
if (data.state) refreshFromBrief(data.state);
|
if (data.state) refreshFromBrief(data.state);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -493,8 +494,57 @@ async function renderBoard() {
|
|||||||
const MAP = {
|
const MAP = {
|
||||||
cv: null, ctx: null, scale: 1, w: 0, h: 0,
|
cv: null, ctx: null, scale: 1, w: 0, h: 0,
|
||||||
field: null, pen: null, builds: [], stripH: 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 cropEmoji = { wheat: "🌾", carrot: "🥕", tomato: "🍅", corn: "🌽", pumpkin: "🎃", strawberry: "🍓" };
|
||||||
const animalEmoji = { chicken: "🐔", sheep: "🐑", cow: "🐄" };
|
const animalEmoji = { chicken: "🐔", sheep: "🐑", cow: "🐄" };
|
||||||
const buildingInfo = {
|
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; });
|
(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 未布局时直接跳过) */
|
/* 牲畜漫步 + 边界回弹(pen 未布局时直接跳过) */
|
||||||
const pen = MAP.pen;
|
const pen = MAP.pen;
|
||||||
if (!pen) return;
|
if (!pen) return;
|
||||||
@@ -607,7 +665,7 @@ function mapRoundRect(x, y, w, h, r) {
|
|||||||
c.closePath();
|
c.closePath();
|
||||||
}
|
}
|
||||||
|
|
||||||
function mapDrawTile(p, x, y, ts) {
|
function mapDrawTile(p, x, y, ts, now) {
|
||||||
const c = MAP.ctx;
|
const c = MAP.ctx;
|
||||||
const wet = p.moisture / (p.water_capacity || 3);
|
const wet = p.moisture / (p.water_capacity || 3);
|
||||||
const r = Math.round(mapLerp(206, 118, wet));
|
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 ratio = p.grow_days ? p.growth / p.grow_days : 1;
|
||||||
const size = Math.round(16 + ratio * 20);
|
const size = Math.round(16 + ratio * 20);
|
||||||
c.save();
|
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.font = size + 'px "Segoe UI Emoji","Noto Color Emoji",sans-serif';
|
||||||
c.textAlign = "center"; c.textBaseline = "middle";
|
c.textAlign = "center"; c.textBaseline = "middle";
|
||||||
c.fillText(cropEmoji[p.crop] || "🌱", x + ts / 2, y + ts / 2 - 3);
|
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;
|
const f = MAP.field, ts = f.ts;
|
||||||
(state.plots || []).forEach((p, i) => {
|
(state.plots || []).forEach((p, i) => {
|
||||||
const col = i % f.cols, row = Math.floor(i / f.cols);
|
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;
|
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);
|
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;
|
const h = MAP.hover;
|
||||||
if (h) {
|
if (h) {
|
||||||
@@ -821,8 +902,9 @@ function mapBind() {
|
|||||||
const pt = mapPoint(ev);
|
const pt = mapPoint(ev);
|
||||||
const hit = mapHit(pt.x, pt.y);
|
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.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) => {
|
MAP.cv.addEventListener("click", (ev) => {
|
||||||
const pt = mapPoint(ev);
|
const pt = mapPoint(ev);
|
||||||
const hit = mapHit(pt.x, pt.y);
|
const hit = mapHit(pt.x, pt.y);
|
||||||
|
|||||||
Reference in new issue
Block a user