Skip to content

Commit 00040e6

Browse files
authored
feat(ui): yishijie 借鉴 PR#1+PR#2 — 战斗角色 + 地图多媒体 (#4)
* docs(plans): 创建 yishijie 借鉴计划双文档 - 基建版(B1-B8):错误监控、env 注入、base path 适配、schema 迁移、release-bundle、资源捕获 - UI/玩法版(U1-U22):水晶拨点、属性雷达、战斗模型扩展、2.5D 地图、多媒体画廊、立绘切换 本次仅创建规划文档,未动业务代码。等用户 proceed 后分 5 个 PR 落地。 * feat(new-game): U2 水晶拨点属性分配面板 - 新增 CrystalStatPanel 组件,6 维属性以水晶方式呈现(大小/亮度随数值缩放) - 顶部徽章实时显示剩余点数(负数变红)+ 难度配色 - 新增「自动均衡」+「重置」两个快捷操作 - 替换 NewGameWizardContent 内联 -/+ 按钮 UI(-41 / +20 行) - TDD:11 个测试覆盖渲染、+/- 交互、min/max 边界、剩余点禁用、快捷操作 来源:docs/plans/2026-06-15_yishijie-ui-gameplay-borrow-plan.md U2 测试:vitest 2455 通过 / 2 跳过(无回归) * feat(character): U3 6 维属性雷达图(SVG) - 新增 AttributeRadar 组件:纯 SVG + Tailwind 颜色,零外部依赖 - 6 顶点(力量/敏捷/体质/根骨/悟性/福源)顺时针分布 - 3 层同心六边形网格 + 6 条轴线 + 数据多边形 + 顶点圆点 - 可定制 size / maxValue / fillColor / gridColor / showValues - 集成到 CharacterProfileCard「基础六维」section,作为视觉补充(不替换文本列表) - TDD:10 个测试覆盖 SVG 渲染、标签、网格层、数据点、缩放、自定义 来源:docs/plans/2026-06-15_yishijie-ui-gameplay-borrow-plan.md U3 测试:vitest 2465 通过 / 2 跳过(无回归) * feat(battle): U4 战斗模型扩展(5 字段 optional + 规范化函数) - models/battle.ts 新增 5 个 optional 字段:暴击率/闪避率/最大连击/物理抗性/内力抗性 - 全部 default 0,向后兼容旧存档(JSON forward-compatible) - 新增 规范化战斗敌方信息(raw) 函数:不可变补齐默认值,复制 技能 数组 - BattleModal 集成:敌方卡片在资源条下方按需显示扩展属性徽章(> 0 才显示) - 徽章配色:暴击=琥珀/闪避=天蓝/连击=紫红/物抗=石色/内抗=紫罗兰 - TDD:7 个测试覆盖旧存档兼容、字段保留、部分缺失、不可变性、独立引用 来源:docs/plans/2026-06-15_yishijie-ui-gameplay-borrow-plan.md U4 测试:vitest 2472 通过 / 2 跳过(无回归) * feat(ui): U5 通用状态徽章组件 StatusBadge - 新增 components/ui/StatusBadge.tsx:通用 tone-based 徽章 - 7 种 tone:neutral/info/success/warning/danger/primary/rarity - 3 种 size:sm(9px) / md(10px) / lg(sm-base) - 可选 icon + value(value=0 不渲染,避免零值噪音) - 重构 BattleModal M4.c 的 5 个 raw 徽章 → StatusBadge 调用 (warning/info/danger/neutral/primary tone 映射暴击/闪避/连击/物抗/内抗) - 注意:与 BoardGame/shared/StatusBadge.tsx 同名但用途不同(路径隔离) TDD:20 个测试覆盖基础渲染、tone 配色、size、icon+value、自定义 className 来源:docs/plans/2026-06-15_yishijie-ui-gameplay-borrow-plan.md U5 测试:vitest 2492 通过 / 2 跳过(无回归) * feat(map): U7 世界地图缩略图组件 WorldMinimap - 新增 components/features/Map/WorldMinimap.tsx:地图缩略卡片网格 - 每张卡片显示:名称 + 归属层级(大>中>小)+ 建筑数徽章 - 当前 small place 匹配的卡片用 wuxia-gold 高亮 + 「当前位置」徽章 - 零外部依赖,纯 Tailwind - 集成到 MapModal 左侧 sidebar 顶部(保留原详细列表) - TDD:7 个测试覆盖基础渲染、当前高亮、点击交互、边界处理 来源:docs/plans/2026-06-15_yishijie-ui-gameplay-borrow-plan.md U7 测试:vitest 2499 通过 / 2 跳过(无回归) * feat(story): U8 故事图片画廊组件 StoryGallery - 新增 components/features/Story/StoryGallery.tsx:通用图片网格 - 网格布局 2/3/4 列响应式,缩略图 hover 缩放效果 - 点击触发 onSelect 回调(父组件决定如何放大) - 零外部依赖,纯 Tailwind - 解耦:组件接受 image URL 列表,不连 image-assets 异步 API (集成时由父组件 fetch URL 后传入) - TDD:6 个测试覆盖基础渲染、空态、点击、可访问性 来源:docs/plans/2026-06-15_yishijie-ui-gameplay-borrow-plan.md U8 测试:vitest 2505 通过 / 2 跳过(无回归) * feat(ui): U9 通用视频内嵌播放组件 VideoPlayer - 新增 components/ui/VideoPlayer.tsx:HTML5 <video> 包装 - 支持 src/poster/controls/autoPlay/loop/muted/maxWidth - 错误时显示降级 UI(红色边框 + 源 URL 提示) - 零外部依赖 - TDD:7 个测试覆盖渲染、poster、controls、错误处理、可定制性 来源:docs/plans/2026-06-15_yishijie-ui-gameplay-borrow-plan.md U9 测试:vitest 2512 通过 / 2 跳过(无回归) * feat(map): U11 2.5D Canvas 等距投影地图组件 - 新增 components/features/Map/IsometricMapCanvas.tsx:Canvas 2D 等距地图 - 零外部依赖(替代 Three.js,遵循基建版「不引入 3D」原则) - N×M tile 网格 + 菱形顶面 + 高度差侧面(自动上色) - back-to-front 渲染顺序(按 x+y 排序避免遮挡) - 当前位置金色脉冲环标记 - 点击反向投影回 tile 坐标,触发 onTileClick - TDD:8 个测试覆盖 canvas 渲染、尺寸、点击交互、当前位置、边界 来源:docs/plans/2026-06-15_yishijie-ui-gameplay-borrow-plan.md U11 测试:vitest 2520 通过 / 2 跳过(无回归)
1 parent 079499f commit 00040e6

