diff --git a/CHANGELOG.md b/CHANGELOG.md index 3cd3e220e8..dba498193f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ Pixel Avatars can be an elf, dwarf, orc or talking flower with beards, medieval - Every other part can be drawn as well: outfit, accessory, beard, glasses, nose, cheeks, petals and flower base each offer **Draw or edit this piece**, starting from the style you wear. Pixels in an appearance color stay recolorable. A drawn face part never covers the speaking mouth. Drawn parts hide where the species or headwear hides the built-in part, and **Back to the built-in style** restores your choice. Picking a built-in style no longer upgrades an older Avatar's recipe unless a drawn part is being taken off ([#1240](https://github.com/BotHarness/DeepSeekBot/issues/1240)). - Drawn parts can be shared. An exported Bot carries the parts it wears, and importing it adds them to your Part Library marked **From an imported Bot**. A single part exports as a PNG that previews anywhere and imports back losslessly. The whole library exports as a zip of those PNGs. Imports merge identical parts, recompute each part's identity from its content, and refuse malformed or oversized files with a message. The library can be filtered by origin ([#1215](https://github.com/BotHarness/DeepSeekBot/issues/1215)). +- The part editor gains line and rectangle (hold Shift for straight angles and squares), a tone gradient with 4×4 or 2×2 dithering, noise with an amount and **Re-roll**, an eyedropper (or Alt-click / long-press), and a **Shade** pencil that makes cells lighter or darker once per stroke. Every tool mirrors and is one undo step. Results stay appearance colors at a tone, so the part still recolors. On touch, a two-finger tap undoes, a three-finger tap redoes, and an **Offset cursor** with a **Hold to draw** button keeps your finger off the pixel ([#1216](https://github.com/BotHarness/DeepSeekBot/issues/1216)). - Hair pieces can be drawn too: open bangs, left or right side hair or back hair and start from the style you wear, flattened to pixels. Reshape it with the same tools, save it to the Part Library and wear it in that slot. The drawn hair is shaded and recolored like built-in hair, turns with the head and hides under helmets, hoods and on flowers. **Back to the built-in style** restores your original choice ([#1238](https://github.com/BotHarness/DeepSeekBot/issues/1238)). - You can draw your own headpiece for a pixel Avatar: pencil, eraser, fill, mirroring across the face, a front layer over the hair and a back layer behind it, undo and redo, and a 1× preview on the real Avatar. Colors can follow the Avatar's hair, skin, eye or outfit color in five tones, or be fixed. Saved parts go into a Part Library in your Profile and can be worn by any PersonaBot. The worn copy turns and speaks with the Window Companion and travels with an exported Bot, and editing a library part saves a new part without changing Avatars that already wear the old one ([#1211](https://github.com/BotHarness/DeepSeekBot/issues/1211)). - Pixel Avatars can be an elf, dwarf, orc or talking flower. Beards always leave the speaking mouth visible. Armor, robe, tunic and cloak outfits are available, and a helmet or hood hides the hair underneath. Flowers have bead eyes, five petal styles and a stem or pot. Choices a species or headwear doesn't show stay saved, with a note, and return when switched back ([#1212](https://github.com/BotHarness/DeepSeekBot/issues/1212), [#1213](https://github.com/BotHarness/DeepSeekBot/issues/1213)). diff --git a/CHANGELOG.zh.md b/CHANGELOG.zh.md index 023d6cbe08..4ac915e42c 100644 --- a/CHANGELOG.zh.md +++ b/CHANGELOG.zh.md @@ -15,6 +15,7 @@ - 其他部件也都能自己画了:衣服、配饰、胡子、眼镜、鼻子、腮红、花瓣和花盆都有 **绘制/修改这一片**,从当前戴的样式开始;外形颜色的像素仍会跟着变色;自绘的脸部部件不会挡住说话的嘴;物种或头饰隐藏内置部件时,自绘部件也会隐藏;**换回内置样式** 可恢复原来的选择。只有在取下自绘部件时,选内置样式才会升级旧头像的 recipe([#1240](https://github.com/BotHarness/DeepSeekBot/issues/1240))。 - 自绘部件可以分享了:导出的 Bot 会带上它戴着的部件,导入后这些部件会进入你的部件库,标记为“来自导入的 Bot”;单个部件可导出为任何地方都能预览的 PNG,并能无损导回;整个部件库可导出为这些 PNG 的 zip。导入时相同内容的部件会合并,并按内容重新计算部件身份,格式错误或过大的文件会被拒绝并给出提示;部件库可按来源筛选([#1215](https://github.com/BotHarness/DeepSeekBot/issues/1215))。 +- 部件编辑器新增直线和矩形(按住 Shift 锁定直角和正方形)、带 4×4 或 2×2 抖动的明暗渐变、可调强度并可 **重新随机** 的杂色、吸管(也可按住 Alt 点击或长按),以及每笔只变亮或变暗一次的 **明暗笔**。所有工具都支持镜像,每次操作是一步撤销;结果仍是外形颜色加明暗,部件照样跟着变色。触屏上双指轻点撤销、三指轻点重做,**偏移光标** 配合 **按住绘制** 按钮,手指不会挡住要画的像素([#1216](https://github.com/BotHarness/DeepSeekBot/issues/1216))。 - 头发也能自己画了:在前发、左右侧发或后发里,从当前戴的样式开始(压平成像素),用同样的工具改形状,存进部件库并戴在这个位置。自绘头发会像内置头发一样上阴影、跟着发色变色、随头部转动,戴头盔/兜帽或变成花朵时会隐藏。点 **换回内置样式** 可恢复原来的选择([#1238](https://github.com/BotHarness/DeepSeekBot/issues/1238))。 - 可以为像素头像自己画头饰:铅笔、橡皮、填充、沿脸部中线镜像,前层盖在头发上、后层在头发后面,支持撤销重做,并在真实头像上显示 1× 预览。颜色可以跟随头像的发色、肤色、眼睛或衣服颜色(五档明暗),也可以是固定色。保存的部件进入 Profile 里的部件库,任何 PersonaBot 都能戴上;戴上的副本会在窗口伙伴里随头部转动和说话,并随导出的 Bot 一起带走;修改库里的部件会另存为新部件,不会改变已经戴着旧部件的头像([#1211](https://github.com/BotHarness/DeepSeekBot/issues/1211))。 - 像素头像新增精灵、矮人、兽人和会说话的花。胡子始终露出说话的嘴,新增盔甲、法袍、束腰外衣和斗篷,戴上头盔或兜帽会遮住下面的头发。花有豆豆眼、五种花瓣样式,以及茎叶或花盆。当前物种或头饰不显示的选择会保留并给出提示,换回后恢复([#1212](https://github.com/BotHarness/DeepSeekBot/issues/1212)、[#1213](https://github.com/BotHarness/DeepSeekBot/issues/1213))。 diff --git a/docs/architecture/botharness-architecture.en.md b/docs/architecture/botharness-architecture.en.md index b8e2b8ab03..481df7f3b7 100644 --- a/docs/architecture/botharness-architecture.en.md +++ b/docs/architecture/botharness-architecture.en.md @@ -408,7 +408,7 @@ Custom Parts are bounded pixel grids whose cells reference appearance colors or New PersonaBots record a full-domain name-seed version; PersonaBots without it keep the human-only seed. Recipes using species, new slots or Custom Parts raise the asset/schema version and fall back to the saved snapshot on older Clients; existing recipes render unchanged. The first slice proves goblin, split side hair and one drawn headpiece from editor to Part Library to a Window Companion and through export/import. -Implemented so far: goblin, elf, dwarf, orc and flower species, split side hair, beards, medieval outfits and headwear, the full-domain seed (#1210, #1212–#1214), the first Custom Part path (#1211), drawn hair pieces (#1238), every other drawn part (#1240), and part files (#1215). A drawn bangs, side or back piece replaces the built-in piece while that choice stays saved; its live `hairColor` cells are shaded like built-in hair, so editing starts from the worn style flattened to cells. A drawn headpiece is an asset version 3 recipe. The Part Library is the `avatar-part-library` module's `avatar_part_library` table (schema generation 73), keyed by the part's content hash and holding name, origins and parent. The `partLibraryList`, `partLibraryAdd`, `partLibraryExport` and `partLibraryImport` bridge methods expose it to the Client editor (pencil, eraser, 4-connected fill, centerline mirror, front and back layers, undo and redo, and a 1× preview). The appearance embeds a copy of the part, so it travels in `.botharness/bot.json` and Bot Zips. `part-file` encodes one part as a ×8 preview PNG with the part data in a `botharness-part` tEXt chunk, and the library as a zip of such PNGs; import checks every chunk CRC and deduplicates by content. Importing a Bot adds the parts its appearance wears to the library with the `imported-bot` origin and the Bot's display name as author. Animal species, plain PNG import and further drawing tools are still to come. +Implemented so far: goblin, elf, dwarf, orc and flower species, split side hair, beards, medieval outfits and headwear, the full-domain seed (#1210, #1212–#1214), the first Custom Part path (#1211), drawn hair pieces (#1238), every other drawn part (#1240), part files (#1215), and the full drawing tool set (#1216). A drawn bangs, side or back piece replaces the built-in piece while that choice stays saved; its live `hairColor` cells are shaded like built-in hair, so editing starts from the worn style flattened to cells. A drawn headpiece is an asset version 3 recipe. The Part Library is the `avatar-part-library` module's `avatar_part_library` table (schema generation 73), keyed by the part's content hash and holding name, origins and parent. The `partLibraryList`, `partLibraryAdd`, `partLibraryExport` and `partLibraryImport` bridge methods expose it to the Client editor (pencil with a lighter/darker shade modifier, eraser, 4-connected fill, line and rectangle with Shift snapping, a region-bounded tone gradient with 4×4 or 2×2 Bayer dithering, seeded ±1 tone noise with re-roll, an eyedropper by Alt-click or long-press, centerline mirror for every tool, front and back layers, one undo step per action with two- and three-finger tap undo and redo, an offset touch cursor, and a 1× preview; BotPixel computes every tool's cells, which stay appearance colors at a tone). The appearance embeds a copy of the part, so it travels in `.botharness/bot.json` and Bot Zips. `part-file` encodes one part as a ×8 preview PNG with the part data in a `botharness-part` tEXt chunk, and the library as a zip of such PNGs; import checks every chunk CRC and deduplicates by content. Importing a Bot adds the parts its appearance wears to the library with the `imported-bot` origin and the Bot's display name as author. Animal species and plain PNG import are still to come. ## 6 · Persistence, export, and restore boundaries diff --git a/docs/architecture/botharness-architecture.md b/docs/architecture/botharness-architecture.md index 91d607e5e8..4457f6b99e 100644 --- a/docs/architecture/botharness-architecture.md +++ b/docs/architecture/botharness-architecture.md @@ -514,7 +514,7 @@ Custom Part 是有界像素网格,每格引用外形颜色或固定色并带 新 PersonaBot 记录全域名字种子版本;没有该记录的 PersonaBot 保持仅人类的种子。使用物种、新槽位或 Custom Part 的 recipe 提升 asset/schema 版本,旧 Client 显示保存的快照;既有 recipe 渲染不变。首个切片验证哥布林、左右侧发分片与一个自绘头饰,从编辑器到 Part Library、再到 Window Companion,并经过导出/导入。 -目前已实现:哥布林、精灵、矮人、兽人和花物种,左右侧发分片,胡子,中世纪服装与头饰,全域种子(#1210、#1212–#1214),第一条 Custom Part 路径(#1211),自绘头发(#1238),自绘其他部件(#1240),以及部件文件分享(#1215)。自绘的前发、侧发或后发会替换对应的内置部件,原来的选择仍会保留;其中发色的像素会像内置头发一样上阴影,所以编辑时从当前样式压平成的格子开始。自绘头饰的 recipe 是 asset version 3。Part Library 是 `avatar-part-library` 模块的 `avatar_part_library` 表(schema generation 73),以部件内容哈希为键,并保存名称、来源和父部件。Client 编辑器通过 `partLibraryList`、`partLibraryAdd`、`partLibraryExport` 和 `partLibraryImport` 四个桥接方法使用它;`part-file` 把单个部件编码为 ×8 预览 PNG,部件数据放在 `botharness-part` tEXt 块中,整个部件库导出为这种 PNG 的 zip,导入时校验每个块的 CRC 并按内容去重;导入 Bot 时其外形穿戴的部件以 `imported-bot` 来源加入部件库,作者为该 Bot 的显示名;编辑器提供铅笔、橡皮、四连通填充、中线镜像、前后两层、撤销重做和 1× 预览。外形嵌入部件副本,因此部件会随 `.botharness/bot.json` 和 Bot Zip 一起带走。动物物种、普通 PNG 导入和更多绘制工具尚未实现。 +目前已实现:哥布林、精灵、矮人、兽人和花物种,左右侧发分片,胡子,中世纪服装与头饰,全域种子(#1210、#1212–#1214),第一条 Custom Part 路径(#1211),自绘头发(#1238),自绘其他部件(#1240)、部件文件分享(#1215),以及完整绘制工具(#1216)。自绘的前发、侧发或后发会替换对应的内置部件,原来的选择仍会保留;其中发色的像素会像内置头发一样上阴影,所以编辑时从当前样式压平成的格子开始。自绘头饰的 recipe 是 asset version 3。Part Library 是 `avatar-part-library` 模块的 `avatar_part_library` 表(schema generation 73),以部件内容哈希为键,并保存名称、来源和父部件。Client 编辑器通过 `partLibraryList`、`partLibraryAdd`、`partLibraryExport` 和 `partLibraryImport` 四个桥接方法使用它;`part-file` 把单个部件编码为 ×8 预览 PNG,部件数据放在 `botharness-part` tEXt 块中,整个部件库导出为这种 PNG 的 zip,导入时校验每个块的 CRC 并按内容去重;导入 Bot 时其外形穿戴的部件以 `imported-bot` 来源加入部件库,作者为该 Bot 的显示名;编辑器提供带变亮/变暗明暗笔的铅笔、橡皮、四连通填充、按 Shift 锁定角度和正方形的直线与矩形、限定在区域内并带 4×4 或 2×2 Bayer 抖动的明暗渐变、可重新随机的 ±1 明暗杂色、Alt 点击或长按吸管、所有工具的中线镜像、前后两层、每次操作一步撤销(触屏双指撤销、三指重做)、触屏偏移光标和 1× 预览;每个工具的格子都由 BotPixel 计算,结果仍是外形颜色加明暗档位。外形嵌入部件副本,因此部件会随 `.botharness/bot.json` 和 Bot Zip 一起带走。动物物种和普通 PNG 导入尚未实现。 ## 6 · 持久化、导出与恢复边界 diff --git a/docs/assets/pr/1216-drawing-tools/README.md b/docs/assets/pr/1216-drawing-tools/README.md new file mode 100644 index 0000000000..dc6ade86a0 --- /dev/null +++ b/docs/assets/pr/1216-drawing-tools/README.md @@ -0,0 +1,33 @@ +# #1216 Gradient, noise and the full tool set + +Captured on an isolated DSH instance built from this branch, with `@botharness/pixel-avatar` 0.8.0 (BotHarness/BotPixel#16). Mirror is on throughout. + +## Shapes + +| Rectangle, and a line snapped to 45° with Shift | +| ----------------------------------------------- | +| ![rect and line](rect-line-en-light.webp) | + +## Shading and texture + +| Gradient inside the rectangle (hair color +1 → −2, 4×4 Bayer) | Noise at 30%, re-rolled twice | +| ------------------------------------------------------------- | ----------------------------- | +| ![gradient](gradient-en-light.webp) | ![noise](noise-en-light.webp) | + +| Darker shade stroke, then Alt-click picks color and tone | Offset cursor with the Hold to draw button | +| -------------------------------------------------------- | ------------------------------------------ | +| ![shade and pick](shade-pick-en-light.webp) | ![offset](offset-en-light.webp) | + +## Recolor and Window Companion + +The speech is simulated. A test script drives the companion's own mouth layers; no model reply was involved. + +| Red hair recolors the part with its tones intact | Speaking with the shaded tiara | +| ------------------------------------------------ | ----------------------------------- | +| ![recolor](recolor-en-light.webp) | ![speaking](speaking-en-light.webp) | + +## Full flow + +![full flow](drawing-tools-flow.gif) + +The same recording as video: [drawing-tools-flow.mp4](drawing-tools-flow.mp4) diff --git a/docs/assets/pr/1216-drawing-tools/drawing-tools-flow.gif b/docs/assets/pr/1216-drawing-tools/drawing-tools-flow.gif new file mode 100644 index 0000000000..a3618da35f Binary files /dev/null and b/docs/assets/pr/1216-drawing-tools/drawing-tools-flow.gif differ diff --git a/docs/assets/pr/1216-drawing-tools/drawing-tools-flow.mp4 b/docs/assets/pr/1216-drawing-tools/drawing-tools-flow.mp4 new file mode 100644 index 0000000000..b6a0257d30 Binary files /dev/null and b/docs/assets/pr/1216-drawing-tools/drawing-tools-flow.mp4 differ diff --git a/docs/assets/pr/1216-drawing-tools/gradient-en-light.webp b/docs/assets/pr/1216-drawing-tools/gradient-en-light.webp new file mode 100644 index 0000000000..c54a555c6c Binary files /dev/null and b/docs/assets/pr/1216-drawing-tools/gradient-en-light.webp differ diff --git a/docs/assets/pr/1216-drawing-tools/noise-en-light.webp b/docs/assets/pr/1216-drawing-tools/noise-en-light.webp new file mode 100644 index 0000000000..921b9cf98f Binary files /dev/null and b/docs/assets/pr/1216-drawing-tools/noise-en-light.webp differ diff --git a/docs/assets/pr/1216-drawing-tools/offset-en-light.webp b/docs/assets/pr/1216-drawing-tools/offset-en-light.webp new file mode 100644 index 0000000000..39493725ab Binary files /dev/null and b/docs/assets/pr/1216-drawing-tools/offset-en-light.webp differ diff --git a/docs/assets/pr/1216-drawing-tools/recolor-en-light.webp b/docs/assets/pr/1216-drawing-tools/recolor-en-light.webp new file mode 100644 index 0000000000..ebb303a986 Binary files /dev/null and b/docs/assets/pr/1216-drawing-tools/recolor-en-light.webp differ diff --git a/docs/assets/pr/1216-drawing-tools/rect-line-en-light.webp b/docs/assets/pr/1216-drawing-tools/rect-line-en-light.webp new file mode 100644 index 0000000000..26d0d12b26 Binary files /dev/null and b/docs/assets/pr/1216-drawing-tools/rect-line-en-light.webp differ diff --git a/docs/assets/pr/1216-drawing-tools/shade-pick-en-light.webp b/docs/assets/pr/1216-drawing-tools/shade-pick-en-light.webp new file mode 100644 index 0000000000..c8047c15ea Binary files /dev/null and b/docs/assets/pr/1216-drawing-tools/shade-pick-en-light.webp differ diff --git a/docs/assets/pr/1216-drawing-tools/speaking-en-light.webp b/docs/assets/pr/1216-drawing-tools/speaking-en-light.webp new file mode 100644 index 0000000000..e6199beb45 Binary files /dev/null and b/docs/assets/pr/1216-drawing-tools/speaking-en-light.webp differ diff --git a/packages/client/src/client/custom-part-editor.tsx b/packages/client/src/client/custom-part-editor.tsx index eeca31e38d..0d4a00729d 100644 --- a/packages/client/src/client/custom-part-editor.tsx +++ b/packages/client/src/client/custom-part-editor.tsx @@ -1,4 +1,10 @@ -import { useMemo, useRef, useState, type ReactElement } from 'react'; +import { + useMemo, + useRef, + useState, + type PointerEvent as ReactPointerEvent, + type ReactElement, +} from 'react'; import { AVATAR_COLORS, PART_SLOTS, @@ -7,9 +13,14 @@ import { createCustomPart, emptyPartLayer, fillPartLayer, + gradientPartLayer, + noisePartLayer, paintPartLayer, partLayer, + partLinePoints, + partRectPoints, partToneColor, + shadePartLayer, withAvatarCustomPart, isHairPartSlot, type PartSlot, @@ -18,13 +29,28 @@ import { type PartInk, type PartLayer, type PartLayerName, + type PartTone, type PixelCustomPart, } from '../../../core/src/bots/avatar-appearance.js'; import type { BotHarnessTranslate } from './locale.js'; type Key = Parameters[0]; -type Tool = 'pencil' | 'eraser' | 'fill'; +type Tool = 'pencil' | 'eraser' | 'fill' | 'line' | 'rect' | 'gradient' | 'noise' | 'eyedropper'; type Layers = Record; +type Point = readonly [number, number]; +interface Gesture { + base: Layers; + future: Layers[]; + from: Point; + points: Point[]; + fixed: boolean; +} +interface Noise { + base: Layers; + seed: string; + layer: PartLayerName; + mirror: boolean; +} const FIXED_COLORS: readonly PartColor[] = [ '#efb93f', @@ -36,7 +62,25 @@ const FIXED_COLORS: readonly PartColor[] = [ '#3d9970', '#8a5ad0', ]; -const TOOLS: readonly Tool[] = ['pencil', 'eraser', 'fill']; +const TOOLS: readonly Tool[] = [ + 'pencil', + 'eraser', + 'fill', + 'line', + 'rect', + 'gradient', + 'noise', + 'eyedropper', +]; +const SHADES = [ + ['off', 0], + ['lighter', 1], + ['darker', -1], +] as const; +const LONG_PRESS_MS = 500; +const TAP_MS = 250; +const STROKE_TOOLS = new Set(['pencil', 'eraser', 'line', 'rect', 'gradient']); +const OFFSET_ROWS = 3; const MIN_ZOOM = 6; const MAX_ZOOM = 16; @@ -82,7 +126,20 @@ export function CustomPartEditor({ const [name, setName] = useState(''); const [busy, setBusy] = useState(false); const [failed, setFailed] = useState(false); - const drawing = useRef(undefined); + const [shade, setShade] = useState<0 | 1 | -1>(0); + const [gradientTo, setGradientTo] = useState(-2); + const [dither, setDither] = useState<2 | 4>(4); + const [noiseAmount, setNoiseAmount] = useState(0.4); + const [noise, setNoise] = useState(); + const [offset, setOffset] = useState(false); + const [cursor, setCursor] = useState(); + const gesture = useRef(undefined); + const touches = useRef(new Set()); + const most = useRef(0); + const press = useRef | undefined>(undefined); + const touchStart = useRef({ at: 0, moved: false, tap: true }); + const pendingPick = useRef(undefined); + const grid = useRef(null); const bare = useMemo(() => withAvatarCustomPart(recipe, slot, undefined), [recipe]); const part = useMemo(() => createCustomPart(slot, layers), [layers]); const wearing = useMemo(() => withAvatarCustomPart(recipe, slot, part), [recipe, part]); @@ -94,35 +151,97 @@ export function CustomPartEditor({ setLayers(next); onChange(createCustomPart(slot, next)); }; - const applyAt = (base: Layers, x: number, y: number): Layers => { + const record = () => { + setPast((stack) => [...stack, layers]); + setFuture([]); + setNoise(undefined); + }; + const noised = (state: Noise, amount: number): Layers => ({ + ...state.base, + [state.layer]: noisePartLayer(slot, state.base[state.layer], amount, state.seed, state.mirror), + }); + const draw = (current: Gesture, to: Point, snap: boolean): Layers => { + const { base, from, points } = current; const value = tool === 'eraser' ? null : ink; - const target = + const target = base[layer]; + const next = tool === 'fill' - ? fillPartLayer(slot, base[layer], x, y, value, mirror) - : paintPartLayer(slot, base[layer], [[x, y]], value, mirror); - return { ...base, [layer]: target }; + ? fillPartLayer(slot, target, from[0], from[1], value, mirror) + : tool === 'line' + ? paintPartLayer(slot, target, partLinePoints(from, to, snap), value, mirror) + : tool === 'rect' + ? paintPartLayer(slot, target, partRectPoints(from, to, snap), value, mirror) + : tool === 'gradient' + ? gradientPartLayer(slot, target, from, to, ink.color, ink.tone, gradientTo, { + dither, + mirror, + }) + : tool === 'pencil' && shade !== 0 + ? shadePartLayer(slot, target, points, shade, mirror) + : paintPartLayer(slot, target, points, value, mirror); + return { ...base, [layer]: next }; }; - const start = (x: number, y: number) => { - setPast((stack) => [...stack, layers]); - setFuture([]); - const next = applyAt(layers, x, y); - drawing.current = tool === 'fill' ? undefined : next; - commit(next); + const pick = ([x, y]: Point) => { + const found = layers[layer][y]![x] ?? layers[layer === 'front' ? 'back' : 'front'][y]![x]; + if (!found) return; + setInk(found); + if (tool === 'eraser' || tool === 'eyedropper') setTool('pencil'); }; - const extend = (x: number, y: number) => { - if (!drawing.current) return; - const next = applyAt(drawing.current, x, y); - drawing.current = next; - commit(next); + const begin = (point: Point, alt: boolean, touch = false) => { + if (alt || tool === 'eyedropper') { + if (touch) pendingPick.current = point; + else pick(point); + return; + } + const fixed = tool === 'fill' || tool === 'noise'; + const current = { base: layers, future, from: point, points: [point], fixed }; + record(); + if (tool === 'noise') { + const state = { base: layers, seed: Math.random().toString(36).slice(2), layer, mirror }; + setNoise(state); + commit(noised(state, noiseAmount)); + } else commit(draw(current, point, false)); + gesture.current = fixed && !touch ? undefined : current; + }; + const move = (point: Point, snap: boolean) => { + const current = gesture.current; + if (!current || current.fixed) return; + const last = current.points.at(-1)!; + if (last[0] === point[0] && last[1] === point[1]) return; + current.points = [...current.points, ...partLinePoints(last, point).slice(1)]; + commit(draw(current, point, snap)); + }; + const end = (point: Point | undefined, snap: boolean) => { + const current = gesture.current; + gesture.current = undefined; + if (current && !current.fixed && point && tool !== 'pencil' && tool !== 'eraser') + commit(draw(current, point, snap)); }; - const stop = () => { - drawing.current = undefined; + const cancel = () => { + const current = gesture.current; + gesture.current = undefined; + if (!current) return; + setPast((stack) => stack.slice(0, -1)); + setFuture(current.future); + setNoise(undefined); + commit(current.base); + }; + const reroll = () => { + if (!noise) return; + const seed = Math.random().toString(36).slice(2); + setNoise({ ...noise, seed }); + commit(noised({ ...noise, seed }, noiseAmount)); + }; + const changeAmount = (amount: number) => { + setNoiseAmount(amount); + if (noise) commit(noised(noise, amount)); }; const undo = () => { const previous = past.at(-1); if (!previous) return; setPast(past.slice(0, -1)); setFuture([layers, ...future]); + setNoise(undefined); commit(previous); }; const redo = () => { @@ -130,8 +249,105 @@ export function CustomPartEditor({ if (!next) return; setFuture(rest); setPast([...past, layers]); + setNoise(undefined); commit(next); }; + const cellAt = (event: ReactPointerEvent, clamp = true): Point | undefined => { + const box = grid.current?.getBoundingClientRect(); + if (box && box.width > 0 && typeof event.clientX === 'number') { + const x = Math.floor((event.clientX - box.left) / zoom); + const y = Math.floor((event.clientY - box.top) / zoom); + if (!clamp && (x < 0 || x >= W || y < 0 || y >= H)) return undefined; + return [Math.max(0, Math.min(W - 1, x)), Math.max(0, Math.min(H - 1, y))]; + } + const cell = (event.target as Element | null)?.closest?.('[data-part-cell]'); + const [x, y] = (cell?.getAttribute('data-part-cell') ?? '').split(',').map(Number); + return cell ? [x!, y!] : undefined; + }; + const aim = (point: Point): Point => [point[0], Math.max(0, point[1] - OFFSET_ROWS)]; + const clearPress = () => { + if (press.current) clearTimeout(press.current); + press.current = undefined; + }; + const colored = ([x, y]: Point) => + !!(layers[layer][y]![x] ?? layers[layer === 'front' ? 'back' : 'front'][y]![x]); + const onDown = (event: ReactPointerEvent) => { + event.preventDefault(); + const touch = event.pointerType === 'touch'; + if (touch) { + touches.current.add(event.pointerId); + most.current = Math.max(most.current, touches.current.size); + if (touches.current.size === 1) + touchStart.current = { at: Date.now(), moved: false, tap: true }; + else { + clearPress(); + pendingPick.current = undefined; + const { at, moved } = touchStart.current; + if (Date.now() - at > TAP_MS || moved) touchStart.current.tap = false; + else cancel(); + return; + } + } + const point = cellAt(event, false); + if (!point) return; + if (offset) { + setCursor(aim(point)); + return; + } + begin(point, event.altKey, touch); + if (touch && STROKE_TOOLS.has(tool) && colored(point)) + press.current = setTimeout(() => { + press.current = undefined; + cancel(); + pick(point); + }, LONG_PRESS_MS); + }; + const onMove = (event: ReactPointerEvent) => { + const point = cellAt(event); + if (!point) return; + if (offset) { + if (touches.current.size > 0 || event.pointerType !== 'touch') { + const target = aim(point); + if (cursor?.join() !== target.join()) setCursor(target); + move(target, event.shiftKey); + } + return; + } + const current = gesture.current; + if (current && current.points.at(-1)?.join() !== point.join()) { + clearPress(); + touchStart.current.moved = true; + } + move(point, event.shiftKey); + }; + const onUp = (event: ReactPointerEvent) => { + clearPress(); + if (event.pointerType === 'touch') { + touches.current.delete(event.pointerId); + if (touches.current.size > 0) return; + const count = most.current; + most.current = 0; + if (count > 1) { + if (touchStart.current.tap) { + if (count === 2) undo(); + if (count === 3) redo(); + } else end(gesture.current?.points.at(-1), false); + return; + } + const picked = pendingPick.current; + pendingPick.current = undefined; + if (picked) pick(picked); + } + if (!offset || gesture.current) + end(cellAt(event) ?? gesture.current?.points.at(-1), event.shiftKey); + }; + const onAbort = (event: ReactPointerEvent) => { + clearPress(); + pendingPick.current = undefined; + touches.current.delete(event.pointerId); + if (touches.current.size === 0) most.current = 0; + cancel(); + }; const save = async () => { if (empty || busy) return; setBusy(true); @@ -154,11 +370,6 @@ export function CustomPartEditor({ data-part-ink={own ? `${own.color}:${own.tone}` : undefined} data-part-other={!own && other ? 'true' : undefined} style={shown ? { background: inkColor(recipe, shown) } : undefined} - onPointerDown={(event) => { - event.preventDefault(); - start(x, y); - }} - onPointerEnter={() => extend(x, y)} />, ); } @@ -234,6 +445,91 @@ export function CustomPartEditor({ > + + + +
+ {tool === 'pencil' ? ( + + {t('profile.avatar.part.shade')} + {SHADES.map(([key, value]) => ( + + ))} + + ) : null} + {tool === 'gradient' ? ( + <> + + {t('profile.avatar.part.gradientTo')} + {PART_TONES.map((tone) => ( + + ))} + + + ) : null} + {tool === 'noise' ? ( + + + + + ) : null} + {t('profile.avatar.part.toolHint')}
{ + if (event.pointerType !== 'touch' && !offset) onUp(event); + }} >
{cells}
+ {offset && cursor ? ( +
+ {offset ? ( + + ) : null}