()
| 174 | return spriteColWidth(nameWidth) + SPRITE_PADDING_X + bubble; |
| 175 | } |
| 176 | export function CompanionSprite(): React.ReactNode { |
| 177 | const reaction = useAppState(s => s.companionReaction); |
| 178 | const petAt = useAppState(s => s.companionPetAt); |
| 179 | const focused = useAppState(s => s.footerSelection === 'companion'); |
| 180 | const setAppState = useSetAppState(); |
| 181 | const { |
| 182 | columns |
| 183 | } = useTerminalSize(); |
| 184 | const [tick, setTick] = useState(0); |
| 185 | const lastSpokeTick = useRef(0); |
| 186 | // Sync-during-render (not useEffect) so the first post-pet render already |
| 187 | // has petStartTick=tick and petAge=0 — otherwise frame 0 is skipped. |
| 188 | const [{ |
| 189 | petStartTick, |
| 190 | forPetAt |
| 191 | }, setPetStart] = useState({ |
| 192 | petStartTick: 0, |
| 193 | forPetAt: petAt |
| 194 | }); |
| 195 | if (petAt !== forPetAt) { |
| 196 | setPetStart({ |
| 197 | petStartTick: tick, |
| 198 | forPetAt: petAt |
| 199 | }); |
| 200 | } |
| 201 | useEffect(() => { |
| 202 | const timer = setInterval(setT => setT((t: number) => t + 1), TICK_MS, setTick); |
| 203 | return () => clearInterval(timer); |
| 204 | }, []); |
| 205 | useEffect(() => { |
| 206 | if (!reaction) return; |
| 207 | lastSpokeTick.current = tick; |
| 208 | const timer = setTimeout(setA => setA((prev: AppState) => prev.companionReaction === undefined ? prev : { |
| 209 | ...prev, |
| 210 | companionReaction: undefined |
| 211 | }), BUBBLE_SHOW * TICK_MS, setAppState); |
| 212 | return () => clearTimeout(timer); |
| 213 | // eslint-disable-next-line react-hooks/exhaustive-deps -- tick intentionally captured at reaction-change, not tracked |
| 214 | }, [reaction, setAppState]); |
| 215 | if (!feature('BUDDY')) return null; |
| 216 | const companion = getCompanion(); |
| 217 | if (!companion || getGlobalConfig().companionMuted) return null; |
| 218 | const color = RARITY_COLORS[companion.rarity]; |
| 219 | const colWidth = spriteColWidth(stringWidth(companion.name)); |
| 220 | const bubbleAge = reaction ? tick - lastSpokeTick.current : 0; |
| 221 | const fading = reaction !== undefined && bubbleAge >= BUBBLE_SHOW - FADE_WINDOW; |
| 222 | const petAge = petAt ? tick - petStartTick : Infinity; |
| 223 | const petting = petAge * TICK_MS < PET_BURST_MS; |
| 224 | |
| 225 | // Narrow terminals: collapse to one-line face. When speaking, the quip |
| 226 | // replaces the name beside the face (no room for a bubble). |
| 227 | if (columns < MIN_COLS_FOR_FULL_SPRITE) { |
| 228 | const quip = reaction && reaction.length > NARROW_QUIP_CAP ? reaction.slice(0, NARROW_QUIP_CAP - 1) + '…' : reaction; |
| 229 | const label = quip ? `"${quip}"` : focused ? ` ${companion.name} ` : companion.name; |
| 230 | return <Box paddingX={1} alignSelf="flex-end"> |
| 231 | <Text> |
| 232 | {petting && <Text color="autoAccept">{figures.heart} </Text>} |
| 233 | <Text bold color={color}> |
nothing calls this directly
no test coverage detected