22 files changed

Lines changed: 3282 additions & 44 deletions

components/features/Battle/BattleModal.tsx

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import React from 'react';
22
import { 角色数据结构, 战斗状态结构 } from '../../../types';
33
import { IconSwords, IconYinYang } from '../../ui/Icons';
4+
import { StatusBadge } from '../../ui/StatusBadge';
45
import BattleActionPanel from './BattleActionPanel';
56
import { RpgBattleIntegration, shouldUseRpgBattle } from './RpgBattleIntegration';
67
import { 战斗行动结果 } from '../../../hooks/useGame/combat/combatCalculation';
@@ -160,6 +161,27 @@ const BattleModal: React.FC<Props> = ({ character, battle, onClose, onAction })
160161
)}
161162
</div>
162163

164+
{/* U4 扩展属性徽章:仅当 > 0 时显示,避免旧存档噪音 */}
165+
{(enemy?.暴击率 || enemy?.闪避率 || enemy?.最大连击 || enemy?.物理抗性 || enemy?.内力抗性) ? (
166+
<div className="mt-3 flex flex-wrap gap-1.5 relative z-10" data-testid="battle-extended-stats">
167+
{enemy.暴击率 ? (
168+
<StatusBadge tone="warning" size="sm" value={enemy.暴击率} title="暴击率">暴击</StatusBadge>
169+
) : null}
170+
{enemy.闪避率 ? (
171+
<StatusBadge tone="info" size="sm" value={enemy.闪避率} title="闪避率">闪避</StatusBadge>
172+
) : null}
173+
{enemy.最大连击 ? (
174+
<StatusBadge tone="danger" size="sm" value={enemy.最大连击} title="最大连击">连击</StatusBadge>
175+
) : null}
176+
{enemy.物理抗性 ? (
177+
<StatusBadge tone="neutral" size="sm" value={enemy.物理抗性} title="物理抗性">物抗</StatusBadge>
178+
) : null}
179+
{enemy.内力抗性 ? (
180+
<StatusBadge tone="primary" size="sm" value={enemy.内力抗性} title="内力抗性">内抗</StatusBadge>
181+
) : null}
182+
</div>
183+
) : null}
184+
163185
<div className="mt-4 pt-3 border-t border-white/5 relative z-10">
164186
<div className="text-[10px] text-red-500/70 tracking-[0.2em] font-serif mb-2 flex items-center gap-1.5">
165187
<span className="w-1 h-3 bg-red-900/80 rounded-full"></span> 功法路数

