feat(desktop): 办公室换 AI 房间背景,手绘 SVG 场景全部退役

第三版房间才可用。前两版地板都只占画面 13%,而场景需要 ~60% 的地面
才摆得下工位——但那不怪模型,是我提示词自相矛盾:同时要求"正面平视"和
"下方 60% 是地板",几何上不可能同时成立(平视时地板向地平线收缩)。
改成"俯视 30 度、娃娃屋视角"后一次就对(实测地板 53% / 墙 47%)。
参考项目的 office.png 本来就是俯视娃娃屋 + 正面角色,它早验证过这个组合。

不拉伸图去迁就场景,而是改场景去迁就图:VB 720×510 → 940×512,正好是
房间图的比例(1.835)。拉伸 30% 的话墙上挂钟会变成椭圆,一眼假。
所有坐标按图里量出的百分比换算:FLOOR_Y=242(地平线 47.3%)、
DOOR_X=279(门中心 29.7%,角色从画上那扇真门里走出来)。

量着修的两个碰撞:
- 白板压住门和窗。实测墙上被门 214~343、钟 443~515、窗 612~791、
  绿植 863~932 占着,最大空白段仅 214 宽,放不下 340 的白板。选择盖掉
  挂钟(静态装饰、零信息量),白板落在门与窗之间的 347~613。
- 协调者的桌子撞上正下方工位的气泡。可行区间只有 (242, 276):
  低于 242 会飘到墙上,高于 276 撞气泡。取 268。

字号 ×1.3 补偿:viewBox 从 720 加宽到 940 后,同样单位在真实面板
(~520px)里会缩小 28%,不补的话白板正文只有 7px。验收也是在 520px
下做的,不是在预览页那个宽容的尺寸下。

顺带清掉 4 个死常量(WALL/FLOOR/DESK_TOP/DESK_FRONT)——房间换贴图后没人用了。

资源合计 148 KB(7 立绘 + 1 房间,全 WebP)。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Blizzard
2026-07-16 11:17:41 +08:00
parent 8b19c572f4
commit bfbca08d72
2 changed files with 23 additions and 54 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

