Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file added src/renderer/src/assets/emissary/angry.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added src/renderer/src/assets/emissary/ashamed.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added src/renderer/src/assets/emissary/awaiting.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added src/renderer/src/assets/emissary/bored.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added src/renderer/src/assets/emissary/broody.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added src/renderer/src/assets/emissary/browsing.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added src/renderer/src/assets/emissary/cheeky-2.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added src/renderer/src/assets/emissary/cheeky-grin.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added src/renderer/src/assets/emissary/cheeky.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added src/renderer/src/assets/emissary/chortle.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added src/renderer/src/assets/emissary/chuckling.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added src/renderer/src/assets/emissary/concussion.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added src/renderer/src/assets/emissary/confident.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added src/renderer/src/assets/emissary/confused.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added src/renderer/src/assets/emissary/crying.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added src/renderer/src/assets/emissary/flirting.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added src/renderer/src/assets/emissary/giggling.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added src/renderer/src/assets/emissary/hunger.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added src/renderer/src/assets/emissary/idle-cheeky.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added src/renderer/src/assets/emissary/idle-sitting.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added src/renderer/src/assets/emissary/idle.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added src/renderer/src/assets/emissary/joy.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added src/renderer/src/assets/emissary/keen.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added src/renderer/src/assets/emissary/lonely.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added src/renderer/src/assets/emissary/romantic.png
Binary file added src/renderer/src/assets/emissary/sad-1.png
Binary file added src/renderer/src/assets/emissary/sad-2.png
Binary file added src/renderer/src/assets/emissary/searching.png
Binary file added src/renderer/src/assets/emissary/secretive.png
Binary file added src/renderer/src/assets/emissary/shock.png
Binary file added src/renderer/src/assets/emissary/smiling.png
Binary file added src/renderer/src/assets/emissary/smirks.png
Binary file added src/renderer/src/assets/emissary/speed.png
Binary file added src/renderer/src/assets/emissary/swimming.png
Binary file added src/renderer/src/assets/emissary/teasing.png
Binary file added src/renderer/src/assets/emissary/waving-hello.png
Binary file added src/renderer/src/assets/emissary/yearning.png
77 changes: 77 additions & 0 deletions src/renderer/src/components/EmissaryFigure.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import React from 'react'
import type { EmissaryAnimation, EmissaryEmotion } from '../screens/fishtank-lib'
import { ANIMATION_LABELS } from '../screens/fishtank-lib'
import type { ServiceStatus } from '../../../shared/types'

// Dynamically import all portrait PNGs from the emissary assets folder.
// Vite resolves these at build time — no manual list needed as portraits grow.
const _emotionModules = import.meta.glob('../assets/emissary/*.png', {
eager: true,
import: 'default',
}) as Record<string, string>

const EMOTION_IMAGES: Record<string, string> = Object.fromEntries(
Object.entries(_emotionModules).map(([path, url]) => {
const key = path.split('/').pop()!.replace('.png', '')
return [key, url]
}),
)

function getEmotionImage(emotion: EmissaryEmotion): string {
return EMOTION_IMAGES[emotion] ?? EMOTION_IMAGES['idle'] ?? ''
}

interface EmissaryFigureProps {
emotion: EmissaryEmotion
animation: EmissaryAnimation
status: ServiceStatus
name: string
style?: React.CSSProperties
onClick?: () => void
onKeyDown?: (e: React.KeyboardEvent) => void
}

export const EmissaryFigure: React.FC<EmissaryFigureProps> = ({
emotion,
animation,
status,
name,
style,
onClick,
onKeyDown,
}) => {
const src = getEmotionImage(emotion)
const haloGlow = status === 'running'
? '0 0 60px rgba(0,200,212,0.35), 0 0 120px rgba(0,200,212,0.15)'
: '0 0 30px rgba(0,200,212,0.18), 0 0 60px rgba(0,200,212,0.07)'

return (
<div
onClick={onClick}
role="button"
tabIndex={0}
aria-label={`Click ${name} to interact`}
onKeyDown={onKeyDown}
className="fishtank-emissary"
Comment on lines +49 to +55

Copilot AI Apr 20, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

EmissaryFigure renders a div with role="button"/tabIndex={0}, but it doesn’t provide default keyboard activation behavior (Enter/Space) unless every caller remembers to pass an onKeyDown handler. Since the component itself declares button semantics, consider handling Enter/Space internally (and still calling any passed onKeyDown) so the component is accessible by default.

Copilot uses AI. Check for mistakes.
style={style}
>
{/* Glow halo ring sits behind the character */}
<div
className="fishtank-emissary-halo"
style={{ boxShadow: haloGlow }}
/>

{/* Merman portrait PNG */}
<img
src={src}
alt={`${name} — ${emotion}`}
className="fishtank-emissary-img"
/>

{/* Action label */}
<div className="fishtank-emissary-label">
{ANIMATION_LABELS[animation]}
</div>
</div>
)
}
45 changes: 36 additions & 9 deletions src/renderer/src/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -519,17 +519,42 @@ main > div {
cursor: pointer;
}