components/features/Character/CharacterProfileCard.tsx

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import React from 'react';
22
import { 角色数据结构, 视觉设置结构 } from '../../../types';
33
import { 构建区域文字样式 } from '../../../utils/visualSettings';
4+
import { AttributeRadar } from '../../ui/AttributeRadar';
45

56
interface Props {
67
character: 角色数据结构;
@@ -259,9 +260,26 @@ const CharacterProfileCard: React.FC<Props> = ({ character, visualConfig }) => {
259260
</div>
260261

261262
<div className="border border-gray-800/80 bg-black/30 p-4">
262-
<div className="mb-3 text-[10px] uppercase tracking-[0.35em] text-wuxia-gold/65">
263-
基础六维
264-
{气运列表.length > 0 && <span className="ml-2 text-[8px] text-wuxia-cyan">(气运修正中)</span>}
263+
<div className="mb-3 flex items-center justify-between">
264+
<div className="text-[10px] uppercase tracking-[0.35em] text-wuxia-gold/65">
265+
基础六维
266+
{气运列表.length > 0 && <span className="ml-2 text-[8px] text-wuxia-cyan">(气运修正中)</span>}
267+
</div>
268+
</div>
269+
<div className="flex justify-center mb-4">
270+
<AttributeRadar
271+
stats={{
272+
力量: character.力量,
273+
敏捷: character.敏捷,
274+
体质: character.体质,
275+
根骨: character.根骨,
276+
悟性: character.悟性,
277+
福源: character.福源,
278+
}}
279+
size={220}
280+
fillColor="#10b981"
281+
gridColor="#4b5563"
282+
/>
265283
</div>
266284
<div className="grid grid-cols-3 gap-2">
267285
{attributes.map((attr) => (
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
/**
2+
* IsometricMapCanvas — 2.5D Canvas 等距投影地图组件测试
3+
*
4+
* TDD 来源:docs/plans/2026-06-15_yishijie-ui-gameplay-borrow-plan.md U11
5+
* 目标:用 Canvas 2D 实现 2.5D 倾斜地图(替代 Three.js,零外部依赖)
6+
* 设计:N×M tile 网格 + 等距投影 + 当前位置高亮
7+
*/
8+
import { describe, it, expect, vi } from 'vitest';
9+
import { render, screen, fireEvent } from '@testing-library/react';
10+
import { IsometricMapCanvas, type MapTile } from './IsometricMapCanvas';
11+
12+
const tiles: MapTile[] = [
13+
{ x: 0, y: 0, height: 0, color: '#444' },
14+
{ x: 1, y: 0, height: 1, color: '#666' },
15+
{ x: 0, y: 1, height: 1, color: '#666' },
16+
{ x: 1, y: 1, height: 2, color: '#888' },
17+
];
18+
19+
const baseProps = {
20+
tiles,
21+
cols: 2,
22+
rows: 2,
23+
currentPosition: { x: 1, y: 1 },
24+
onTileClick: vi.fn(),
25+
};
26+
27+
describe('IsometricMapCanvas — 基础渲染', () => {
28+
it('渲染 canvas 元素', () => {
29+
const { container } = render(<IsometricMapCanvas {...baseProps} />);
30+
const canvas = container.querySelector('canvas');
31+
expect(canvas).toBeInTheDocument();
32+
});
33+
34+
it('canvas 有正确的 width/height 属性', () => {
35+
const { container } = render(
36+
<IsometricMapCanvas {...baseProps} width={400} height={300} />
37+
);
38+
const canvas = container.querySelector('canvas')!;
39+
expect(canvas).toHaveAttribute('width', '400');
40+
expect(canvas).toHaveAttribute('height', '300');
41+
});
42+
});
43+
44+
describe('IsometricMapCanvas — 可点击交互', () => {
45+
it('点击 canvas 有效 tile 区域触发 onTileClick 回调', () => {
46+
const onTileClick = vi.fn();
47+
const { container } = render(
48+
<IsometricMapCanvas
49+
{...baseProps}
50+
width={400}
51+
height={300}
52+
tileWidth={80}
53+
tileHeight={40}
54+
onTileClick={onTileClick}
55+
/>
56+
);
57+
const canvas = container.querySelector('canvas')!;
58+
// 点击 canvas 中心(接近 (1,1) 位置)
59+
fireEvent.click(canvas, { clientX: 200, clientY: 150 });
60+
expect(onTileClick).toHaveBeenCalled();
61+
});
62+
63+
it('点击 canvas 越界区域不触发回调', () => {
64+
const onTileClick = vi.fn();
65+
const { container } = render(
66+
<IsometricMapCanvas
67+
{...baseProps}
68+
onTileClick={onTileClick}
69+
/>
70+
);
71+
const canvas = container.querySelector('canvas')!;
72+
// 点击 canvas 左上角(必然越界)
73+
fireEvent.click(canvas, { clientX: 0, clientY: 0 });
74+
expect(onTileClick).not.toHaveBeenCalled();
75+
});
76+
});
77+
78+
describe('IsometricMapCanvas — 当前位置', () => {
79+
it('显示当前位置指示器(data-current-x/y)', () => {
80+
const { container } = render(
81+
<IsometricMapCanvas {...baseProps} currentPosition={{ x: 1, y: 1 }} />
82+
);
83+
const canvas = container.querySelector('canvas')!;
84+
expect(canvas.getAttribute('data-current-x')).toBe('1');
85+
expect(canvas.getAttribute('data-current-y')).toBe('1');
86+
});
87+
88+
it('不传 currentPosition 时无当前位置属性', () => {
89+
const { container } = render(
90+
<IsometricMapCanvas {...baseProps} currentPosition={undefined} />
91+
);
92+
const canvas = container.querySelector('canvas')!;
93+
expect(canvas.getAttribute('data-current-x')).toBeNull();
94+
});
95+
});
96+
97+
describe('IsometricMapCanvas — 边界处理', () => {
98+
it('空 tiles 数组不崩溃', () => {
99+
const { container } = render(
100+
<IsometricMapCanvas tiles={[]} cols={0} rows={0} />
101+
);
102+
const canvas = container.querySelector('canvas');
103+
expect(canvas).toBeInTheDocument();
104+
});
105+
106+
it('支持自定义 className', () => {
107+
const { container } = render(
108+
<IsometricMapCanvas {...baseProps} className="my-canvas" />
109+
);
110+
const canvas = container.querySelector('canvas.my-canvas');
111+
expect(canvas).toBeInTheDocument();
112+
});
113+
});

0 commit comments

Comments
 (0)