268 lines
8.5 KiB
TypeScript

'use client'
import { useScene } from '@pascal-app/core'
import { SegmentedControl, SliderControl, ToggleControl, useEditor } from '@pascal-app/editor'
import { FLOWER_PRESET_LIST } from './flower-presets'
import type { FlowerPreset } from './flower-schema'
import { GRASS_PRESET_LIST } from './grass-presets'
import type { GrassPreset } from './grass-schema'
import { TREE_PRESET_LIST } from './presets'
import type { TreePreset } from './schema'
import { type TreesPanelMode as Mode, useTreesStore } from './store'
const KIND: Record<Mode, string> = {
trees: 'trees:tree',
flowers: 'trees:flower',
grass: 'trees:grass',
}
const NOUN: Record<Mode, string> = { trees: 'tree', flowers: 'flower', grass: 'grass' }
/**
* The plugin's left-rail panel. A Trees / Flowers / Grass segmented control
* switches the brush; picking a preset arms placement for that kind
* (`setTool('trees:*')` + build mode). The count chip reads the scene reactively,
* closing the triangle: panel → store → tool → scene → panel. It composes the
* host's exported controls (`SegmentedControl`/`SliderControl`/`ToggleControl`)
* so the brush matches the right-hand inspector pixel-for-pixel.
*/
export default function TreesPanel() {
// Section lives in the plugin store (not local state) so "find in catalog"
// can point the panel at the found node's section — see find-sync.ts.
const mode = useTreesStore((s) => s.mode)
const setMode = useTreesStore((s) => s.setMode)
const activeTool = useEditor((s) => s.tool)
const count = useScene(
(s) => Object.values(s.nodes).filter((n) => (n.type as string) === KIND[mode]).length,
)
const arming = activeTool === KIND[mode]
return (
<div className="flex flex-col gap-4 p-4 text-sidebar-foreground">
<header className="flex flex-col gap-3">
<div className="flex items-center justify-between">
<h2 className="font-semibold text-base">自然资产</h2>
<span className="rounded-full bg-sidebar-accent px-2 py-0.5 text-sidebar-foreground/70 text-xs">
{count} planted
</span>
</div>
<SegmentedControl
onChange={setMode}
options={[
{ label: 'Trees', value: 'trees' },
{ label: 'Flowers', value: 'flowers' },
{ label: 'Grass', value: 'grass' },
]}
value={mode}
/>
<p className="text-sidebar-foreground/50 text-xs">
{arming
? 'Click the ground to plant. Press Esc to stop.'
: `Pick ${mode === 'grass' ? 'a grass' : `a ${NOUN[mode]}`}, then click the ground.`}
</p>
</header>
{mode === 'trees' && <TreesSection arming={arming} />}
{mode === 'flowers' && <FlowersSection arming={arming} />}
{mode === 'grass' && <GrassSection arming={arming} />}
<footer className="-mx-4 -mb-4 sticky bottom-0 mt-1 border-sidebar-border/50 border-t bg-sidebar px-4 py-3 text-[11px] text-sidebar-foreground/50 leading-relaxed">
Trees generated with{' '}
<a
className="underline decoration-dotted underline-offset-2 hover:text-sidebar-foreground/70"
href="https://github.com/dgreenheck/ez-tree"
rel="noreferrer"
target="_blank"
>
ez-tree
</a>{' '}
by{' '}
<a
className="underline decoration-dotted underline-offset-2 hover:text-sidebar-foreground/70"
href="https://x.com/dangreenheck"
rel="noreferrer"
target="_blank"
>
Daniel Greenheck
</a>{' '}
(MIT).
</footer>
</div>
)
}
function TreesSection({ arming }: { arming: boolean }) {
const selected = useTreesStore((s) => s.preset)
const size = useTreesStore((s) => s.size)
const height = useTreesStore((s) => s.height)
const foliageDensity = useTreesStore((s) => s.foliageDensity)
const trunkThickness = useTreesStore((s) => s.trunkThickness)
const leafless = useTreesStore((s) => s.leafless)
const activate = (preset: TreePreset) => {
useTreesStore.getState().setPreset(preset)
useEditor.getState().setTool('trees:tree')
useEditor.getState().setMode('build')
}
return (
<>
<PresetGrid items={TREE_PRESET_LIST} onPick={activate} selected={arming ? selected : null} />
{selected !== 'trellis' && (
<div className="flex flex-col gap-2">
<SegmentedControl
onChange={useTreesStore.getState().setSize}
options={[
{ label: 'S', value: 'small' },
{ label: 'M', value: 'medium' },
{ label: 'L', value: 'large' },
]}
value={size}
/>
</div>
)}
<div className="flex flex-col gap-0.5">
<SliderControl
label="Height"
max={15}
min={1}
onChange={useTreesStore.getState().setHeight}
precision={1}
restoreOnCommit={false}
step={0.5}
unit="m"
value={height}
/>
{!leafless && (
<SliderControl
label="Foliage"
max={1.5}
min={0}
onChange={useTreesStore.getState().setFoliageDensity}
precision={1}
restoreOnCommit={false}
step={0.1}
value={foliageDensity}
/>
)}
<SliderControl
label="Trunk"
max={2.5}
min={0.3}
onChange={useTreesStore.getState().setTrunkThickness}
precision={1}
restoreOnCommit={false}
step={0.1}
value={trunkThickness}
/>
<ToggleControl
checked={leafless}
label="Bare (leafless)"
onChange={useTreesStore.getState().setLeafless}
/>
</div>
</>
)
}
function FlowersSection({ arming }: { arming: boolean }) {
const selected = useTreesStore((s) => s.flowerPreset)
const height = useTreesStore((s) => s.flowerHeight)
const activate = (preset: FlowerPreset) => {
useTreesStore.getState().setFlowerPreset(preset)
useEditor.getState().setTool('trees:flower')
useEditor.getState().setMode('build')
}
return (
<>
<PresetGrid
items={FLOWER_PRESET_LIST}
onPick={activate}
selected={arming ? selected : null}
/>
<SliderControl
label="Height"
max={2}
min={0.2}
onChange={useTreesStore.getState().setFlowerHeight}
precision={2}
restoreOnCommit={false}
step={0.05}
unit="m"
value={height}
/>
</>
)
}
function GrassSection({ arming }: { arming: boolean }) {
const selected = useTreesStore((s) => s.grassPreset)
const height = useTreesStore((s) => s.grassHeight)
const activate = (preset: GrassPreset) => {
useTreesStore.getState().setGrassPreset(preset)
useEditor.getState().setTool('trees:grass')
useEditor.getState().setMode('build')
}
return (
<>
<PresetGrid items={GRASS_PRESET_LIST} onPick={activate} selected={arming ? selected : null} />
<SliderControl
label="Height"
max={2}
min={0.1}
onChange={useTreesStore.getState().setGrassHeight}
precision={2}
restoreOnCommit={false}
step={0.05}
unit="m"
value={height}
/>
</>
)
}
function PresetGrid<T extends string>({
items,
selected,
onPick,
}: {
items: ReadonlyArray<{ id: T; label: string; thumbnail: string }>
selected: T | null
onPick: (id: T) => void
}) {
return (
<div className="grid grid-cols-2 gap-2">
{items.map((item) => {
const isSelected = selected === item.id
return (
<button
className={`group relative flex flex-col gap-2 rounded-xl border p-2 transition-all ${
isSelected
? 'border-sidebar-ring bg-sidebar-accent shadow-sm'
: 'border-sidebar-border hover:border-sidebar-ring/50 hover:bg-sidebar-accent/40'
}`}
key={item.id}
onClick={() => onPick(item.id)}
type="button"
>
<img
alt=""
aria-hidden
className="aspect-square w-full rounded-lg bg-[#f3f4f6] object-cover ring-1 ring-black/10 transition-transform group-hover:scale-[1.02]"
src={item.thumbnail}
/>
<span className="pl-0.5 font-medium text-xs">{item.label}</span>
{isSelected && (
<span className="absolute top-3 right-3 h-2 w-2 rounded-full bg-sidebar-ring ring-2 ring-sidebar-accent" />
)}
</button>
)
})}
</div>
)
}