diff --git a/design/art/art-bible.md b/design/art/art-bible.md index 53c21eb9..1ca77c1f 100644 --- a/design/art/art-bible.md +++ b/design/art/art-bible.md @@ -392,12 +392,12 @@ - **情绪与色域**:承载 §2.8——全作最暖:烛光黄 `#F6C667` × 木色 `#8A5A34`(§7.2),中等饱和,无棋盘式高对比。 - **场景构成**:一张可点击的营地场景图(1280×800,与工程档同幅面)。远景=天空与远山;中景=**独立成层的木围墙**(密排竖木桩、桩顶削尖、不透缝无横木)与**半开的营地大门**;近景=中央篝火、左侧锻炉铁砧、右侧帐篷与宝箱、帐篷旁的笔记本,另有木桩与挂在围墙上的布告板。**中景带自底部往上约 2/3 处起**;**近景物件可覆盖围墙,唯一例外=中间门洞必须留空、不被任何物件遮挡**。七个可点物件的功能绑定归 `camp.md`,本节只定画法。 - **图层拆分(10 张)**:场景按「远景/中景/近景要素各自成层」拆——`scene_camp_bg` 背景层(z0,不透明全幅)=天空+远山+整片地面+门洞内一条通向远处的小路;`scene_camp_wall` 围墙层(z1,透明底)=横贯全宽密排木桩墙(左右两段)+两根略高的门柱,**门洞处留透明缺口**透出背景层的小路;`scene_camp_board` 布告板(z2)、`scene_camp_gate` 门扇(z3)、`scene_camp_dummy` 木桩/`scene_camp_forge` 锻炉/`scene_camp_tent` 帐篷(z4)、`scene_camp_chest` 宝箱/`scene_camp_notebook` 笔记本(z5)、`scene_camp_campfire` 篝火(z6,最前)=八个物件层,各透明底单件一张。**帐篷**为承载件、不可点。围墙独立成层的好处:桩高可单独锁死、门洞做透明缺口、背景层不混入暖木色。提示词与坐标 = `camp-scene-prompts.md`,消费常量 = `asset-path-map`。 -- **交付形态**:**背景层 1 张(不透明全幅)+ 围墙层 1 张 + 八个物件层 = 10 张**,其中 9 张透明底、1 张不透明全幅 + 一份坐标表(贴图矩形/命中矩形/z 序)。背景层由**远景片 `bg_far`(2560×734)与地面片 `bg_floor`(2560×866)两片出图、按 y734 拼成一张**——整张一次出图时模型把远山挤到画面下半部,被围墙挡住后完全消失(实测三张里两张山体上缘落在墙顶以下)。接缝整条落在围墙背后,画面无接缝。**篝火例外**:命中矩形只取下半(火堆),上半(火焰)只作特效展示。围墙层落盘按细条裁,不做全幅(全幅约 3.9 MB 显存,细条约 0.35 MB);**实装落盘矩形 `(0, 203, 1280, 164)`、底锚 y367**(桩身 150px、门柱 164px 终稿锁定值,落带内〔围墙带 (0,203,1280,164)〕)。 +- **交付形态**:**背景层 1 张(不透明全幅)+ 围墙层 1 张 + 八个物件层 = 10 张**,其中 9 张透明底、1 张不透明全幅 + 一份坐标表(贴图矩形/命中矩形/z 序)。**篝火例外**:命中矩形只取下半(火堆),上半(火焰)只作特效展示。围墙层落盘按细条裁,不做全幅(全幅约 3.9 MB 显存,细条约 0.35 MB);**实装落盘矩形 `(0, 261, 1280, 49)`、底锚 y310**(Y 压缩按桩身 43px 终稿锁定值精确,落带内〔规划带 (0,240,1280,70)〕;备选 A′〔带高 70/桩身 62〕存档 artwork 同目录)。 - **色彩与氛围依据**:营地场景的冷暖分工(冷的大面积后退、暖的小面积前进)、空气透视三档与大面积用色规则 = `reports/提案-营地色彩与氛围脚本-2026-10-23.md`。本节不复述色值;新增环境色按 §4.1 场景环境子板条款登记(每屏一套、≤32 色、过 §4.2 语义检查与 RGB 三通道差 ≥8/255 两道门)。 - **交互四态(零新素材,全部程序化)**:悬停=**轮廓亮边 + 浮动**(浮动须整像素吸附);按下=位移 1px;**禁用/未解锁=降明度 + 去饱和**(§2 已缩窄至情绪域,状态表达不受限),**仅锻炉(锻造工坊)与宝箱(秘宝匣)两件会有此态**;已解锁=常态。 - **钥石余额**:常驻画面顶部的一行小 HUD 栏(高 48px,**浮层覆盖,不挤压画面**)。 - **事实源边界**:解锁条件/价格/存档写入归元进度 GDD 与 `MetaProgression`;素材命名与路径归 `asset-path-map`;清单登记归 `art-assets-checklist` §3.5。 -- **已裁(2026-11-01 用户裁)**:① **营地环境子板色值**——不前置拍板:量化场景实际用色后按 §4.1 场景环境子板条款登记(每屏一套、≤32 色、过 §4.2 语义检查与 RGB 三通道差 ≥8/255 两道门);② **门扇与门洞的高宽比例**——门扇贴图 240×138(锁定 240×147),随门洞 y283–367 压扁重画,坐标按门洞 x520–760 对齐;③ **帐篷的落地尺寸**——251×170 落位 (982, 335),上端遮住围墙下部。 +- **已裁(2026-11-01 用户裁)**:① **营地环境子板色值**——不前置拍板:量化场景实际用色后按 §4.1 场景环境子板条款登记(每屏一套、≤32 色、过 §4.2 语义检查与 RGB 三通道差 ≥8/255 两道门);② **门扇与门洞的高宽比例**——门扇 240×220 追认现值,坐标按门洞 x520–760 对齐(`camp-scene-prompts.md` §2.3);③ **帐篷的落地尺寸**——300×170 追认现值(各素材第一张样本稿已过审为终稿,重定即触发重出,不裁)。 ### 6.3 三关图类型色板候选(board_type 换挡方向) diff --git a/design/art/camp-scene-prompts.md b/design/art/camp-scene-prompts.md index 38e35f2e..a269ff63 100644 --- a/design/art/camp-scene-prompts.md +++ b/design/art/camp-scene-prompts.md @@ -1,9 +1,9 @@ # 营地场景 · 分层生图提示词卡 v1.5 > **性质**:营地场景图(1280×800,一张可点击场景图 + 十张分层:背景层 1 + 围墙层 1 + 八个物件层)的出图执行侧文件。**场景级资产,与角色级 [art-prompts](art-prompts.md) 并列,两文件互不覆盖**。 -> **依据**:色彩与氛围 = `reports/提案-营地色彩与氛围脚本-2026-10-23.md`(本卡不复述色值);坐标与层序 = 本卡 §2.3(运行期单一事实源 = `game/data/tables/camp_scene_layout.tres`);物件命名与消费链 = [asset-path-map](asset-path-map.md);画法与交付形态 = [art-bible](art-bible.md) §6.2。冲突时色彩以色彩脚本为准,画法以 art-bible 为准。 +> **依据**:色彩与氛围 = `reports/提案-营地色彩与氛围脚本-2026-10-23.md`(本卡不复述色值);坐标与层序 = `artwork/2026-10-23-营地场景/布局草案-1280x800.html`;物件命名与消费链 = [asset-path-map](asset-path-map.md);画法与交付形态 = [art-bible](art-bible.md) §6.2。冲突时色彩以色彩脚本为准,画法以 art-bible 为准。 > **边界**:只管营地场景图的分层出图。角色、图标、格面走 art-prompts.md 与脚本管线,不需要提示词。 -> **建立**:2026-10-25 · 状态:十条提示词成文(背景层 1 + 围墙层 1 + 八个物件层);2026-11-05 按地平线 y367 重排,背景层与围墙层待按 §4 新判据重出,门扇随门洞压扁重画 +> **建立**:2026-10-25 · 状态:十条提示词成文(背景层 1 + 围墙层 1 + 八个物件层),出图通道由作者自定 --- @@ -14,7 +14,7 @@ 3. **后处理**:脚本管线代跑——去底转透明、降采样到终稿尺寸(整数倍)、色板量化、四联底测试条。**背景层不去底(全幅不透明)**;围墙层与八个物件层去底转透明。后处理工具清单与用法见 [art-pipeline-tools](art-pipeline-tools.md)。 4. **转正**:终稿转入切片资产目录,按坐标表接线。 -**出图顺序(先锁后产,不能跳)**:概念脚本 → **本卡提示词** → 贴风格参考图**先出远景片 `bg_far` 1 张** → 作者确认风格前缀 → 才出地面片 `bg_floor`、围墙层与八个物件层。 +**出图顺序(先锁后产,不能跳)**:概念脚本 → **本卡提示词** → 贴风格参考图**先出背景层 `scene_camp_bg` 1 张** → 作者确认风格前缀 → 才扩产围墙层 `scene_camp_wall` 与八个物件层(共 9 张)。 --- @@ -43,57 +43,22 @@ Style reference: the attached images are this game's own finished pixel art and ### 2.1 背景层(`scene_camp_bg`,不可点,不透明全幅) -**产出方式**:分两片出图,后处理按 y734 拼成一张 2560×1600。接缝整条落在围墙层(406–734)背后,画面里看不见。 - -| 片 | 出图画布 | 终稿落位 | 内容 | -|---|---|---|---| -| 远景片 `bg_far` | 2560×734(3.5:1 超宽) | (0, 0, 1280, 367) | 天空+远山+草地+小路 | -| 地面片 `bg_floor` | 2560×866(3:1 超宽) | (0, 367, 1280, 433) | 营地内踩实裸土 | - -**为什么分两片**:整张一次出图时,模型按「构图好看」分配天地比例,山被挤到画面下半部——三张实测里两张的山体整段落在围墙后面(山体上缘 y280/y227,墙顶 217),远山等于消失。拆开后远景片的全部高度都归天空+山+草地,山才可能长到围墙之上。 - -#### 2.1.1 远景片(`bg_far`) - -**画布比例**:3.5:1 超宽。通道支持直接出 2560×734;不支持则按 3:2 出图后纵向压到 3.5:1,构图比例不变 -**分带(按画面高度)**: -- 天空 0–14% -- 远山 14–55%(**山体占 41%,硬指标**) -- 草地 55–100% -- 小路在草地内:从底边向上收窄,到山脚为止,**不越过山脚、不上探到天空** -**画什么**:天空、2–3 层远山、成片草地、草地中央一条土路 -**不画什么**:围墙、门柱、门扇、物件、角色、营地内地面(那是 §2.1.2);山与草地之间禁出现平直硬边 +**出图画布**:2560×1600(终稿 1280×800 的 2 倍,保证整数倍降采样) +**画什么**:天空、远山、整片地面、门洞位置一条通向远处的小路 +**不画什么**:木围墙、门柱、门扇、七个物件、角色、接地阴影——围墙与门柱是 §2.2 的独立层,门洞是它的透明缺口;小路在本层画好、透出来即可 ``` -Pixel art style. True pixel art game background, chunky square pixels, strict pixel grid, hard edges, no anti-aliasing, flat colors, limited palette about 20 colors, retro 16-bit look, Aseprite style, flat side view, orthographic, ULTRA WIDE horizontal composition, aspect ratio 3.5 to 1. This is the far view seen from inside a small adventurer camp, looking out. It contains ONLY three bands stacked top to bottom, plus one path. BAND 1 — SKY, from the top edge down to 14 percent of the frame height: pale cool off-white with a faint blue-grey tint, flat and empty, at most one or two extremely simple flat clouds, no sun. BAND 2 — DISTANT HILLS, from 14 percent down to 55 percent of the frame height: two or three overlapping layers of rolling hills in pale cool blue-grey-green, each farther layer lighter, cooler and lower in contrast. THE HILLS MUST FILL THIS ENTIRE BAND — the highest ridgeline reaches up to 14 to 20 percent of the frame height and the hill mass continues down to 55 percent; a low ridge hugging the bottom of the frame is WRONG. Silhouette edges are stepped in pixel blocks, no outline, no rim light. BAND 3 — MEADOW, from 55 percent down to the bottom edge: cool pale grey-green grass, flat and low contrast, marked only by a few simple flat grass tufts, no trees, no bushes. PATH — one dirt path in the meadow only: it starts at the bottom edge of the frame in the middle, about one fifth of the frame width there, runs straight up and narrows until it meets the hill foot at 55 percent, where it disappears; it must not go above the hill foot and must not reach the sky. COLOR: everything cool, pale, desaturated, low contrast, no warm, golden, amber or yellow anywhere. DO NOT ADD: any palisade wall, fence, gate, gate posts, tent, campfire, crate, barrel, prop, character, animal; any hard straight horizontal seam between sky, hills and meadow — the hill foot dissolves into the meadow through stepped intermediate tones; any outline or rim; trees, forests or dense vegetation; antialiasing, gradients, dithering, painterly strokes, 3D, realistic shading, glossy light, bloom, vignette, dark corners, night, sunset, sun; any text, letters, number, watermark, signature, logo, frame, border, UI element, icon, badge. +Pixel art style. True pixel art game background layer drawn with chunky square pixels, strict pixel grid alignment, hard edges, no anti-aliasing, no gradients, flat colors only, a limited palette of about 24 colors, retro 16-bit look, Aseprite style, all ages, flat side view, orthographic, no perspective distortion. Wide horizontal composition, 16:10 aspect ratio. This is the FAR BACKGROUND LAYER ONLY of a small adventurer camp: sky, distant hills and the whole ground. It must contain no palisade wall, no gate posts, no camp objects, no gate leaves, no characters and no drop shadows, because all of those are drawn as separate transparent layers on top of this one. COLOR SCRIPT — cool recedes, warm advances: the sky, the hills and the ground are all COOL, pale, desaturated and low in contrast; there is no warm accent anywhere in this layer, warm color belongs only to the separate object layers drawn on top. ATMOSPHERIC DEPTH: every plane that is farther away is lighter, closer to the value of the sky, cooler, lower in contrast and simpler in detail, while the nearest plane is the darkest and highest in contrast. SKY (top of frame): the lightest thing in the picture, flat pale cool off-white with a faint blue-grey tint, no yellow or cream tone, no sun anywhere; the very top strip of the frame, about one sixteenth of the frame height, stays completely calm and empty, reserved as clear space with nothing in it; at most one or two extremely simple flat low-contrast clouds. FAR DISTANCE: two or three layers of low rolling hill silhouettes in pale cool blue-grey, each farther layer lighter, cooler and lower in contrast than the one in front, fading towards the value of the sky, no trees, no individual foliage. HILLS TO GROUND: the hills fade gradually and smoothly down into the ground, with NO visible horizon line and NO distant ground band — the transition must not read as a separate horizontal stripe of a different color. GATE PATH: in the middle of the frame, between x=1040 and x=1520, one clearly visible dirt path runs from the ground at two thirds of the frame height measured from the bottom edge away into the distance between the hills, narrowing as it recedes, painted in a muted desaturated grey-brown earth tone, never golden or warm yellow; this path shows through the empty gate opening of the wall layer above. FOREGROUND GROUND: the darkest area of the picture, dry packed bare earth in a neutral desaturated grey-brown, never golden, amber or warm yellow, hard flat color blocks, hard edges, no gradient, no grass, no grass tufts, no weeds and no plants of any kind. The foreground ground is completely empty apart from the dirt path: no objects, no icons, no badges, no markers, no decorations of any kind. Do not add any of the following: realistic, photorealistic or 3D rendering; painterly brushwork, smooth anti-aliased lines, gradient or dithered shading, glossy highlights; black or heavy dark outlines; drop shadows, contact shadows or any shadow band; blur or compression artifacts; a vignette, dark corners, or any mood darkening; night, dusk, moon, stars, sunset or sunrise light; a sun or any light source drawn in the sky; a visible horizon line, a separate horizontal distant ground band or any distant grass strip below the hills; trees, forests or foliage in the distance; grass, grass tufts, weeds or plants in the foreground; a palisade wall, wooden fence, fence posts, gate posts or any wall structure; any warm, golden, amber or yellow cast on the sky, on the hills or on the ground; any camp object, furniture, crate, barrel, fire, tent or tool; any gate leaf, door panel or hinge; any purple or magenta anywhere; any text, letters, numbers, watermark, signature or logo; any frame, border, panel, arrow or UI element; any icon, badge, item tile, emblem, sign, marker or symbol anywhere in the frame; any characters, people, creatures or animals. ``` -#### 2.1.2 地面片(`bg_floor`) - -**画布比例**:3:1 超宽 -**内容**:营地内的踩实裸土,铺满整幅 -**硬指标**:**中线与左右两侧同色——没有任何路、小径、车辙**;明度 30%–45%;色相与远景片草地明显区分 -**不画什么**:草、植被、大石块、物件、天空、地平线、围墙、接缝线 - -``` -Pixel art style. True pixel art game ground texture, chunky square pixels, strict pixel grid, hard edges, no anti-aliasing, flat colors, limited palette about 12 colors, retro 16-bit look, Aseprite style, ULTRA WIDE horizontal composition, aspect ratio 3 to 1. This is the trodden floor INSIDE a small adventurer camp — bare packed dry earth filling the whole frame edge to edge, seen at a shallow angle from slightly above. MATERIAL: desaturated warm grey-brown, medium value, roughly 30 to 45 percent luminance, never dark, never muddy. TEXTURE: deliberate pixel clusters only — short paired scuff marks that read as dragged footprints, small scattered pebbles, beaten patches where feet have worn the ground smooth, occasional faint drag marks; density rises slightly toward the bottom edge and the middle stays calm and open. CRITICAL, NO PATH: there is no road, no trail, no track, no wheel rut and no worn strip anywhere in this image — the middle of the frame must look the same as the left and right edges, nothing leads anywhere. DO NOT ADD: grass, vegetation, plants, flowers, moss, lichen; rocks larger than a pebble; crates, sticks, bones, debris piles, tools; water, puddles; shadows of objects; sky, horizon, hills, wall, fence, gate, building; any line, seam, border or edge inside the frame; antialiasing, gradients, dithering, painterly strokes, 3D, realistic shading, glossy light, bloom, vignette, dark corners, warm golden cast; any text, letters, number, watermark, signature, logo, frame, border, UI element, icon, badge. -``` - -#### 2.1.3 拼接与后处理 - -1. 两片各缩放到终稿尺寸:远景片 1280×367、地面片 1280×433 -2. 按 y367 上下拼接成 1280×800 -3. 量化到营地环境子板(≤32 色) -4. 需要 2× 档时整数倍放大到 2560×1600 -5. 接缝不做过渡处理——它整条落在围墙(203–367)背后 - ### 2.2 围墙层(`scene_camp_wall`,不可点,透明底) -**出图画布**:2560×1600(与背景层同画布,门洞 x 坐标直接对齐);墙横贯画在出图 y406–734 这条带内(桩身 y434–734,门柱 y406–734),其余为纯色底 `#F2F2F2`。落盘由 S4 后处理裁成终稿外接矩形 `(0, 203, 1280, 164)` / 出图 `(0, 406, 2560, 328)` 的细条,**不做全幅 1280×800 落盘**(全幅约 3.9 MB 显存,细条约 0.35 MB)。 -**画什么**:横贯全宽的密排竖木桩墙(左右两段,桩身终稿 150 高)+ 两根略高的门柱(终稿 164 高),门洞处完全透明 +**出图画布**:2560×1600(与背景层同画布,门洞 x 坐标直接对齐);墙横贯画在出图 y480–620 这条**窄带**内,其余为纯色底 `#F2F2F2`。落盘由 S4 后处理裁成终稿外接矩形 `(0, 240, 1280, 70)` / 出图 `(0, 480, 2560, 140)` 的细条,**不做全幅 1280×800 落盘**(全幅约 3.9 MB 显存,细条约 0.35 MB)。 +**画什么**:横贯全宽的密排竖木桩墙(左右两段)+ 两根略高的门柱,门洞处完全透明 **不画什么**:天空、远山、地面、小路、门扇、七个物件、角色;门洞内不画任何东西,透出背景层的小路 ``` -Pixel art style. True pixel art game layer drawn with chunky square pixels, strict pixel grid alignment, hard edges, no anti-aliasing, no gradients, flat colors only, a limited palette of about 24 colors, retro 16-bit look, Aseprite style, all ages, flat side view, orthographic, no perspective distortion. Wide horizontal composition, 16:10 aspect ratio. This is the PALISADE WALL LAYER ONLY of a small adventurer camp, drawn on a plain flat uniform light grey #F2F2F2 background for easy keying. A wooden palisade wall runs straight across the full width of the frame, seen from the front, built from closely spaced vertical logs standing side by side with no gaps between them, every log cut to a sharp point at the top, a solid barrier with no horizontal rails, no see-through gaps, no ranch-style post-and-rail fence. The wall stands on the ground line at y=734 of 1600. The LOG BODIES, pointed tips included, rise 300 pixels above that base line — about nineteen percent of the total frame height — so it reads as a real barrier with presence, clearly taller than the gate opening cut into it, not a thin decorative strip. In the middle of the wall one wide gap holds two thick gate posts standing 28 pixels taller than the logs, and the entire opening between them is completely EMPTY — plain flat uniform light grey #F2F2F2 showing through, spanning x=1040 to x=1520 and rising from the base line y=734 up to y=566, no logs, no path, no ground, no scenery inside the opening, because the dirt path and the hills belong to the background layer below and the gate leaves are a separate object layer. The wall is painted in a muted greyed wood brown, with a thin warm cream outline around the whole silhouette, flat shading. Do not add any of the following: realistic, photorealistic or 3D rendering; painterly brushwork, smooth anti-aliased lines, gradient or dithered shading, glossy highlights; black or heavy dark outlines; drop shadows or contact shadows; blur or compression artifacts; a vignette or dark corners; any sky, hills, ground, grass, dirt path or scenery of any kind; any gate leaf, door panel or hinge; any camp object, furniture, crate, barrel, fire, tent or tool; any characters, people, creatures or animals; any purple or magenta anywhere; any text, letters, numbers, watermark, signature or logo; any frame, border, panel, arrow or UI element. +Pixel art style. True pixel art game layer drawn with chunky square pixels, strict pixel grid alignment, hard edges, no anti-aliasing, no gradients, flat colors only, a limited palette of about 24 colors, retro 16-bit look, Aseprite style, all ages, flat side view, orthographic, no perspective distortion. Wide horizontal composition, 16:10 aspect ratio. This is the PALISADE WALL LAYER ONLY of a small adventurer camp, drawn on a plain flat uniform light grey #F2F2F2 background for easy keying. A wooden palisade wall runs straight across the full width of the frame, seen from the front, built from closely spaced vertical logs standing side by side with no gaps between them, every log cut to a sharp point at the top, a solid barrier with no horizontal rails, no see-through gaps, no ranch-style post-and-rail fence. The wall is SHORT and LOW: including the pointed tips the whole band occupies only about one eighteenth of the total frame height, a thin horizontal strip, NOT a tall barrier; the logs stand only about one eighteenth of the total frame height above their base line. In the middle of the wall one wide gap holds two thick gate posts slightly taller than the wall, and the entire opening between them is completely EMPTY — plain flat uniform light grey #F2F2F2 showing through, no logs, no path, no ground, no scenery inside the opening, because the dirt path and the hills belong to the background layer below and the gate leaves are a separate object layer. The wall is painted in a muted greyed wood brown, with a thin warm cream outline around the whole silhouette, flat shading. Do not add any of the following: realistic, photorealistic or 3D rendering; painterly brushwork, smooth anti-aliased lines, gradient or dithered shading, glossy highlights; black or heavy dark outlines; drop shadows or contact shadows; blur or compression artifacts; a vignette or dark corners; any sky, hills, ground, grass, dirt path or scenery of any kind; any gate leaf, door panel or hinge; any camp object, furniture, crate, barrel, fire, tent or tool; any characters, people, creatures or animals; any purple or magenta anywhere; any text, letters, numbers, watermark, signature or logo; any frame, border, panel, arrow or UI element. ``` ### 2.3 出图画布上的坐标(终稿 ×2) @@ -101,17 +66,11 @@ Pixel art style. True pixel art game layer drawn with chunky square pixels, stri | 元素 | 终稿 1280×800 | 出图画布 2560×1600 | |---|---|---| | 顶栏 HUD 保留带 | y ≤ 48 | **y ≤ 96**(保持空旷) | -| 远景片 `bg_far` | y 0–367(高 367) | y 0–734(高 734,3.5:1) | -| 地面片 `bg_floor` | y 367–800(高 433) | y 734–1600(高 866,3:1) | -| 两片接缝 | **y 367**(落在围墙 203–367 背后) | **y 734**(落在围墙 406–734 背后) | -| 地平线(山脚/草地起点) | y 与两片接缝重合,墙背后不可见 | 同左 | -| 远山带 | y 51–202(画面 14%–55%,山体 41%) | y 103–403 | -| 草地带 | y 202–367 | y 403–734 | -| 围墙层外接矩形 | y 203–367(高 164) | y 406–734(高 328) | -| 围墙带(wall 层桩身) | y 217–367(**高 150px**) | y 434–734(**高 300px**) | -| 门柱(wall 层,略高) | y 203–367(高 164);x 500–520 / 760–780 | y 406–734;x 1000–1040 / 1520–1560 | -| 门洞(wall 层透明缺口) | y 283–367(高 84);x 520–760 | y 566–734(高 168);x 1040–1520 | -| 门洞内小路(画在远景片草地内) | x 520–760 穿门洞,自接缝 y367 向上收窄,到山脚 y202 消失 | x 1040–1520,自 y734 向上收窄,到 y403 消失 | +| 背景层 `scene_camp_bg` | 全幅 1280×800 | 全幅 2560×1600 | +| 围墙带(wall 层桩身) | y 267–310(**高 43px**) | y 534–620(**高 86px**) | +| 门柱(wall 层,略高) | y 240–310;x 500–520 / 760–780 | y 480–620;x 1000–1040 / 1520–1560 | +| 门洞(wall 层透明缺口) | x 520–760 | x 1040–1520 | +| 门洞内小路(画在 bg 层) | x 520–760,自 y310 向上收窄至远山 | x 1040–1520,自 y620 向上收窄至远山 | --- @@ -138,7 +97,7 @@ Do not add any of the following: realistic, photorealistic or 3D rendering; pain | 件 | z 序 | 终稿矩形 | 出图画布 | 正文 | |---|---|---|---|---| | 布告板 | 2 | 130×140 | 260×280 | §3.1 | -| 门扇 | 3 | 240×147 | 480×294 | §3.2 | +| 门扇 | 3 | 240×220 | 480×440 | §3.2 | | 木桩 | 4 | 120×180 | 240×360 | §3.3 | | 锻炉·铁砧 | 4 | 240×240 | 480×480 | §3.4 | | 帐篷 | 4(不可点) | 300×170 | 600×340 | §3.5 | @@ -146,7 +105,7 @@ Do not add any of the following: realistic, photorealistic or 3D rendering; pain | 笔记本 | 5 | 100×80 | 200×160 | §3.7 | | 篝火 | 6 | 160×160 | 320×320 | §3.8 | -**落位尺寸(2026-11-05 用户裁地平线 y367 重排后)**:布告板 130×121/门扇 240×138/木桩 120×180/锻炉 240×193/帐篷 251×170/宝箱 106×90/笔记本 100×66/篝火 160×149——贴图锚定=底中心(帐篷/锻炉/宝箱/篝火/笔记本)+顶锚(门扇悬挂/布告板挂墙);命中矩形按锁定值。八件只改落位,门扇另随门洞压扁重画(原 240×211 → 240×138),其余七件沿用现有终稿。 +**落位实测(锁定矩形 contain)**:布告板 130×121/门扇 240×211/木桩 120×180/锻炉 240×193/帐篷 251×170/宝箱 106×90/笔记本 100×66/篝火 160×149——贴图锚定=底中心(帐篷/锻炉/宝箱/篝火/笔记本)+顶锚(门扇悬挂/布告板挂墙);命中矩形按锁定值。换算式登记(源 2× 档→落位尺寸)存 artwork 工作区。 **3.1 布告板(→ 图鉴)** ``` @@ -155,7 +114,7 @@ A wooden notice board hung on a wall, with two or three blank pinned papers, no **3.2 门扇(→ 冒险)** ``` -Two wooden gate leaves hanging from their hinges, half open and tilted outward, each made of thick planks with a cross brace and dark iron fittings, seen from the front, the pair shown together as one single object; the pair together fills a WIDE LOW rectangle about 240 wide by 138 tall, so the leaves read as broad rather than tall, each leaf roughly twice as wide as it is high; wood brown #8A5A34 planks, dark iron fittings. +Two wooden gate leaves hanging from their hinges, half open and tilted outward, each made of thick planks with a cross brace and dark iron fittings, seen from the front, the pair shown together as one single object; wood brown #8A5A34 planks, dark iron fittings. ``` **3.3 木桩(→ 天赋树)** @@ -192,32 +151,17 @@ A campfire made of a ring of grey stones around crossed stacked logs with a warm ## 4. 验收(机检 + 目检) -**远景片 `bg_far`** -- 黄度 =(R+G)/2−B 分区取样:天空 ≤20、远山 ≤20、草地 ≤35 -- 冷色像素占比 ≥55%;禁出现任何暖色高饱和像素 -- 机检:**山体上缘 ≤ 画面高度 15%**(这是硬指标——山体上缘落得越低,被围墙挡住后越是什么都看不见) -- 机检:**山体占画面高度 ≥40%**(山体上缘到山脚) -- 机检:草地上限 ≤ 画面高度 55% -- 机检:山脚与草地交界处,上下各 8px 窄带的行均色差 ≤6/255(禁平直硬边;旧稿 y321 处约 37/255) -- 目检:顶部天空空旷、无太阳、云不超过两朵扁平简笔 -- 目检:小路只在草地内,从底边向上收窄到山脚为止,**不越过山脚、不上探到天空** -- 目检:无围墙、门柱、门扇、物件、角色 - -**地面片 `bg_floor`** -- 明度 **L 0.30–0.45**(相对亮度);整片低于 0.30 即判红(旧稿偏暗、观感发闷) -- 机检:画面中线 200px 带与左右各 250px 带的平均色差 ≤6/255(**营地内无路带**) -- 机检:与远景片草地的平均色 (R−B) 差 ≥10/255(两种材质色相可辨) -- 目检:禁出现草、植被、大石块、物件、水面;细节读作拖拽脚印/碎石/踩踏斑块,不是满屏噪点 - -**整图(两片拼接后)** -- 接缝 y367 落在围墙 203–367 背后,**画面里看不见接缝** -- 单张 ≤32 色(量化后) -- 全幅无任何独立物件/图标/徽章/标记 +**背景层 `scene_camp_bg`** +- 黄度 =(R+G)/2−B 分区取样:天空 ≤20、远山 ≤20、地面 ≤35 +- 冷色像素占比 ≥55%;本层不应出现任何暖色高饱和像素 +- 目检:顶部约 1/16 完全空旷;**无地平线、无远景地面带**(远山自然渐隐过渡到地面);不含围墙、门柱、门扇、物件、角色 +- 目检/机检:门洞 x1040–1520 处**有一条清晰可辨、向远处收窄的土路**(非纯色块),透出「出去即冒险」 +- 机检/目检:全幅**无任何独立物件/图标/徽章/标记**(前景地面除小路外全空);参考做法=中下方块区独立色数应≈邻区 **围墙层 `scene_camp_wall`** - 透明底 `#F2F2F2` 可去底、奶油描边闭合 -- **桩身 = 出图 y434–734(终稿 150px);门柱 y406–734(终稿 164px);外接矩形 (0,406,2560,328)。桩身超出下沿 y734 达 5px 以上即判红** -- 目检:密排竖木桩、桩顶削尖、不透缝、无横木;门洞 y566–734 / x1040–1520 **完全透明**(透出 bg 小路)、无门扇;桩身高度须明显大于门洞高度 +- **桩身高度 = 出图 y534–620(终稿 43px);门柱略高 y480–620。桩身超出围墙带下沿 y620 达 5px 以上即判红** +- 目检:密排竖木桩、桩顶削尖、不透缝、无横木;门洞 x1040–1520 **完全透明**(透出 bg 小路)、无门扇 - 单张色数 ≤32(量化后)、缩放为整数倍无拉伸痕迹 > **量化与缩放实况注记**:围墙层与八件已按营地主色板 **32 色重量化回写**(art-bible §4.1 子板行——违例对 0);落位缩放实况=源 2× 档经后处理换算为锁定矩形 contain(×0.1934–0.4404 非整数倍),「整数倍无拉伸痕迹」判据的机检面以落位实测尺寸与目检兜底执行。 @@ -231,16 +175,10 @@ A campfire made of a ring of grey stones around crossed stacked logs with a warm --- -## 5. 已裁(2026-11-05 用户裁;不再阻塞坐标表与定稿) +## 5. 已裁(2026-11-01 用户裁;不再阻塞坐标表与定稿) -1. **地平线与整体分带**:地平线定在终稿 y367(出图 y734);地面 433px、可见远景 217px(27%);围墙、门、布告板随之上调 -2. **围墙高度**:桩身 150(出图 300)、门柱 164(出图 328)、门洞 84(出图 168);墙高须大于门洞高 -3. **门扇尺寸**:贴图 240×138(锁定 240×147),随门洞压扁重画,坐标按门洞 x520–760 对齐 -4. **帐篷**:251×170 沿用现终稿,落位 (982, 335),遮住墙下部 32px(覆盖围墙可接受) -5. **笔记本**:随帐篷跟随,落在帐篷左下方外侧、两者不重叠——`x = 帐篷 x − 112`、`y = 帐篷底 + 10` -6. **宝箱离底**:贴图底 y744,距画面底 56px;其余物件同步上移 46px -7. **地面分两区**:营地外(y734 以上)=冷调灰绿草地+一条向远山收窄的土路;营地内(y734 至画面底)=踩实裸土、与草地异色相、无草无路无车辙;两区分界被围墙层完全遮住 -8. **背景层分两片出图**:远景片 `bg_far`(2560×734,天空 14%/远山 41%/草地 45%+小路)+ 地面片 `bg_floor`(2560×866 踩实裸土),后处理按 y734 拼成一张 2560×1600;接缝落在围墙 406–734 背后。分片理由=整张出图时模型把山挤到画面下半部,实测三张里两张山体整段被围墙遮没 -9. **布告板位置**:(845, 224, 130, 140),底 345 落在墙内,不再悬空 -10. **描边颜色**:奶油色追认——去底脚本「奶油描边膨胀 3px 当屏障」的技术前提随现值锁定(§1 注记同源);改深灰褐须换去底方案全量重出,不裁 -11. **营地环境子板色值**:不前置拍板——量化场景实际用色后按 art-bible §4.1 两道门登记(语义检查+RGB 三通道差 ≥8/255),本卡仍不复述色值 +1. **帐篷尺寸**:300×170 追认现值(各素材第一张样本稿已过审为终稿;重定即按比例重出、不裁) +2. **门扇高宽**:240×220 追认现值,坐标按门洞 x520–760 对齐(§2.3);围墙带桩身高 43px、门扇叠在门洞上的叠放关系随此锁定 +3. **布告板位置**:采纳建议值 `(845, 268, 130, 140)` 挂回围墙带;终值随坐标表(`camp_scene_layout.tres`)落盘 +4. **描边颜色**:奶油色追认——去底脚本「奶油描边膨胀 3px 当屏障」的技术前提随现值锁定(§1 注记同源);改深灰褐须换去底方案全量重出,不裁 +5. **营地环境子板色值**:不前置拍板——量化场景实际用色后按 art-bible §4.1 两道门登记(语义检查+RGB 三通道差 ≥8/255),本卡仍不复述色值 diff --git a/design/gdd/ux/camp.md b/design/gdd/ux/camp.md index a37ebf0e..991baf70 100644 --- a/design/gdd/ux/camp.md +++ b/design/gdd/ux/camp.md @@ -20,13 +20,13 @@ | 木桩 | 天赋树 | | | 布告板 | 奖励格图鉴 | 内容冻结未激活,点进看占位——**不是禁用态** | | 篝火 | 离开营地回主菜单 | **必须二次确认**;命中区只含火堆、不含火焰 | -| 帐篷 | — | 承载件,**不可点**;251×170 落位 (982, 335),上端遮住围墙下部;笔记本在其左下方外侧、两者不重叠 | +| 帐篷 | — | 承载件,**不可点**;落地尺寸与占位范围待作者重定(待裁口径见 art-bible §6.2) | 纵深=远景天空与远山/中景木围墙与营地大门/近景篝火与左右物件(作者纵深稿)。中景围墙是横贯全宽的密排竖木桩墙——木桩一根挨一根无缝、桩顶削尖、无横木,只在中段留缺口给大门。 **场景按要素分层(10 张)**:远景天空/远山/整片地面/门洞内的小路合成 `scene_camp_bg` 背景层(z0,不透明全幅);中景木围墙独立成 `scene_camp_wall` 围墙层(z1,透明底,门洞处留透明缺口);其余八个物件层各一张、由上往下叠。门洞是围墙层的透明缺口,透出背景层画的**一条通向远处的小路**,不可填死。 -**地平线在终稿 y367**(自画面底部往上约 46%),其上是天空与远山、其下整片地面;**中景围墙带 y203–367**,桩身 150、门柱 164;近景物件允许压住围墙(围墙不可点,z 序在近景物件之下),**唯一例外=中间门洞必须留空、不被任何物件遮挡**。围墙形态、门扇比例与帐篷尺寸的现行口径归 [art-bible](../../art/art-bible.md) §6.2,本 spec 不再复述。 +**中景带自画面底部往上约 2/3 处起**;近景物件允许压住围墙(围墙不可点,z 序在近景物件之下),**唯一例外=中间门洞必须留空、不被任何物件遮挡**。围墙形态、门扇比例与帐篷尺寸的现行口径归 [art-bible](../../art/art-bible.md) §6.2,本 spec 不再复述。 坐标草案与分层验收判据见 [camp-scene-prompts](../../art/camp-scene-prompts.md) §2.3/§4(10 张口径现行事实源);2026-10-22 美术方案讨论沿革查 git 提交史。 diff --git a/game/data/schemas/level_gen_params.gd b/game/data/schemas/level_gen_params.gd index 40563cc5..6bb36305 100644 --- a/game/data/schemas/level_gen_params.gd +++ b/game/data/schemas/level_gen_params.gd @@ -1,6 +1,7 @@ ## 关卡节点图参数档 schema(节点图生成器核心批 卡① T1):五旋钮+战斗保底+岔口去路数的行式数据表。 ## 键 = level_index(关卡行集单点),消费面经 ConfigDB.run.level_gen_params.row_by_level_index() 读取;生成器只吃参数档、不认关卡号(契约 6)。 -## 数值全 provisional(定值批落终值);域校验沿关卡节点图生成.md v1.1 调参表已锁条款——层深 L1≥6 递增增幅变大、 +## 数值全 provisional(定值批落终值);取值域与字段契约单点=本文件域校验(概念与设计意图指针= +## 关卡节点图生成.md v1.5 参数契约节)——层深 L1≥6 递增增幅变大、 ## 配比四类合计 1、出口 L1 恒 1/L2-L3 1-3、岔口去路数恒 2/3、战斗保底≥0。 class_name LevelGenParams extends Resource @@ -66,7 +67,7 @@ func _validate_row_shape(row: Dictionary, L: int, key: String, layers_by_level: var bd: float = float(row.get("branch_density", -1.0)) if bd < 0.0 or bd > 1.0: errs.append({"category": "domain", "message": "%s 分支密度 %f 越域 [0,1]" % [key, bd]}) - # 域并单点(关卡节点图生成.md v1.4 调参表——生成器侧域校验退役,唯此一处) + # 取值域单点=本文件域校验(概念与设计意图指针=关卡节点图生成.md v1.5 参数契约节) var ftw: float = float(row.get("fork_third_way_pct", -1.0)) if ftw < 0.0 or ftw > 100.0: errs.append({"category": "domain", "message": "%s fork_third_way_pct %f 越域 [0,100]" % [key, ftw]}) diff --git a/game/presentation/views/level_map_screen.gd b/game/presentation/views/level_map_screen.gd index fd0af5e7..c7ad7bf6 100644 --- a/game/presentation/views/level_map_screen.gd +++ b/game/presentation/views/level_map_screen.gd @@ -31,6 +31,8 @@ const SLOT_STEP_Y := 58 # 同层槽位纵向步距 const HIT_PAD := 6.0 # 图上命中外扩(指宽容差) const CONFIRM_WINDOW_S := 3.0 # 放弃确认态窗口(秒——超时回落) const COL_HP_BAR_BACK := UiKit.HP_BAR_BACK # 迷你血条深底(对暗面板可辨;值单点收 UiKit) +const DRAG_THRESHOLD := 8.0 # 拖动距离阈值(布局决策 5 provisional——超阈值进拖拽态,拖拽不触发选路) +const WHEEL_STEP := 60.0 # 滚轮横滚单档步进(provisional 实现期定值;方向映射=代拍板:下/右=向右前进向) ## 内部状态件(u2 子域拆件——契约 9 职责轴):_MapState=选路屏运行态与图绘制态(单一变化原因= ## 选路呈现)。layer_decor 域字典承载换层装饰三件(图标贴图/石纹面/tint 层号——装饰域归并形态)。 @@ -39,6 +41,7 @@ class _MapState extends RefCounted: var runner = null var title: Label var canvas: Control + var viewport: LevelMapViewport # 卷轴视口承载件(布局决策 5——画布宿主/裁切/clamp/跟随/指引) var level_label: Label var gp_value: Label var hp_value: Label @@ -54,6 +57,8 @@ class _MapState extends RefCounted: var _cand_ids: Array[int] = [] # 本停点候选 id 集(图上命中合法集;探针直读冻结——住宿主) var _content_size := Vector2(620, 240) # 图内容包络(open_choices 按图拟合——画布 min 尺寸随它;探针直读冻结) var _hover_id := -1 # 悬停预承诺候选(-1 = 无;探针直读冻结) +var _drag_active := false # 拖拽态(超阈值至抬杆——拖拽不触发选路,布局决策 5) +var _drag_press_pos := Vector2(-1, -1) # 拖距判定基准(按下点画布局部坐标;-1 = 无按下跟踪) var abandon_btn: Button # 放弃入口(可见性归组合根 set_abandon_available——只在冒险模式开;探针直读冻结) var status_badge: Button # 状态面板入口角标(顶栏 GP 徽右侧;连接归组合根;探针直读冻结) var _layout_cache := {"nodes": [], "canvas_size": Vector2.ZERO, "pos": {}} # 布局缓存(_layout 纯函数于 (nodes, canvas)——键=nodes 内容等价+画布尺寸,图态换代或画布尺寸变即自然失效;探针直读冻结) @@ -74,15 +79,15 @@ func _init() -> void: base.add_child(tex) _map.layer_decor.noise_rect = tex # 世界侧 tint 承载面(换层色板渐变挂点——§2.7 切片档) base.add_child(UiKit.vignette_rect()) - var center := CenterContainer.new() - center.set_anchors_preset(Control.PRESET_FULL_RECT) - base.add_child(center) - var box := VBoxContainer.new() - box.add_theme_constant_override("separation", 10) - center.add_child(box) - _build_top_bar(box) - _build_canvas(box) - _build_status_bar(box) + # 三段纵排页(布局决策 5 卷轴视口:顶栏/标题在上、图区视口扩张充盈、底栏在下—— + # 视口为窗口屏为框,画布随图生长可超屏) + var page := VBoxContainer.new() + page.set_anchors_preset(Control.PRESET_FULL_RECT) + page.add_theme_constant_override("separation", 10) + base.add_child(page) + _build_top_bar(page) + _build_canvas(page) + _build_status_bar(page) visible = false @@ -122,14 +127,21 @@ func _build_top_bar(box: VBoxContainer) -> void: box.add_child(_map.title) -## 节点图画布域(图上点击 = 唯一提交通道——布局决策 2;mouse_filter STOP 接悬停/点击) +## 节点图区(卷轴视口承载——布局决策 5):视口件为窗口(裁切/offset/clamp/跟随/指引), +## 画布为其子件;图上点击 = 唯一提交通道(布局决策 2),拖拽/滚轮判定留在画布 _gui_input +## (事件流同源——悬停/选路同源,视口件不做父子事件路由) func _build_canvas(box: VBoxContainer) -> void: _map.canvas = Control.new() _map.canvas.custom_minimum_size = Vector2(620, 240) _map.canvas.mouse_filter = Control.MOUSE_FILTER_STOP _map.canvas.draw.connect(_draw_graph.bind(_map.canvas)) _map.canvas.gui_input.connect(_on_canvas_input) - box.add_child(_map.canvas) + _map.canvas.mouse_exited.connect(_on_canvas_mouse_exited) + _map.viewport = LevelMapViewport.new() + _map.viewport.size_flags_horizontal = Control.SIZE_EXPAND_FILL + _map.viewport.size_flags_vertical = Control.SIZE_EXPAND_FILL + box.add_child(_map.viewport) + _map.viewport.setup(_map.canvas, _viewport_anchor, Vector2(1280, 800)) # 兜底尺寸=设计基座(实况窗口以布局为准) ## 底部状态栏域(合并单条:亮米面板一站读齐=头像+生命+属性+本关加成/流派件——demo ④; @@ -297,6 +309,9 @@ func open_choices(cands: Array, runner: LevelRunner) -> void: _fit_canvas(_map.runner.nodes()) _apply_level_tint(int(_map.runner.level_row.get("level_index", 0))) visible = true + # 视口锚定(布局决策 5):入口停点(尚无当前节点)=首锚即达;其余停点/兑付回图=平滑跟随 + # (停点切换与兑付确认回图——减动效开由视口件跳终态) + _map.viewport.reanchor(_map.runner.current_node_id() >= 0) if not was_visible: # 独立屏淡入(spec Transitions:约 200ms;alpha 淡入非明度变化——art-bible §2 明度恒定) modulate.a = 0.0 @@ -323,6 +338,7 @@ func _fit_canvas(nodes: Array) -> void: SLOT_BASE_Y + maxslot * SLOT_STEP_Y + NODE_HALF.y + 24.0) # +24 = 类型文字标签行 _content_size = _content_size.max(Vector2(620, 256)) # 下限(抬高容纳标签行) _map.canvas.custom_minimum_size = _content_size + _map.canvas.size = _content_size # 同帧收敛(视口锚点/包络数学与 _layout 同源读画布尺寸——容器延迟收敛不等锚点) # 换层色板渐变挂点(art-bible §2.7 下探过渡切片档——关卡结算确认→下一关→独立屏淡入链路上的 @@ -356,20 +372,70 @@ func apply_status(payload: Dictionary) -> void: # ---- 图上命中(布局决策 2——唯一提交通道;探针 press_candidate 同路径) ---- +# 拖拽与点击以拖动距离阈值区分(布局决策 5):按下起跟踪拖距,超阈值进拖拽态逐帧平移视口, +# 抬杆不提交;未超阈值的完整点击(按下→抬杆)=提交沿——命中候选即提交,语义零改动。 func _on_canvas_input(ev: InputEvent) -> void: if _map.runner == null or not visible: return - if ev is InputEventMouseMotion: - var nid := _candidate_at(ev.position) # gui_input 事件坐标 = 接收控件局部坐标(push_input 同成立) - if nid != _hover_id: - _hover_id = nid - _map.canvas.mouse_default_cursor_shape = Control.CURSOR_POINTING_HAND if nid >= 0 else Control.CURSOR_ARROW + if ev is InputEventMouseButton and ev.pressed and _wheel_pan_step(ev.button_index) != 0.0: + _map.viewport.pan_by(Vector2(_wheel_pan_step(ev.button_index), 0)) + elif ev is InputEventMouseMotion: + _on_canvas_motion(ev) + elif ev is InputEventMouseButton and ev.button_index == MOUSE_BUTTON_LEFT: + _on_canvas_click(ev) + + +## 悬停预承诺与拖拽态推进沿(画布局部坐标——gui_input 事件流同源) +func _on_canvas_motion(ev: InputEventMouseMotion) -> void: + if _drag_active: + _map.viewport.pan_by(ev.relative) # 拖拽态逐帧平移(视口件执行 clamp——本屏只判定) + return + if _drag_press_pos.x >= 0.0 and ev.position.distance_to(_drag_press_pos) >= DRAG_THRESHOLD: + _drag_active = true + _drag_press_pos = Vector2(-1, -1) + if _hover_id != -1: # 进拖拽态清悬停预承诺(拖拽非选路意图) + _hover_id = -1 + _map.canvas.mouse_default_cursor_shape = Control.CURSOR_ARROW _map.canvas.queue_redraw() - elif ev is InputEventMouseButton and ev.button_index == MOUSE_BUTTON_LEFT and ev.pressed: - var nid2 := _candidate_at(ev.position) - if nid2 >= 0: - _submit_choice(nid2) + _map.viewport.pan_by(ev.relative) # 跨阈值帧位移计入平移(拖距全长=平移全长) + return + var nid := _candidate_at(ev.position) # gui_input 事件坐标 = 接收控件局部坐标(push_input 同成立) + if nid != _hover_id: + _hover_id = nid + _map.canvas.mouse_default_cursor_shape = Control.CURSOR_POINTING_HAND if nid >= 0 else Control.CURSOR_ARROW + _map.canvas.queue_redraw() + + +## 点击沿与拖拽抬杆(未超阈值的完整点击=提交沿——布局决策 2 语义零改动) +func _on_canvas_click(ev: InputEventMouseButton) -> void: + if ev.pressed: + _drag_press_pos = ev.position + _drag_active = false + return + _drag_press_pos = Vector2(-1, -1) + if _drag_active: + _drag_active = false # 拖拽抬杆=平移结束,不触发选路提交(布局决策 5) + return + var nid2 := _candidate_at(ev.position) + if nid2 >= 0: + _submit_choice(nid2) + + +## 滚轮横滚步进(代拍板:下/右=向右前进向——视线移向深层,画布左移;上/左反向;非滚轮=0) +func _wheel_pan_step(button_index: int) -> float: + if button_index == MOUSE_BUTTON_WHEEL_DOWN or button_index == MOUSE_BUTTON_WHEEL_RIGHT: + return -WHEEL_STEP + if button_index == MOUSE_BUTTON_WHEEL_UP or button_index == MOUSE_BUTTON_WHEEL_LEFT: + return WHEEL_STEP + return 0.0 + + +## 拖距跟踪器生命周期清理(按下即武装——鼠标离画布且未入拖拽态时解除, +## 防丢失 release 沿后的残留 armed 态把后续 motion 误判成拖拽激活) +func _on_canvas_mouse_exited() -> void: + if not _drag_active: + _drag_press_pos = Vector2(-1, -1) # 候选命中:位置 = 布局公式(与 _draw_graph 同源),矩形 = 节点方块 + 外扩容差;非候选格不响应 @@ -413,6 +479,42 @@ func press_candidate(index: int) -> void: _submit_choice(nid) +# ---- 视口锚点(布局决策 5:当前节点居中;入口停点尚无当前节点=锚入口组包围盒居中) ---- + +## 锚点画布坐标提供方(构造注入视口件——本屏供数,视口件只消费) +func _viewport_anchor() -> Vector2: + if _map.runner == null: + return _content_size / 2.0 + var nodes: Array[Variant] = _map.runner.nodes() + var pos := _layout(nodes, _map.canvas) + var cur_id: int = _map.runner.current_node_id() + if cur_id < 0: + return _entry_group_center(nodes, pos) # 入口停点(尚未走出第一步) + var cur := _node_by_id(cur_id, nodes) + if cur.is_empty(): + return _content_size / 2.0 + return pos[cur] + + +func _entry_group_center(nodes: Array, pos: Dictionary) -> Vector2: + # 入口组 = 全部无入边节点(与 _assign_layers 入口判据同源);多入口取包围盒居中 + var inbound := {} + for node in nodes: + for tid in _out_ids(node, nodes): + inbound[tid] = true + var lo := Vector2(INF, INF) + var hi := Vector2(-INF, -INF) + for node in nodes: + if inbound.has(_id_of(node)): + continue + var p: Vector2 = pos[node] + lo = lo.min(p) + hi = hi.max(p) + if lo.x > hi.x: + return _content_size / 2.0 # 空图兜底(无入口——不达路径,防御位) + return (lo + hi) / 2.0 + + func _id_of(node: Dictionary) -> int: # 编号显示经行进器读面(编号空间解析单一出口——单链 = 下标 / 图 = node_depth) return _map.runner.node_id_of(node) diff --git a/game/presentation/views/level_map_viewport.gd b/game/presentation/views/level_map_viewport.gd new file mode 100644 index 00000000..70c5642e --- /dev/null +++ b/game/presentation/views/level_map_viewport.gd @@ -0,0 +1,190 @@ +## 卷轴视口承载件(level-map.md 布局决策 5——关卡画面选路屏):画布宿主与裁切、平移状态 +## 与 clamp、锚点跟随与平滑滚动、指引件(屏缘渐隐带+平移提示 chip)。 +## 纯呈现态:平移位置零核心写、不进存档(重进本屏按锚点规则重新定位);跟随只随停点切换/ +## 兑付回图发生(零自动推进)。拖拽/滚轮判定留画布 _gui_input(事件流同源——悬停/选路同源), +## 本件只管裁切/offset/clamp/跟随/指引;画布包络=画布实际尺寸(open_choices 拟合后同帧收敛)。 +class_name LevelMapViewport +extends Control + +const SCROLL_DUR := 0.3 # 平滑滚动时长(布局决策 5 provisional 0.3s 单段缓动) +const FADE_BAND_W := 70.0 # 屏缘渐隐带宽(布局决策 5/像素级判据②) +const CHIP_SIZE := Vector2(240, 30) # 平移提示 chip(像素级判据③——图区底部居中) +const CHIP_CORNER := 6 # chip 圆角(布局决策 5) +const CHIP_BOTTOM_GAP := 24.0 # chip 距视口底缘(效果图档同族取值) +const CHIP_TEXT := "◄ 拖拽画布 / 滚轮平移 ►" # 14px(FS_HINT 档——布局决策 5) +const EDGE_EPS := 0.5 # 包络边界判定容差(浮点平移累积) + +var _canvas: Control +var _anchor_provider: Callable # () -> Vector2 锚点画布坐标(screen 提供——当前节点/入口组) +var _viewport_size := Vector2.ZERO # 窗口尺寸兜底(setup 注入设计基座——实况窗口以控件 size 为准,布局晚于构造) +var _offset := Vector2.ZERO: # 画布左上角在视口件坐标系的位置(纯呈现态——直驱画布 position) + set(value): + _offset = value + if _canvas != null: + _canvas.position = _offset +var _view_tween: Tween +var _guided_visible := false # 指引件可见态(画布超窗才暗示——浅图无平移余量即隐藏) + + +## 一次交齐注入(code-conventions §1.6 拆分交接:画布宿主、锚点来源、窗口兜底尺寸) +func setup(canvas: Control, anchor_provider: Callable, viewport_size: Vector2) -> void: + _canvas = canvas + _anchor_provider = anchor_provider + _viewport_size = viewport_size + clip_contents = true + add_child(canvas) + _build_guidance() + + +func _notification(what: int) -> void: + # 窗口实况尺寸落定/变化即重锚(首开布局晚于 open_choices 的锚定——隐藏子树容器排序 + # 首个可见帧才传播,4.7 行为;实况窗口以本件 size 为准,落定即收敛锚点) + if what == NOTIFICATION_RESIZED and _canvas != null and _anchor_provider.is_valid(): + reanchor(false) + + +## 锚点跟随(布局决策 5:锚点居中;smooth=停点切换/兑付回图平滑滚动,减动效开=跳终态; +## 重入先杀在飞 tween 再建新——快速连续停点切换防叠加) +func reanchor(smooth: bool) -> void: + if _canvas == null or not _anchor_provider.is_valid(): + return + var target := _clamped_center_offset(_anchor_provider.call()) + _kill_scroll_tween() + if not smooth or ArmedTimer.motion_still(): + _offset = target + _sync_guidance() + return + _view_tween = create_tween() + _view_tween.tween_property(self, "_offset", target, UiKit.motion(SCROLL_DUR)) \ + .set_trans(Tween.TRANS_CUBIC).set_ease(Tween.EASE_OUT) + _sync_guidance() + + +## 手动平移(画布 _gui_input 判定后调用——拖拽沿逐帧/滚轮步进;纯呈现零核心写) +func pan_by(delta: Vector2) -> void: + if _canvas == null: + return + _kill_scroll_tween() + var win := _window_size() + _offset = Vector2( + _axis_pan_offset(_offset.x, delta.x, _canvas.size.x, win.x), + _axis_pan_offset(_offset.y, delta.y, _canvas.size.y, win.y)) + _sync_guidance() + + +## 视口中线的画布坐标(锚点居中判据投影面——浅图=画布中心,深图=夹紧域内当前注视点) +func viewport_center_canvas_pos() -> Vector2: + return _window_size() / 2.0 - _offset + + +## 视口是否钉在画布包络边界(任一超窗轴 offset 顶边——clamp 判据面) +func is_clamped_at_edge() -> bool: + var win := _window_size() + if _canvas == null: + return false + return _axis_at_edge(_offset.x, _canvas.size.x, win.x) \ + or _axis_at_edge(_offset.y, _canvas.size.y, win.y) + + +func _kill_scroll_tween() -> void: + if _view_tween != null and _view_tween.is_valid(): + _view_tween.kill() + _view_tween = null + + +## 单轴锚定 offset:画布不超窗=居中分支(夹紧域退化——布局决策 5); +## 超窗=锚点夹紧在画布包络内,锚点落视口中线(offset = 半窗 − 锚点) +func _axis_center_offset(anchor: float, canvas_len: float, half_win: float) -> float: + if canvas_len <= half_win * 2.0: + return half_win - canvas_len / 2.0 + return half_win - clampf(anchor, half_win, canvas_len - half_win) + + +func _clamped_center_offset(anchor: Vector2) -> Vector2: + var half := _window_size() / 2.0 + return Vector2( + _axis_center_offset(anchor.x, _canvas.size.x, half.x), + _axis_center_offset(anchor.y, _canvas.size.y, half.y)) + + +## 单轴平移收敛:居中轴零平移余量不动;超窗轴 offset 收敛在 [半窗−画布, 0](不出露屏底空档) +func _axis_pan_offset(cur: float, delta: float, canvas_len: float, win_len: float) -> float: + if canvas_len <= win_len: + return cur + return clampf(cur + delta, win_len - canvas_len, 0.0) + + +func _axis_at_edge(offset: float, canvas_len: float, win_len: float) -> bool: + if canvas_len <= win_len: + return false + return offset >= -EDGE_EPS or offset <= win_len - canvas_len + EDGE_EPS + + +## 实况窗口优先(容器布局晚于构造——注入值只兜构造帧数学) +func _window_size() -> Vector2: + if size.x > 0.0 and size.y > 0.0: + return size + return _viewport_size + + +## 指引件(布局决策 5/像素级判据②③):屏缘渐隐带盖在画布之上+图区底部居中提示 chip; +## 可见性=画布超窗(_sync_guidance 随锚定/平移刷新) +func _build_guidance() -> void: + var band_l := TextureRect.new() + band_l.name = "FadeL" + band_l.texture = _fade_gradient(false) + band_l.set_anchors_preset(Control.PRESET_LEFT_WIDE) + band_l.offset_right = FADE_BAND_W + band_l.expand_mode = TextureRect.EXPAND_IGNORE_SIZE + band_l.mouse_filter = Control.MOUSE_FILTER_IGNORE + add_child(band_l) + var band_r := TextureRect.new() + band_r.name = "FadeR" + band_r.texture = _fade_gradient(true) + band_r.set_anchors_preset(Control.PRESET_RIGHT_WIDE) + band_r.offset_left = -FADE_BAND_W + band_r.expand_mode = TextureRect.EXPAND_IGNORE_SIZE + band_r.mouse_filter = Control.MOUSE_FILTER_IGNORE + add_child(band_r) + var chip := PanelContainer.new() + chip.name = "PanHint" + chip.add_theme_stylebox_override("panel", + UiKit.panel_flat(UiKit.HP_BAR_BACK, UiKit.STONE, CHIP_CORNER, 1, Vector4(12, 5, 12, 5))) + chip.set_anchors_preset(Control.PRESET_CENTER_BOTTOM) + chip.offset_left = -CHIP_SIZE.x / 2.0 + chip.offset_right = CHIP_SIZE.x / 2.0 + chip.offset_top = -CHIP_SIZE.y - CHIP_BOTTOM_GAP + chip.offset_bottom = -CHIP_BOTTOM_GAP + chip.custom_minimum_size = CHIP_SIZE + chip.mouse_filter = Control.MOUSE_FILTER_IGNORE + var chip_label := UiKit.label(CHIP_TEXT, UiKit.FS_HINT, false, UiKit.WARM) + chip_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + chip_label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER + chip.add_child(chip_label) + add_child(chip) + _sync_guidance() + + +## 屏缘渐隐渐变(色取屏底同族 BG_ROOT——代拍板:不透明端 0.9 单段衰减) +func _fade_gradient(transparent_at_left: bool) -> GradientTexture2D: + var grad := Gradient.new() + grad.offsets = PackedFloat32Array([0.0, 1.0]) + grad.colors = PackedColorArray([Color(UiKit.BG_ROOT, 0.9), Color(UiKit.BG_ROOT, 0.0)]) + var tex := GradientTexture2D.new() + tex.gradient = grad + tex.fill_from = Vector2(1, 0.5) if transparent_at_left else Vector2(0, 0.5) + tex.fill_to = Vector2(0, 0.5) if transparent_at_left else Vector2(1, 0.5) + tex.width = int(FADE_BAND_W) + return tex + + +func _sync_guidance() -> void: + var win := _window_size() + var over := _canvas != null and (_canvas.size.x > win.x or _canvas.size.y > win.y) + if over == _guided_visible: + return + _guided_visible = over + for nm in ["FadeL", "FadeR", "PanHint"]: + var node := get_node_or_null(NodePath(nm)) as Control + if node != null: + node.visible = over diff --git a/game/presentation/views/level_map_viewport.gd.uid b/game/presentation/views/level_map_viewport.gd.uid new file mode 100644 index 00000000..0e5acf00 --- /dev/null +++ b/game/presentation/views/level_map_viewport.gd.uid @@ -0,0 +1 @@ +uid://dgin6f6214cbg diff --git a/game/probe/level_map_shot_probe.gd b/game/probe/level_map_shot_probe.gd new file mode 100644 index 00000000..0f920158 --- /dev/null +++ b/game/probe/level_map_shot_probe.gd @@ -0,0 +1,91 @@ +# level_map_shot_probe.gd — 关卡画面视口 golden 采集探针(卷轴视口批 golden 扩项) +# 两 Shot 面(2026-11-05 用户裁:只对拍关卡最开始与关卡 Boss 节点,中间过程不对拍): +# lmap_entry.png = 入口停点(关卡最开始——锚点=入口组包围盒中心夹紧) +# lmap_boss.png = 中段停点锚定末列(Boss 紫赤描边列入屏——viewport_center 夹紧右缘) +# 采集确定性 = 固定种子单关直构(LevelRunner.start_level_external defer_entry / resume_state, +# 零 RNG 消耗路径)+ 探针隔离档(无断点、出厂设置 1280×800);运行=窗口化(--headless 禁—— +# 渲染闸机器面吃真实渲染帧)。截图经 user args 目录落盘,基线换代走 golden_baseline_refresh.sh --lmap。 +# 运行:Godot_console.exe --path game res://probe/level_map_shot_probe.tscn -- +extends Node + +const SEED_L3 := 910212 # 深图固定种子(与 level_map_viewport_probe 同源——图确定性承载) + +func _ready() -> void: + var args := OS.get_cmdline_user_args() + var out_dir := args[0] if args.size() > 0 else "user://" + await get_tree().process_frame + var host := Control.new() + add_child(host) + host.size = get_viewport().get_visible_rect().size + var screen = load("res://presentation/views/level_map_screen.gd").new() + screen.set_anchors_preset(Control.PRESET_FULL_RECT) + host.add_child(screen) + for i in range(6): + await get_tree().process_frame # 布局收敛(视口实况尺寸落定→resize 重锚) + # Shot①:入口停点(关卡最开始) + var runner = _make_runner(true, -1) + screen.open_choices(_cand_nodes(runner), runner) + await _settle() + _shot(out_dir, "lmap_entry.png") + # Shot②:中段停点锚定末列(布局 x 最深的有出边节点——id 不按深度排,锚定后 Boss 列入屏) + var boss_stop := _deepest_with_outs(screen, runner) + runner = _make_runner(false, boss_stop) + screen.open_choices(_cand_nodes(runner), runner) + await _settle() + _shot(out_dir, "lmap_boss.png") + print("[level_map_shot_probe] PASS — 两 Shot 落盘(entry+boss → %s)" % out_dir) + get_tree().quit(0) + + +func _settle() -> void: + for i in range(8): + await get_tree().process_frame + await get_tree().create_timer(0.5).timeout # 平滑滚动终态余量(reanchor tween 0.3s) + + +func _make_runner(entry: bool, node_id: int): + var rng = load("res://core/rng_service.gd").new() + rng.new_adventure(SEED_L3) + var runner = load("res://core/level_runner.gd").new(get_node("/root/ConfigDB"), rng) + var ok: bool + if entry: + ok = runner.start_level_external(3, Callable(runner, "rest_heal_amount"), true) + else: + ok = runner.start_level_external(3, Callable(runner, "rest_heal_amount"), + false, {"node_id": node_id, "visited_ids": [node_id]}) + if not ok: + push_error("[level_map_shot_probe] 状态注入失败(entry=%s node=%d)" % [str(entry), node_id]) + get_tree().quit(1) + return runner + + +func _cand_nodes(runner) -> Array: + var ids: Array = runner.next_candidate_ids() + var out: Array = [] + for node in runner.nodes(): + if ids.has(runner.node_id_of(node)): + out.append(node) + return out + + +func _deepest_with_outs(screen, runner) -> int: + var pos: Dictionary = screen.layout_probe(runner.nodes()) + var best := -1 + var best_x := -INF + for node in runner.nodes(): + if runner.node_out_ids(node).is_empty(): + continue + var x: float = pos[node].x + if x > best_x: + best_x = x + best = runner.node_id_of(node) + return best + + +func _shot(out_dir: String, fname: String) -> void: + DirAccess.make_dir_recursive_absolute(out_dir) # 落盘目录自建(save_png 对缺目录静默失败——刷新链不预建) + var img := get_viewport().get_texture().get_image() + var err := img.save_png(out_dir.path_join(fname)) + if err != OK: + push_error("[level_map_shot_probe] 截图落盘失败 %s(err=%d)" % [fname, err]) + get_tree().quit(1) diff --git a/game/probe/level_map_shot_probe.tscn b/game/probe/level_map_shot_probe.tscn new file mode 100644 index 00000000..d34d0546 --- /dev/null +++ b/game/probe/level_map_shot_probe.tscn @@ -0,0 +1,6 @@ +[gd_scene load_steps=2 format=3] + +[ext_resource type="Script" path="res://probe/level_map_shot_probe.gd" id="1"] + +[node name="LevelMapShotProbe" type="Node"] +script = ExtResource("1") diff --git a/game/probe/level_map_viewport_probe.gd b/game/probe/level_map_viewport_probe.gd new file mode 100644 index 00000000..6c1ede3a --- /dev/null +++ b/game/probe/level_map_viewport_probe.gd @@ -0,0 +1,389 @@ +# level_map_viewport_probe.gd — 卷轴视口行为探针(卷轴视口批 R1–R8 验收载体) +# 判的是 level-map.md 布局决策 5 的视口行为四条:浅图整幅居中无裁切(R1)、停点切换/兑付回图 +# 当前节点居中锚定——平滑滚动与减动效瞬切(R2/R7)、拖拽/滚轮手动平移与 clamp 包络—— +# 首末列可达(R3/R4/R5)、拖拽不触发选路提交(R6)、指引件可见性与几何(R8,像素级判据②③)。 +# 真实入口 = 合成拖拽沿/滚轮沿/点击沿经 push_input 进引擎输入管线打在真实画布 _gui_input 上 +# (probe_ui_util.push_drag_at/push_wheel_at/push_click_at);状态注入 = 直调核心层写口构造 +# 单关停点(LevelRunner.start_level_external——defer_entry=入口停点 / resume_state=断点续玩 +# 重建到任意停点,零 RNG 消耗;test-conventions §5.4 自动化测试直构口径),零 main 冒险流程 +# 依赖:真实屏 = LevelMapScreen 实例 + 真实单关 runner。 +# 运行:Godot_console.exe --headless --path . res://probe/level_map_viewport_probe.tscn +extends Node + +const ProbeUiUtil := preload("res://probe/probe_ui_util.gd") +const ViewportScript := preload("res://presentation/views/level_map_viewport.gd") + +const SEED_L1 := 910211 # 浅图停点种子(L1 单链——画布 846.5×256 全幅入窗) +const SEED_L3 := 910212 # 深图停点种子(L3 图——画布超 1280 窗宽,滚动视口生效面) + +var _problems: Array[String] = [] +var _checks := 0 +var _runner # 当前停点的单关 runner(open_choices 驱动随记——查证面共用) + + +func _check(desc: String, ok: bool) -> void: + _checks += 1 + if not ok: + _problems.append(desc) + + +func _ready() -> void: + await get_tree().process_frame + await get_tree().process_frame + var host := Control.new() + add_child(host) + host.size = get_viewport().get_visible_rect().size # 显式置底座(父非 Control 锚点不解析) + var screen = load("res://presentation/views/level_map_screen.gd").new() + screen.set_anchors_preset(Control.PRESET_FULL_RECT) + host.add_child(screen) + await get_tree().process_frame + await get_tree().process_frame # 布局收敛先行(视口实况窗口尺寸就位后再开停点——锚点数学以实况窗口为准) + + var vp = _find_viewport(screen) + if vp == null: + _problems.append("卷轴视口承载件未挂入关卡屏(LevelMapViewport 实例缺位)") + _finish() + return + + await _probe_shallow_no_crop(screen, vp) + await _probe_deep_entry_clamp(screen, vp) + await _probe_wheel_pan(screen, vp) + await _probe_drag_pan(screen, vp) + await _probe_clamp_bounds_and_columns(screen, vp) + await _probe_drag_no_submit(screen, vp) + await _probe_follow_anchor(screen, vp) + await _probe_smooth_scroll_reduced_motion(screen, vp) + await _probe_guidance_chrome(screen, vp) + await _probe_shallow_guidance_hidden(screen, vp) + _finish() + + +## R1 浅图整幅居中无裁切+画布入视口件承载(画布=视口首子件——setup 注入序) +func _probe_shallow_no_crop(screen, vp) -> void: + _runner = _make_runner_entry(1, SEED_L1) + screen.open_choices(_cand_nodes(_runner), _runner) + await get_tree().process_frame + await get_tree().process_frame + var canvas: Control = vp.get_child(0) as Control + if canvas == null: + _problems.append("R1 画布实例缺位(视口首子件非 Control)") + return + _check("R1 浅图画布全幅入窗(canvas=%s window=%s)〔卷轴批 V1〕" % [canvas.size, vp.size], + canvas.size.x <= vp.size.x + 0.5 and canvas.size.y <= vp.size.y + 0.5) + var want: Vector2 = (vp.size - canvas.size) / 2.0 + _check("R1 居中分支 offset 定位(want=%s got=%s)〔卷轴批 V1〕" % [want, canvas.position], + canvas.position.distance_to(want) <= 0.5) + _check("R1 画布矩形在窗内无裁切〔卷轴批 V1〕", vp.get_global_rect().encloses(canvas.get_global_rect())) + + +## R5 前置+深图入口停点:锚点=入口组包围盒中心夹紧(spec :101——多入口并列取包围盒居中) +func _probe_deep_entry_clamp(screen, vp) -> void: + _runner = _make_runner_entry(3, SEED_L3) + screen.open_choices(_cand_nodes(_runner), _runner) + await get_tree().process_frame + await get_tree().process_frame + var center: Vector2 = vp.viewport_center_canvas_pos() + var half: float = vp.size.x / 2.0 + var canvas_len: float = _canvas_len(screen) + _check("R5 入口锚定后视口中线在夹紧域内(center.x=%.1f ∈ [%.1f, %.1f])〔卷轴批 V5〕" % + [center.x, half, canvas_len - half], + center.x >= half - 0.5 and center.x <= canvas_len - half + 0.5) + # 入口停点候选 = 入口节点——包围盒中心夹紧即锚点(本档图入口分布跨图,中心落夹紧域) + var nodes: Array = _runner.nodes() + var pos: Dictionary = screen.layout_probe(nodes) + var ids: Array = _runner.next_candidate_ids() + var lo := INF + var hi := -INF + for node in nodes: + if ids.has(_runner.node_id_of(node)): + lo = minf(lo, pos[node].x) + hi = maxf(hi, pos[node].x) + var want: float = clampf((lo + hi) / 2.0, half, canvas_len - half) + _check("R5 入口组包围盒中心=锚点(bbox=%.1f center=%.1f want=%.1f)〔卷轴批 V5〕" % + [(lo + hi) / 2.0, center.x, want], absf(center.x - want) <= 0.5) + + +## R4 滚轮横滚移动视口(下=向右前进向——代拍板映射;窗内平移不钉边界,与 R5 两极 true 构成翻转) +func _probe_wheel_pan(screen, vp) -> void: + var before: float = vp.viewport_center_canvas_pos().x + var pos: Vector2 = vp.get_global_rect().get_center() + ProbeUiUtil.push_wheel_at(self, pos, true) + await get_tree().process_frame + await get_tree().process_frame + var after: float = vp.viewport_center_canvas_pos().x + _check("R4 滚轮下单档平移 +60(before=%.1f after=%.1f)〔卷轴批 V4〕" % [before, after], + absf(after - before - 60.0) <= 0.5) + _check("R4 窗内平移不钉边界(is_clamped_at_edge=false)〔卷轴批 V4〕", not vp.is_clamped_at_edge()) + ProbeUiUtil.push_wheel_at(self, pos, false) + await get_tree().process_frame + await get_tree().process_frame + _check("R4 滚轮上回移 −60〔卷轴批 V4〕", + absf(vp.viewport_center_canvas_pos().x - before) <= 0.5) + + +## R3 拖拽平移移动视口(窗内位移量=拖拽位移,无 clamp 干扰) +func _probe_drag_pan(screen, vp) -> void: + # 先滚入窗内区(两档 +120——避开左包络边界的 clamp 吸收),再拖拽核位移 + var pos: Vector2 = vp.get_global_rect().get_center() + ProbeUiUtil.push_wheel_at(self, pos, true) + ProbeUiUtil.push_wheel_at(self, pos, true) + await get_tree().process_frame + await get_tree().process_frame + var before: float = vp.viewport_center_canvas_pos().x + ProbeUiUtil.push_drag_at(self, pos, Vector2(-40, 0)) + await get_tree().process_frame + await get_tree().process_frame + var after: float = vp.viewport_center_canvas_pos().x + _check("R3 拖拽左移 −40 平移量逐位一致(画布随手——视线 +40,before=%.1f after=%.1f)〔卷轴批 V3〕" % [before, after], + absf(after - before - 40.0) <= 0.5) + # 复位到左包络边界,给后续探针稳定起点 + ProbeUiUtil.push_drag_at(self, pos, Vector2(2000, 0)) + await get_tree().process_frame + await get_tree().process_frame + + +## R5 clamp 边界与首末列可达(拖拽两极) +func _probe_clamp_bounds_and_columns(screen, vp) -> void: + var nodes: Array = _runner.nodes() + var pos: Dictionary = screen.layout_probe(nodes) + var first_node := _extreme_node(pos, nodes, true) + var last_node := _extreme_node(pos, nodes, false) + var vp_rect: Rect2 = vp.get_global_rect() + # 右极:拖拽超包络 → 视口钉左缘,首列(深度 0)入屏 + ProbeUiUtil.push_drag_at(self, vp_rect.get_center(), Vector2(2000, 0)) + await get_tree().process_frame + await get_tree().process_frame + var center: float = vp.viewport_center_canvas_pos().x + var half: float = vp.size.x / 2.0 + _check("R5 拖拽超包络收敛夹紧域(center.x=%.1f)〔卷轴批 V5〕" % center, + center >= half - 0.5 and center <= _canvas_len(screen) - half + 0.5) + _check("R5 右极视口钉边界(is_clamped_at_edge=true)〔卷轴批 V5〕", vp.is_clamped_at_edge()) + _check("R5 首列(深度 0)中心入屏〔卷轴批 V5〕", + vp_rect.has_point(_node_screen_pos(screen, pos, first_node))) + # 左极:反向超包络 → 视口钉右缘,末列(含 Boss 紫赤描边列)入屏 + ProbeUiUtil.push_drag_at(self, vp_rect.get_center(), Vector2(-2000, 0)) + await get_tree().process_frame + await get_tree().process_frame + _check("R5 左极视口钉边界(is_clamped_at_edge=true)〔卷轴批 V5〕", vp.is_clamped_at_edge()) + _check("R5 末列中心入屏(拖拽两极可达——布局决策 5)〔卷轴批 V5〕", + vp_rect.has_point(_node_screen_pos(screen, pos, last_node))) + + +## R6 拖拽不触发选路提交;阈值另一侧完整点击仍提交(提交语义零改动) +func _probe_drag_no_submit(screen, vp) -> void: + # 重建到中段可居中停点(候选=停点出边——候选节点居屏中可拖可达) + var stops := _centerable_stops(screen, vp) + if stops.size() < 3: + _problems.append("R6 可居中停点不足(=%d)" % stops.size()) + return + _runner = _make_runner_at_stop(3, SEED_L3, _runner.node_id_of(stops[0])) + screen.open_choices(_cand_nodes(_runner), _runner) + await get_tree().process_frame + await get_tree().process_frame + var nodes: Array = _runner.nodes() + var cands: Array = _runner.next_candidate_ids() + var target := {} + for node in nodes: + if cands.has(_runner.node_id_of(node)): + target = node + break + if target.is_empty(): + _problems.append("R6 候选节点缺位(候选=%s)" % str(cands)) + return + var gpos: Vector2 = _node_screen_pos(screen, screen.layout_probe(nodes), target) + var submitted: Array = [] + var on_route := func(nid: int) -> void: submitted.append(nid) + screen.route_chosen.connect(on_route) + ProbeUiUtil.push_drag_at(self, gpos, Vector2(120, 24)) + await get_tree().process_frame + await get_tree().process_frame + _check("R6 拖拽沿起止候选上方零提交(submitted=%s)〔卷轴批 V6〕" % str(submitted), submitted.is_empty()) + _check("R6 拖拽后本屏未关〔卷轴批 V6〕", screen.visible) + var gpos2: Vector2 = _node_screen_pos(screen, screen.layout_probe(nodes), target) # 拖拽平移后重算投影(画布随视口移动) + ProbeUiUtil.push_click_at(self, gpos2) + await get_tree().process_frame + await get_tree().process_frame + _check("R6 完整点击(未超阈值)仍提交选路——阈值区分另一侧〔卷轴批 V6〕", submitted.size() == 1) + screen.route_chosen.disconnect(on_route) + + +## R2 停点切换后当前节点居中(视口中线投影 x=640±2——视口满屏宽即屏中线) +func _probe_follow_anchor(screen, vp) -> void: + var stops := _centerable_stops(screen, vp) + if stops.size() < 3: + _problems.append("R2 可居中停点不足(=%d)" % stops.size()) + return + _runner = _make_runner_at_stop(3, SEED_L3, _runner.node_id_of(stops[1])) + screen.open_choices(_cand_nodes(_runner), _runner) + await get_tree().create_timer(0.6).timeout # 平滑滚动终态(0.3s 单段缓动+余量) + var sx: float = _node_screen_pos(screen, screen.layout_probe(_runner.nodes()), stops[1]).x + var want: float = vp.get_global_rect().get_center().x + _check("R2 停点切换后当前节点投影=视口中线(got=%.1f want=%.1f±2)〔卷轴批 V2〕" % [sx, want], + absf(sx - want) <= 2.0) + + +## R7 平滑滚动有 tween 中间帧;减动效开=位置即达(画布坐标域判定) +func _probe_smooth_scroll_reduced_motion(screen, vp) -> void: + var saved_scale: float = UiKit.motion_scale + UiKit.motion_scale = 1.0 + var stops := _centerable_stops(screen, vp) + if stops.size() < 3: + _problems.append("R7 可居中停点不足(=%d)" % stops.size()) + return + # 起点:锚在停点 A(画面已停稳) + _runner = _make_runner_at_stop(3, SEED_L3, _runner.node_id_of(stops[0])) + screen.open_choices(_cand_nodes(_runner), _runner) + await get_tree().create_timer(0.6).timeout + var start: float = vp.viewport_center_canvas_pos().x + # 停点切换 A→B(屏幕保持可见=兑付回图同路径)——平滑滚动 + var target_b: float = screen.layout_probe(_runner.nodes())[stops[1]].x + _runner = _make_runner_at_stop(3, SEED_L3, _runner.node_id_of(stops[1])) + screen.open_choices(_cand_nodes(_runner), _runner) + await get_tree().create_timer(0.05).timeout # 中间帧采样(300ms 单段缓动早段) + var mid: float = vp.viewport_center_canvas_pos().x + var total := absf(target_b - start) + _check("R7 平滑滚动中间帧在途中(start=%.1f mid=%.1f target=%.1f)〔卷轴批 V7〕" % [start, mid, target_b], + total > 4.0 and absf(mid - start) > 0.5 and absf(mid - target_b) > 0.5 + and (mid - start) * (target_b - start) > 0.0) # 同向在途 + await get_tree().create_timer(0.5).timeout + _check("R7 平滑滚动终态到位〔卷轴批 V7〕", + absf(vp.viewport_center_canvas_pos().x - target_b) <= 2.0) + # 减动效置位(UiKit.motion 通道单点——设置面开关写同一值):停点切换 B→C 位置即达 + UiKit.motion_scale = 0.5 + var target_c: float = screen.layout_probe(_runner.nodes())[stops[2]].x + _runner = _make_runner_at_stop(3, SEED_L3, _runner.node_id_of(stops[2])) + screen.open_choices(_cand_nodes(_runner), _runner) + await get_tree().process_frame + await get_tree().process_frame + _check("R7 减动效开=位置即达(got=%.1f target=%.1f)〔卷轴批 V7〕" % + [vp.viewport_center_canvas_pos().x, target_c], + absf(vp.viewport_center_canvas_pos().x - target_c) <= 2.0) + UiKit.motion_scale = saved_scale + + +## R8 指引件可见性与几何(深图超窗——像素级判据②③) +func _probe_guidance_chrome(screen, vp) -> void: + var band_l: Control = vp.get_node_or_null(NodePath("FadeL")) + var band_r: Control = vp.get_node_or_null(NodePath("FadeR")) + var chip: Control = vp.get_node_or_null(NodePath("PanHint")) + if band_l == null or band_r == null or chip == null: + _problems.append("R8 指引件缺位(FadeL/FadeR/PanHint)〔卷轴批 V8〕") + return + _check("R8 深图渐隐带可见〔卷轴批 V8〕", band_l.visible and band_r.visible) + _check("R8 渐隐带宽 70px(L=%.1f R=%.1f)〔卷轴批 V8〕" % [band_l.size.x, band_r.size.x], + absf(band_l.size.x - 70.0) <= 0.5 and absf(band_r.size.x - 70.0) <= 0.5) + _check("R8 平移提示 chip 可见且 240×30(size=%s)〔卷轴批 V8〕" % chip.size, + chip.visible and absf(chip.size.x - 240.0) <= 0.5 and absf(chip.size.y - 30.0) <= 0.5) + _check("R8 chip 图区底部居中(水平中线对窗中线±2)〔卷轴批 V8〕", + absf(chip.get_global_rect().get_center().x - vp.get_global_rect().get_center().x) <= 2.0) + + +## R8 浅图指引件隐藏(无暗示必要) +func _probe_shallow_guidance_hidden(screen, vp) -> void: + _runner = _make_runner_entry(1, SEED_L1) + screen.open_choices(_cand_nodes(_runner), _runner) + await get_tree().process_frame + await get_tree().process_frame + var band_l: Control = vp.get_node_or_null(NodePath("FadeL")) + var chip: Control = vp.get_node_or_null(NodePath("PanHint")) + _check("R8 浅图画布不超窗指引件隐藏〔卷轴批 V8〕", + band_l != null and not band_l.visible and chip != null and not chip.visible) + + +# ---- 驱动与查证工具(状态注入面——直调核心层写口) ---- + +## 入口停点 runner(defer_entry——选路态挂起、候选=入口节点) +func _make_runner_entry(level_index: int, seed_val: int): + var rng = load("res://core/rng_service.gd").new() + rng.new_adventure(seed_val) + var runner = load("res://core/level_runner.gd").new(get_node("/root/ConfigDB"), rng) + if not runner.start_level_external(level_index, Callable(runner, "rest_heal_amount"), true): + _problems.append("状态注入失败:start_level_external(%d, %d) 未受理" % [level_index, seed_val]) + return runner + + +## 中段停点 runner(断点续玩重建——图由种子+参数档重生成同图,零 RNG 消耗) +func _make_runner_at_stop(level_index: int, seed_val: int, node_id: int): + var rng = load("res://core/rng_service.gd").new() + rng.new_adventure(seed_val) + var runner = load("res://core/level_runner.gd").new(get_node("/root/ConfigDB"), rng) + if not runner.start_level_external(level_index, Callable(runner, "rest_heal_amount"), + false, {"node_id": node_id, "visited_ids": [node_id]}): + _problems.append("状态注入失败:断点停点 %d 未受理" % node_id) + return runner + + +func _cand_nodes(runner) -> Array: + var ids: Array = runner.next_candidate_ids() + var out: Array = [] + for node in runner.nodes(): + if ids.has(runner.node_id_of(node)): + out.append(node) + return out + + +## 深图可居中停点选取(有出边且画布 x 落在夹紧域内的节点——锚定不贴边、当前节点投影可居中; +## 按 x 降序去重取第 idx 个——同层同 x 只计一个,层距 122 保证停点间滚动位移充分) +func _centerable_stops(screen, vp) -> Array: + var nodes: Array = _runner.nodes() + var pos: Dictionary = screen.layout_probe(nodes) + var half: float = vp.size.x / 2.0 + var canvas_len: float = _canvas_len(screen) + var by_x_desc: Array = nodes.duplicate() + by_x_desc.sort_custom(func(a, b) -> bool: return pos[a].x > pos[b].x) + var picked: Array = [] + var seen_x := {} + for node in by_x_desc: + if _runner.node_out_ids(node).is_empty(): + continue + var x: float = pos[node].x + if x < half + 20.0 or x > canvas_len - half - 20.0 or seen_x.has(x): + continue + seen_x[x] = true + picked.append(node) + return picked + + +func _find_viewport(screen) -> Control: + return _find_by_script(screen, ViewportScript) + + +func _find_by_script(root: Node, script: Script) -> Control: + if root.get_script() == script: + return root as Control + for child in root.get_children(): + var found := _find_by_script(child, script) + if found != null: + return found + return null + + +func _canvas_len(screen) -> float: + return screen.canvas_probe()["min_size"].x # 画布包络宽(open_choices 拟合值——画布尺寸即它) + + +func _node_screen_pos(screen, pos: Dictionary, node: Dictionary) -> Vector2: + return screen.canvas_probe()["origin"] + pos[node] + + +func _extreme_node(pos: Dictionary, nodes: Array, first: bool) -> Dictionary: + var best := {} + var best_x := INF if first else -INF + for node in nodes: + var x: float = pos[node].x + if (first and x < best_x) or (not first and x > best_x): + best_x = x + best = node + return best + + +func _finish() -> void: + if _checks == 0: + _problems.append("零断言执行(断言数下限闸——三硬闸③)") + if _problems.is_empty(): + print("[level_map_viewport_probe] PASS — 卷轴视口行为断言全绿(%d 项)" % _checks) + get_tree().quit(0) + else: + for p in _problems: + printerr("[level_map_viewport_probe] FAIL — " + p) + get_tree().quit(1) diff --git a/game/probe/level_map_viewport_probe.gd.uid b/game/probe/level_map_viewport_probe.gd.uid new file mode 100644 index 00000000..06779e47 --- /dev/null +++ b/game/probe/level_map_viewport_probe.gd.uid @@ -0,0 +1 @@ +uid://cprbakccv06re diff --git a/game/probe/level_map_viewport_probe.tscn b/game/probe/level_map_viewport_probe.tscn new file mode 100644 index 00000000..5a3ee798 --- /dev/null +++ b/game/probe/level_map_viewport_probe.tscn @@ -0,0 +1,6 @@ +[gd_scene load_steps=2 format=3] + +[ext_resource type="Script" path="res://probe/level_map_viewport_probe.gd" id="1"] + +[node name="LevelMapViewportProbe" type="Node"] +script = ExtResource("1") diff --git a/game/probe/probe_ui_util.gd b/game/probe/probe_ui_util.gd index 2a33d2b3..d64ea998 100644 --- a/game/probe/probe_ui_util.gd +++ b/game/probe/probe_ui_util.gd @@ -141,6 +141,42 @@ static func push_click_at(main, pos: Vector2) -> void: main.get_viewport().push_input(up, true) +# 真输入拖拽沿(press→motion×steps→release 成对经 push_input 进引擎输入管线——卷轴视口批 +# R3/R5/R6 驱动沿;沿 push_click_at 同式。relative 按步显式置值——合成事件的位移量由事件 +# 自身承载,被测面按 relative 逐帧平移) +static func push_drag_at(main, pos: Vector2, delta: Vector2, steps: int = 4) -> void: + var down := InputEventMouseButton.new() + down.position = pos + down.global_position = pos + down.button_index = MOUSE_BUTTON_LEFT + down.pressed = true + main.get_viewport().push_input(down, true) + for i in range(1, steps + 1): + var cur := pos + delta * float(i) / float(steps) + var mv := InputEventMouseMotion.new() + mv.position = cur + mv.global_position = cur + mv.relative = delta / float(steps) + main.get_viewport().push_input(mv, true) + var up := InputEventMouseButton.new() + up.position = pos + delta + up.global_position = pos + delta + up.button_index = MOUSE_BUTTON_LEFT + up.pressed = false + main.get_viewport().push_input(up, true) + + +# 真输入滚轮沿(单档滚轮事件经 push_input——卷轴视口批 R4 驱动沿;方向映射在被测面, +# 本工具只发原始滚轮档) +static func push_wheel_at(main, pos: Vector2, wheel_down: bool = true) -> void: + var ev := InputEventMouseButton.new() + ev.position = pos + ev.global_position = pos + ev.button_index = MOUSE_BUTTON_WHEEL_DOWN if wheel_down else MOUSE_BUTTON_WHEEL_UP + ev.pressed = true + main.get_viewport().push_input(ev, true) + + # 营地经进档开层(卡② mn9 新路径——campbtn 已撤;槽模板注入隔离命名空间,防碰真实槽 # user://save_slot_N.json——真实档红线)。创建=激活=暂行开营地(卡③ Q3 乙落点); # 驱动后写盘目标=隔离槽文件(持久化断言读 user://_slot_1.json)。返回 false=新档钮缺失(驱动中断)。 diff --git a/game/probe/ui_input_probe.gd b/game/probe/ui_input_probe.gd index 65f89a51..e3ecde7c 100644 --- a/game/probe/ui_input_probe.gd +++ b/game/probe/ui_input_probe.gd @@ -87,6 +87,14 @@ func _ready() -> void: evf.position = fog_pos evf.global_position = fog_pos main.get_viewport().push_input(evf, true) + # 完整点击=press+release 成对(卷轴视口批阈值语义:拖距跟踪器由按下起武装, + # 单 press 沿会留 armed 态污染后续 motion 判定——判据语义不变:雾下点击零提交) + var evf2 := InputEventMouseButton.new() + evf2.button_index = MOUSE_BUTTON_LEFT + evf2.pressed = false + evf2.position = fog_pos + evf2.global_position = fog_pos + main.get_viewport().push_input(evf2, true) await get_tree().process_frame await get_tree().process_frame ro.route_chosen.disconnect(on_route) @@ -114,6 +122,14 @@ func _ready() -> void: evc.position = gpos evc.global_position = gpos main.get_viewport().push_input(evc, true) + # 完整点击=press+release 成对(卷轴视口批阈值语义:提交沿=未超阈值的抬杆—— + # 判据语义不变:图上真实点击提交选路;单 press 沿不再是提交沿) + var evr := InputEventMouseButton.new() + evr.button_index = MOUSE_BUTTON_LEFT + evr.pressed = false + evr.position = gpos + evr.global_position = gpos + main.get_viewport().push_input(evr, true) await get_tree().process_frame await get_tree().process_frame if main.ui.modals.run.level_screen.visible or main.run_scope.runner.awaiting_choice(): diff --git a/game/tests/golden/README.md b/game/tests/golden/README.md index 4dac9021..9936ab1d 100644 --- a/game/tests/golden/README.md +++ b/game/tests/golden/README.md @@ -4,11 +4,15 @@ `windows/` 与 `mac/` 子目录各持一套,平台内部照常严格比对,跨平台不互比; 平台选择实现单点 = `tools/gate_platform.sh` 的 `gate_platform_id`,比较器 `tools/golden_compare.py` 自动拼平台子目录)。本目录现持:`windows/`(棋盘三图 `board_L1_dungeon` / `board_L2_underdark` / -`board_L3_deep_underdark`,裁剪区 900×760 + 启动态菜单 `menu_boot`,全屏)、 -`level_map`(关卡画面全屏,历史基线留根——现行闸件未对拍它)、 +`board_L3_deep_underdark`,裁剪区 900×760 + 启动态菜单 `menu_boot`,全屏 + 关卡视口两 Shot +`lmap_entry` / `lmap_boss`——2026-11-05 用户裁两 Shot 面:只对拍关卡最开始(入口停点)与 +关卡 Boss 节点(末列锚定),中间过程不对拍)、 +`level_map`(关卡画面全屏,历史基线留根——现行闸件未对拍它,由两 Shot 面接替)、 `palette_baseline/`(色板基线,纯文件内容比对与平台无关)。 比对判据与工具见 `tools/golden_compare.py` -(逐像素灰度差 >16 的像素占比 ≤2%)。 +(逐像素灰度差 >16 的像素占比 ≤2%)。两 Shot 基线采集=`res://probe/level_map_shot_probe.tscn` +(固定种子单关直构——确定性采集),换代入口=`tools/golden_baseline_refresh.sh --lmap`; +他平台(mac)的关卡视口基线尚未冻结——渲染闸对本平台缺基线记 SKIP 不判红,冻结义务归批末呈报。 **未建锚面备案**:营地/设置/战斗三屏与战末结算/三选一/图例/调试浮窗四浮层无改前 golden 锚;这些呈现面的等价证据承载=现役探针/单元测试断言面+实机目检(体验验证清单 ㊵ 节)。 diff --git a/game/tests/golden/windows/lmap_boss.png b/game/tests/golden/windows/lmap_boss.png new file mode 100644 index 00000000..90d14187 Binary files /dev/null and b/game/tests/golden/windows/lmap_boss.png differ diff --git a/game/tests/golden/windows/lmap_entry.png b/game/tests/golden/windows/lmap_entry.png new file mode 100644 index 00000000..d710cd53 Binary files /dev/null and b/game/tests/golden/windows/lmap_entry.png differ diff --git a/playnote.md b/playnote.md index 9f10c73c..764d2fe5 100644 --- a/playnote.md +++ b/playnote.md @@ -1,9 +1,15 @@ # 试玩问题记录 +- 一开始不能让角色的成长系数太高.比如在没经过升级之前,你的成长系数是跟不上关卡怪物难度提升的;但是后续能力解锁之后,成长系数才能匹配得上 + - UI优化,第二第三关的棋盘看着太累了;不知道是因为亮度还是对比度的原因,看着非常费劲,非常不舒服; - 第一关的第二个节点数值有问题 +- 现在的立绘太丑了,像素化的粒度太低了,导致根本看不清立绘; 看是否可以改回用较为高清的图片? +还有一个问题是大小的问题.我们或许可以调整一下不同怪物的大小,因为现在地精的图片看起来比熊地精还要大,这并不合理; + + - 修改地图的奖励类型 战斗节点增加不同的地图类型,比如有些图踩雷受到更多的伤害,有些图标雷可以造成更多的伤害,有些图伤害会减半需要凑更多清图奖励(这些都是暂时的设定) @@ -16,27 +22,10 @@ - 3选1奖励中有一个refresh,任何说明都没有,就一个refresh, 明显的一个问题物品,但是却没有处理; +- "奇物门票" 这他妈是专场门票,而且你他妈应该有一个更好的名称,而不是直接"门票"; 我真是操了你妈; +而且里面的解释非常弱智 + - 确认选择按钮旁边有一个"免费选择,点刷新不消耗AP" 我真是操你妈了,要你这种傻逼解释; 你他妈这是游戏的UI吗?设计像你妈坨屎; -- 封印回响这个专长描述也是错误的,怎么是本轮AC+1呢 - -## 2026-11-04 营地相关问题记录 - -1. 背景图存在着很多问题上半部分的内容太长了,我们应该预留更多的地面细节给我们的可点击的地方; -但是现在远景占据了太多的位置; -远景应该压缩到只有1/3的高度(现在占据了2/3) -近景的地面太朴素且太暗了; - -远景压缩之后,围墙,门,告示板记得也同步上调; - -2. 围墙贴图没对齐就算了, 过于矮? 高度应该>门 -3. 物品太靠下了; 整体可以往上调, 让最下面的宝箱和最底下保持着一个32px的距离 -4. 钥石说好是一条顶栏的,为什么现在是搞了一个类似浮动框的形式展示? - -为什么我设计定义的东西你没有进行实现,而且还自作主张地做了一个没有经过我同意的展示方式 -5. 点击大门开始新冒险,直接回到了开始菜单界面; -你是否真的做过完整的测试? -我这句话问了都白问,因为你做了完整的测试的话,就不可能会这么简单的逻辑闭环都没有完成 -6. 开始新冒险的是和否反过来了.否在左边,是在右边; 完全的反直觉; 完全的弱智 - +- 封印回响这个专长描述也是错误的,怎么是本轮AC+1呢 \ No newline at end of file diff --git a/tools/golden_baseline_refresh.sh b/tools/golden_baseline_refresh.sh index 422d5a4f..7aba1e93 100755 --- a/tools/golden_baseline_refresh.sh +++ b/tools/golden_baseline_refresh.sh @@ -5,6 +5,8 @@ # 缺位时先拷主区素材+导入边车再跑)。 # 模式: # --menu:shot_probe 启动态菜单整屏帧 → game/tests/golden/menu_boot.png。 +# --lmap:level_map_shot_probe 关卡视口两 Shot(入口停点+Boss 列锚定——2026-11-05 用户裁 +# 两 Shot 面)→ game/tests/golden//lmap_entry.png + lmap_boss.png。 # 全屏基线可行性前置已验证:菜单连续帧差 0.0000%/max_diff=0(光柱为 # 程序生成静态贴片、零动画着色器,全屏基线不抖动);采集确定性=探针隔离档(无断点+出厂 # 设置 1280×800),与 render_gate 的 shot_menu 对比段同源同帧。 @@ -21,6 +23,7 @@ MODE="board" for a in "$@"; do case "$a" in --menu) MODE="menu" ;; + --lmap) MODE="lmap" ;; *) if [ -z "$GAME" ]; then GAME="$a"; fi ;; esac done @@ -53,6 +56,34 @@ if [ "$MODE" = "menu" ]; then exit 0 fi +if [ "$MODE" = "lmap" ]; then + # 关卡视口两 Shot 面(卷轴视口批·2026-11-05 用户裁:只对拍关卡最开始与关卡 Boss 节点, + # 中间过程不对拍)——入口停点 + 末列 Boss 列锚定,两基线各存平台子目录。 + for a in assets/fonts/ui_font_fusion_pixel_zh_hans.ttf \ + assets/art/ui/button_stone.png \ + assets/art/ui/node_battle.png assets/art/ui/node_reward.png \ + assets/art/ui/node_rest.png assets/art/ui/node_boss.png \ + assets/art/ui/node_shop.png; do + if [ ! -f "$GAME/$a" ]; then + echo "[golden_refresh] 拒跑 — 关卡屏素材缺位: ${a}(S3 停机点口径:基线禁锚字形回退态,先补素材+导入边车)" + exit 1 + fi + done + gate_run_timeout 420 "$TMP/run.log" "$G" --path "$GAME_WIN" --quit-after 3000 res://probe/level_map_shot_probe.tscn -- "$TMP/shots" + st=$? + grep -E "\[level_map_shot_probe\]" "$TMP/run.log" | tail -3 + # 判定锚工件(同 menu——quit-after 与 quit(0) 竞速的 124 非真红):PASS 签名 + 双图非零。 + if ! grep -q "PASS — 两 Shot 落盘" "$TMP/run.log" || [ ! -s "$TMP/shots/lmap_entry.png" ] || [ ! -s "$TMP/shots/lmap_boss.png" ]; then + echo "[golden_refresh] FAIL — 关卡视口截图缺失或探针自检红(st=$st,见上)" + exit 1 + fi + mkdir -p "$REPO/game/tests/golden/$PLAT" + cp "$TMP/shots/lmap_entry.png" "$REPO/game/tests/golden/$PLAT/lmap_entry.png" || exit 1 + cp "$TMP/shots/lmap_boss.png" "$REPO/game/tests/golden/$PLAT/lmap_boss.png" || exit 1 + echo "[golden_refresh] 完成 — 关卡视口两 Shot 基线已代入 game/tests/golden/$PLAT/(提交说明须写明触发原因)" + exit 0 +fi + for a in assets/fonts/ui_font_fusion_pixel_zh_hans.ttf \ assets/art/board/seal_mark.png \ assets/art/character/character_hero_idle.png \ diff --git a/tools/probe_gate.sh b/tools/probe_gate.sh index d47b7af8..b54848ae 100755 --- a/tools/probe_gate.sh +++ b/tools/probe_gate.sh @@ -134,6 +134,12 @@ st=$? grep -E "PASS|FAIL|问题" "$TMP/uip.log" | head -12 verdict "ui_input_probe" "$TMP/uip.log" "$st" || _rc=1 +echo "======== level_map_viewport_probe(卷轴视口批 R1-R8) ========" +run_suite 120 "$TMP/lvp.log" res://probe/level_map_viewport_probe.tscn +st=$? +grep -E "PASS|FAIL" "$TMP/lvp.log" | head -12 +verdict "level_map_viewport_probe" "$TMP/lvp.log" "$st" || _rc=1 + echo "======== ui_struct_probe ========" run_suite 120 "$TMP/stp.log" res://probe/ui_struct_probe.tscn st=$? diff --git a/tools/render_gate.sh b/tools/render_gate.sh index 8e1a7b8a..d8bfb16f 100755 --- a/tools/render_gate.sh +++ b/tools/render_gate.sh @@ -165,6 +165,38 @@ else fi fi + +echo "======== 关卡视口 golden 逐像素(卷轴视口批——两 Shot 面:入口停点+Boss 列锚定)========" +LM="$TMP/lmapshots" +gate_run_timeout 360 "$TMP/lm.log" "$G" --path "$GAME_WIN" --quit-after 3000 res://probe/level_map_shot_probe.tscn -- "$LM" +st=$?; kill_orphans +grep -E "\[level_map_shot_probe\]" "$TMP/lm.log" | tail -3 +# 判定锚工件(同 fx/board——quit-after 竞速 124 非真红):PASS 签名 + 双图非零。 +if ! grep -q "PASS — 两 Shot 落盘" "$TMP/lm.log"; then + echo "[render_gate] FAIL — level_map_shot_probe 未干净完成(exit=${st})"; _rc=1 +fi +for b in lmap_entry.png lmap_boss.png; do + if [ ! -s "$LM/$b" ]; then echo "[render_gate] FAIL — 取证缺失/零字节: $b"; _rc=1; fi +done +scan_log "level_map_shot_probe" "$TMP/lm.log" +if [ "$_rc" -eq 0 ]; then + for b in lmap_entry.png lmap_boss.png; do + if [ ! -s "$GOLDEN/$b" ]; then + # 两 Shot 面(2026-11-05 用户裁)各平台独立冻结——本平台缺基线=未冻结态记 SKIP 不判红 + #(windows 先锚;他平台冻结义务归收尾呈报),已锚平台照常逐像素严判 + echo "[render_gate] SKIP — $GOLDEN/$b 基线缺失(本平台未冻结——跑 tools/golden_baseline_refresh.sh --lmap)" + else + LMG="$TMP/lmgolden" + mkdir -p "$LMG" + cp "$LM/$b" "$LMG/$b" + python3 tools/golden_compare.py "$REPO/game/tests/golden" "$LMG" "$b" | sed 's/^/[render_gate] /' + if [ "${PIPESTATUS[0]}" -ne 0 ]; then + echo "[render_gate] FAIL — 关卡视口 golden 超容差(S2 停机点口径:视口呈现换代=显式重制基线+呈报;超预期面=L3)"; _rc=1 + fi + fi + done +fi + echo "======== 残留检查(批末闸) ========" left="$(orphans | wc -l | tr -d ' ')" total="$(gate_godot_count_all)" diff --git a/待办清单.md b/待办清单.md index 4e6d90fb..73af9f33 100644 --- a/待办清单.md +++ b/待办清单.md @@ -257,11 +257,3 @@ **改动面**:游戏代码 **状态**:待执行 - -#### QA 覆盖总览面板与自动试玩扩展——自动发现测试验证缺漏(2026-11-05 登记,来源=会话提及) - -**实况**:方案全文=`D:\code\qa-harness-discussion\QA-Harness讨论结果-中间报告.md`(三批定案,全部三关试点):①探针存量 emit 真输入化(~40 处站点,承接上条)②B 覆盖总览面板+本体枚举器(六源回执明细化+能力清单提取+HTML 双击即开+游戏内容↔测试验证对账)③C 自动试玩扩展(auto_player 扩全动线全功能,异常上报含机器自填归因)。裁决在案勿重提:A 执行覆盖矩阵不做、D 缺口闸不做、变异测试禁入、归因登记不立独立制度。 - -**改动面**:游戏代码+工具代码+文档 - -**状态**:待执行