.fishtank-emissary-orb {
width: 130px;
height: 130px;
/* Glow halo — radial soft ring that sits behind the PNG portrait */
.fishtank-emissary-halo {
position: absolute;
width: 220px;
height: 220px;
border-radius: 50%;
background: radial-gradient(circle at 40% 35%, rgba(0,200,212,0.15), rgba(0,200,212,0.03));
border: 2px solid rgba(0,200,212,0.25);
display: flex;
align-items: center;
justify-content: center;
font-size: 60px;
background: radial-gradient(circle, rgba(0,200,212,0.12) 0%, transparent 70%);
border: 1.5px solid rgba(0,200,212,0.18);
top: 50%;
left: 50%;
transform: translate(-50%, -55%);
pointer-events: none;
transition: box-shadow 1s;
animation: haloBreath 4s ease-in-out infinite;
}

@keyframes haloBreath {
0%, 100% { opacity: 0.8; transform: translate(-50%, -55%) scale(1); }
50% { opacity: 1; transform: translate(-50%, -55%) scale(1.05); }
}

/* The merman portrait PNG */
.fishtank-emissary-img {
width: 260px;
height: 310px;
object-fit: contain;
object-position: center bottom;
filter: drop-shadow(0 0 18px rgba(0,200,212,0.35)) drop-shadow(0 0 6px rgba(0,200,212,0.5));
position: relative;
z-index: 2;
transition: filter 0.8s ease, transform 0.3s ease;
}

.fishtank-emissary:hover .fishtank-emissary-img {
filter: drop-shadow(0 0 28px rgba(0,200,212,0.5)) drop-shadow(0 0 10px rgba(0,200,212,0.6));
transform: scale(1.03);
}

.fishtank-emissary-label {
Expand All @@ -540,6 +565,8 @@ main > div {
font-weight: 500;
text-align: center;
max-width: 200px;
position: relative;
z-index: 2;
}

.fishtank-speech {
Expand Down
31 changes: 11 additions & 20 deletions src/renderer/src/screens/Fishtank.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { IPC_CHANNELS } from '../../../shared/ipc'
import { SeabedCanvas } from '../components/SeabedCanvas'
import { HabitSuggestion } from '../components/HabitSuggestion'
import { Tooltip } from '../components/Tooltip'
import { EmissaryFigure } from '../components/EmissaryFigure'
import {
type Weather,
type EmissaryAnimation,
Expand All @@ -16,8 +17,8 @@ import {
WEATHER_CAUSTIC_OPACITY,
ANIMATIONS,
ANIMATION_LABELS,
ANIMATION_EMOJIS,
getEmissaryStyle,
getEmissaryEmotion,
getSayings,
getStatusText,
getClickResponse,
Expand Down Expand Up @@ -251,6 +252,7 @@ export const Fishtank: React.FC<FishtankProps> = ({ status, recentTasks = [], on
}, [])

const emissaryStyle = getEmissaryStyle(animation, status)
const emissaryEmotion = getEmissaryEmotion(animation, status, weather)

return (
<div className="fishtank-page">
Expand Down Expand Up @@ -392,27 +394,16 @@ export const Fishtank: React.FC<FishtankProps> = ({ status, recentTasks = [], on
{/* Procedural seabed */}
<SeabedCanvas width={tankSize.width} height={tankSize.height} seed={seabedSeed} weather={weather} />

{/* The Emissary — clickable for interaction */}
<div
{/* The Emissary — PNG portrait, glow halo, action label */}
<EmissaryFigure
emotion={emissaryEmotion}
animation={animation}
status={status}
name={name}
style={emissaryStyle}
onClick={handleEmissaryClick}
role="button"
tabIndex={0}
aria-label={`Click ${name} to interact`}
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); handleEmissaryClick() } }}
className="fishtank-emissary"
style={emissaryStyle}
>
<div className="fishtank-emissary-orb" style={{
boxShadow: status === 'running'
? '0 0 50px rgba(0,200,212,0.3), 0 0 100px rgba(0,200,212,0.1)'
: '0 0 25px rgba(0,200,212,0.12)',
}}>
{ANIMATION_EMOJIS[animation]}
</div>
<div className="fishtank-emissary-label">
{ANIMATION_LABELS[animation]}
</div>
</div>
/>

{/* Speech bubble */}
<div key={sayingKey} aria-live="polite" aria-label={`${name} says`} className="fishtank-speech">
Expand Down
92 changes: 92 additions & 0 deletions src/renderer/src/screens/fishtank-lib/emissary.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { ServiceStatus } from '../../../../shared/types'
import type { Weather } from './weather'

// --- Expanded idle animations ---
export type EmissaryAnimation =
Expand Down Expand Up @@ -99,3 +100,94 @@ export function getEmissaryStyle(animation: EmissaryAnimation, status: ServiceSt
}
return { ...base, animation: map[animation] || 'emissaryFloat 4s ease-in-out infinite' }
}

// ── Emotion mapping for PNG sprite selection ──────────────────────────────────

export type EmissaryEmotion =
| 'angry'
| 'ashamed'
| 'awaiting'
| 'blowing-bubbles'
| 'bored'
| 'broody'
| 'browsing'
| 'cheeky-2'
| 'cheeky-grin'
| 'cheeky'
| 'chortle'
| 'chuckling'
| 'concussion'
| 'confident'
| 'confused'
| 'crying'
| 'flirting'
| 'giggling'
| 'hunger'
| 'idle-cheeky'
| 'idle-sitting'
| 'idle'
| 'joy'
| 'keen'
| 'lonely'
| 'romantic-interest'
| 'romantic'
| 'sad-1'
| 'sad-2'
| 'searching'
| 'secretive'
| 'shock'
| 'smiling'
| 'smirks'
| 'speed'
| 'super-playful'
| 'swimming'
| 'teasing'
| 'very-confused'
| 'waving-hello'
| 'yearning'

/**
* Derives which portrait image to display based on the current animation,
* service status, and weather state.
*
* Priority (highest → lowest):
* 1. Error status → shock
* 2. Thunderstorm weather → shock
* 3. Running status → keen
* 4. Golden weather → joy
* 5. Animation-specific portrait
* 6. Default → idle
*/
export function getEmissaryEmotion(
animation: EmissaryAnimation,
status: ServiceStatus,
weather: Weather,
): EmissaryEmotion {
if (status === 'error') return 'shock'
if (weather === 'thunderstorm') return 'shock'
if (status === 'running') return 'keen'
if (weather === 'golden') return 'joy'

const map: Record<EmissaryAnimation, EmissaryEmotion> = {
floating: 'idle',
swimming: 'swimming',
thinking: 'broody',
'examining-scroll': 'searching',
waving: 'waving-hello',
stretching: 'idle-sitting',
gazing: 'yearning',
flirty: 'flirting',
fearful: 'shock',
Comment on lines +176 to +180

Copilot AI Apr 20, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The PR description says each animation state now resolves to a distinct portrait, but this mapping still reuses emotions across multiple animations (e.g. stretching and napping both map to idle-sitting, and there are other repeats further down). Either adjust the mapping to use unique portraits per animation, or update the PR description/comment so it matches the intended behavior.

Copilot uses AI. Check for mistakes.
preening: 'smirks',
'collecting-pearls':'keen',
'chasing-fish': 'super-playful',
flexing: 'confident',
singing: 'chortle',
napping: 'idle-sitting',
beckoning: 'romantic',
meditating: 'awaiting',
'juggling-shells': 'cheeky',
posing: 'smirks',
}
return map[animation] ?? 'idle'
}
2 changes: 2 additions & 0 deletions src/renderer/src/screens/fishtank-lib/index.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
export { type Weather, deriveWeather, WEATHER_BACKGROUNDS, WEATHER_CAUSTIC_OPACITY } from './weather'
export {
type EmissaryAnimation,
type EmissaryEmotion,
ANIMATIONS,
ANIMATION_LABELS,
ANIMATION_EMOJIS,
getEmissaryStyle,
getEmissaryEmotion,
Comment on lines +4 to +9

Copilot AI Apr 20, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ANIMATION_EMOJIS is still being re-exported here, but it no longer appears to be used anywhere in the repo after swapping to EmissaryFigure (search shows only its definition + this export). Consider removing the export (and the constant) to avoid dead code and keep the public surface of fishtank-lib minimal.

Copilot uses AI. Check for mistakes.
} from './emissary'
export { getSayings, getStatusText, getClickResponse } from './sayings'
export {
Expand Down
16 changes: 16 additions & 0 deletions src/renderer/src/vite-env.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
/// <reference types="vite/client" />

declare module '*.png' {
const src: string
export default src
}

declare module '*.jpg' {
const src: string
export default src
}

declare module '*.svg' {
const src: string
export default src
}