@@ -1,6 +1,7 @@
import { useMemo, type CSSProperties } from "react";
import { deriveTeam, teamNow, type RunPhase, type TeamSeat } from "../lib/run";
import type { ExecEvent } from "../lib/api";
import roomSprite from "../assets/office/room.webp";
import leadSprite from "../assets/office/lead.webp";
import deskSprite from "../assets/office/desk.webp";
import a0 from "../assets/office/a0.webp";
@@ -22,17 +23,15 @@ const ACTORS = [a0, a1, a2, a3, a4];
// 复盘时按事件时间戳算出各自的出场/交付时刻,压进 PLAY_MS 内当一出戏放。
// 动效=状态编码,不是装饰;并守 prefers-reduced-motion。
const VB_W = 720;
const VB_H = 510;
const FLOOR_Y = 200;
// 场景尺寸 = 房间贴图的比例(2760×1504 → 1.835),让背景零拉伸:
// 拉伸 30% 的话墙上那个挂钟会变成椭圆,一眼假。宁可改场景去迁就图。
const VB_W = 940;
const VB_H = 512;
const FLOOR_Y = 242; // 贴图里量的地平线在 47.3%
const OUTLINE = "#2f2a26";
const SKIN = "#f7d9bd";
const PANTS = "#4a5568";
const WALL = "#f3ede4";
const FLOOR = "#ddd0be";
const DESK_TOP = "#ffffff";
const DESK_FRONT = "#f1ebe2";
const RUN = "#7c5cf6";
const OK = "#34d399";
@@ -43,16 +42,16 @@ const SHIRTS = ["#7c5cf6", "#38bdf8", "#34d399", "#fbbf24", "#fb7185", "#a78bfa"
const HAIRS = ["#3f3229", "#6b4a2f", "#c8913f", "#2e3f52", "#8c4a4a", "#4a3a6b", "#5c5148", "#7a4a2a"];
// 门口:出场点。角色从这里走向自己的工位。
const DOOR_X = 84;
const DOOR_Y = 198;
const DOOR_X = 279; // 贴图里那扇门的中心(29.7%)——角色从画上的真门里走出来
const DOOR_Y = 250;
const DOOR_SCALE = 0.5;
// 协调者机位;递简报时专家走到它桌子两侧。
const LEAD_X = 360;
const LEAD_Y = 232;
const LEAD_X = 470;
const LEAD_Y = 268; // 下限 242(地平线,再高就飘到墙上)、上限 276(再低撞到工位气泡)
const LEAD_SCALE = 0.62;
const HANDOFF_DX = 86;
const HANDOFF_Y = 250;
const HANDOFF_Y = 288;
const HANDOFF_SCALE = 0.66;
const ENTER_MS = 950;
@@ -271,10 +270,10 @@ function Deskette({ name, sub, tone, glow, w = 200 }: { name: string; sub: strin
掉到挡板外会骑在桌子下边线上、被切成半截。 */}
<rect x={-hw + 18} y={-24} width={w - 36} height={23} rx={4} fill="#fff" stroke={OUTLINE} strokeWidth={2.2} />
<circle cx={-hw + 31} cy={-12.5} r={4.2} fill={tone} />
<text x={-hw + 42} y={-7} fontSize={16} fontWeight={600} fill="#2f2a26" style={{ fontFamily: "system-ui, sans-serif" }}>
{fitText(name, (w - 62) / 16)}
<text x={-hw + 42} y={-7} fontSize={20} fontWeight={600} fill="#2f2a26" style={{ fontFamily: "system-ui, sans-serif" }}>
{fitText(name, (w - 62) / 20)}
</text>
<text x={0} y={16} fontSize={13} textAnchor="middle" fill="#6b6156" style={{ fontFamily: "system-ui, sans-serif" }}>
<text x={0} y={16} fontSize={16} textAnchor="middle" fill="#6b6156" style={{ fontFamily: "system-ui, sans-serif" }}>
{sub}
</text>
</g>
@@ -376,40 +375,10 @@ function Actor({ cue, reduced, hasLead }: { cue: Cue; reduced: boolean; hasLead:
);
}
// 场景道具
// 场景道具:整间房是一张贴图(AI 生成),门/窗/挂钟/绿植都在图里。
// 原来这里是手绘 SVG 的墙地门窗,被立绘一衬托就露怯,整段换掉。
function Room() {
return (
<g>
<rect x={0} y={0} width={VB_W} height={FLOOR_Y} fill={WALL} />
<rect x={0} y={FLOOR_Y} width={VB_W} height={VB_H - FLOOR_Y} fill={FLOOR} />
<rect x={0} y={FLOOR_Y - 5} width={VB_W} height={5} fill="#cabfae" />
{/* 地毯 */}
<ellipse cx={360} cy={402} rx={318} ry={48} fill="#e9dfd0" stroke="#cfc2ae" strokeWidth={2} />
{/* 门:专家从这里进场 */}
<g>
<rect x={44} y={86} width={80} height={114} rx={4} fill="#e4d9c6" stroke={OUTLINE} strokeWidth={2.8} />
<rect x={52} y={94} width={64} height={98} rx={2} fill="#efe6d6" stroke={OUTLINE} strokeWidth={2} />
<circle cx={110} cy={146} r={3.6} fill={OUTLINE} />
</g>
{/* 挂钟 */}
<g transform="translate(534, 58)">
<circle r={22} fill="#fff" stroke={OUTLINE} strokeWidth={2.6} />
<path d="M 0 0 L 0 -13 M 0 0 L 9 5" stroke={OUTLINE} strokeWidth={2.4} strokeLinecap="round" />
</g>
{/* 窗 */}
<g>
<rect x={578} y={34} width={116} height={92} rx={5} fill="#cfe9f5" stroke={OUTLINE} strokeWidth={2.6} />
<path d="M 636 34 L 636 126 M 578 80 L 694 80" stroke={OUTLINE} strokeWidth={2.2} />
<path d="M 588 120 L 626 42 L 642 42 L 604 120 Z" fill="#fff" opacity={0.35} />
</g>
{/* 绿植 */}
<g transform="translate(636, 186)">
<path d="M 0 -6 C -22 -14 -26 -44 -4 -52 C -12 -30 -4 -18 0 -6 Z" fill="#4a9c6d" stroke={OUTLINE} strokeWidth={2.2} strokeLinejoin="round" />
<path d="M 0 -6 C 22 -18 24 -50 2 -58 C 12 -32 4 -18 0 -6 Z" fill="#5cb07d" stroke={OUTLINE} strokeWidth={2.2} strokeLinejoin="round" />
<path d="M -13 -6 L 13 -6 L 9 16 L -9 16 Z" fill="#e6e0d6" stroke={OUTLINE} strokeWidth={2.4} strokeLinejoin="round" />
</g>
</g>
);
return <image href={roomSprite} x={0} y={0} width={VB_W} height={VB_H} preserveAspectRatio="xMidYMid slice" />;
}
export function OfficeView({ events, output, phase }: { events: ExecEvent[]; output?: string; phase?: RunPhase }) {
@@ -426,7 +395,7 @@ export function OfficeView({ events, output, phase }: { events: ExecEvent[]; out
// 排位:≤5 人一排(前排),否则拆两排,后排小、前排大 —— 用缩放造纵深。
// 两排时后排不出头顶气泡:气泡很高,会顶到协调者的桌子;状态仍由显示器辉光和铭牌圆点表达。
const rows: TeamSeat[][] = seats.length <= 5 ? [seats] : [seats.slice(0, Math.ceil(seats.length / 2)), seats.slice(Math.ceil(seats.length / 2))];
const rowCfg = rows.length === 1 ? [{ y: 452, s: 1 }] : [{ y: 330, s: 0.58 }, { y: 460, s: 0.8 }];
const rowCfg = rows.length === 1 ? [{ y: 470, s: 1 }] : [{ y: 358, s: 0.62 }, { y: 478, s: 0.85 }];
// 档期:直播时事件到哪演到哪(延迟一律 0);复盘时按时间戳排,整段压进 PLAY_MS。
const rate = streaming ? 0 : Math.min(1, PLAY_MS / span);
@@ -524,11 +493,11 @@ export function OfficeView({ events, output, phase }: { events: ExecEvent[]; out
{/* 白板:协调者的思考/综合过程 */}
<g>
<rect x={176} y={18} width={320} height={94} rx={6} fill="#fff" stroke={OUTLINE} strokeWidth={2.8} />
<rect x={176} y={104} width={320} height={8} rx={3} fill="#e2dbcf" stroke={OUTLINE} strokeWidth={2.2} />
<foreignObject x={188} y={26} width={296} height={74}>
<rect x={347} y={22} width={266} height={104} rx={6} fill="#fff" stroke={OUTLINE} strokeWidth={2.8} />
<rect x={347} y={118} width={266} height={8} rx={3} fill="#e2dbcf" stroke={OUTLINE} strokeWidth={2.2} />
<foreignObject x={357} y={30} width={246} height={86}>
<div style={{ fontFamily: "system-ui, sans-serif", color: "#4a4238", lineHeight: 1.35 }}>
<div style={{ fontSize: 12, color: leadTone, fontWeight: 600 }}>
<div style={{ fontSize: 15, color: leadTone, fontWeight: 600 }}>
{lead
? streaming
? "综合中…"
@@ -543,7 +512,7 @@ export function OfficeView({ events, output, phase }: { events: ExecEvent[]; out
</div>
<div
style={{
fontSize: 13,
fontSize: 16,
marginTop: 3,
wordBreak: "break-word",
display: "-webkit-box",