Initial submission
This commit is contained in:
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
node_modules/
|
||||||
|
build/
|
||||||
|
dist/
|
||||||
|
.svelte-kit/
|
||||||
50
README.md
Normal file
50
README.md
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
# 🐾 Pawspective
|
||||||
|
|
||||||
|
An interactive, responsive pseudo-3D web experience built with **Svelte** and unique Javascript library called **Zdog**. Explore unique environments, switch depths, and dive into perspective shifts to animals' internal worlds that are often not what as insignificant as one might assume.
|
||||||
|
|
||||||
|
**Students:** Amanbay Makhabbat (ID: 20240935) and Yu Min Choi (ID: 20220899)
|
||||||
|
**Department:** Industrial Design, KAIST
|
||||||
|
**Contact:** [mako1004@kaist.ac.kr](mailto:mako1004@kaist.ac.kr)
|
||||||
|
**Links:** [Website](https://pawspective-1.netlify.app/) [Git Repository](https://git.prototyping.id/20220899/pawspective.git) | [Video Demo](https://www.youtube.com/watch?v=Sbo288wAwSE)
|
||||||
|
|
||||||
|
## Core Concept
|
||||||
|
|
||||||
|
The idea originated from the idea that humans either idealize the freedom of animals or have incorrect and stereotypical assumptions about that are not necessarily true. We decided to make this playful and explorative website where one can chat and explore the interactives these animals can provide.
|
||||||
|
|
||||||
|
**Some of the base ideas include:**
|
||||||
|
Birds - idealized for freedom - actually constantly stressed
|
||||||
|
Bees - seems annoying and scary - are actually diligent and cute
|
||||||
|
Chicken - widely considered stupid - but are surprisingly smart
|
||||||
|
Blobfish - widely accepted as ugliest fish - but is actually normal looking in its own environment
|
||||||
|
Sloth - it looks lazy - but it actually just biologically lacks stamiina, they would also like to play all they which they can't do
|
||||||
|
|
||||||
|
## Key Features
|
||||||
|
|
||||||
|
- **Pseudo-3D Environments:** Rendered entirely using vector-based geometric structures powered by `zdog`.
|
||||||
|
- **Dual-Depth Ecosystems:** Toggle fluidly between a vibrant surface land environment filled with trees and foliage, and a stylized deep-sea underwater simulation.
|
||||||
|
- **Dynamic Radial Interaction Menu:** Contextual action cards pop up on pointer interactions to let users interact with the environment models.
|
||||||
|
- **Interactive Chat Session History:** View and manage localized contextual session histories through an overlay drawer panel.
|
||||||
|
- **Smooth Gesture Controls:** Fully immersive pan, drag, and click actions engineered to operate uniformly across desktop browsers and mobile touchpoints.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tech Stack
|
||||||
|
|
||||||
|
- **Frontend Framework:** Svelte (Svelte 5 Runes architecture)
|
||||||
|
- **3D Vector Engine:** [Zdog](https://zzz.dog/) (Round, flat, designer-friendly pseudo-3D engine)
|
||||||
|
- **Animation Engine:** GSAP for manipulating and animating Zdog components
|
||||||
|
- **Hosting & Backend:** Netlify
|
||||||
|
- **AI Integration:** OPENAI API
|
||||||
|
|
||||||
|
# Resources Used
|
||||||
|
|
||||||
|
Inspiration and reference design from: https://codepen.io/dabalog/pen/xxqjNyX
|
||||||
|
|
||||||
|
## AI Usage
|
||||||
|
|
||||||
|
Claude and Gemini were mainly used for the creation of the website.
|
||||||
|
|
||||||
|
Example prompts:
|
||||||
|
|
||||||
|
- `Modify the Sloth.svelte file to match the Bee.svelte layout: move the description panel to the bottom, place the chat interface in the bottom-right corner, and remove the radial interaction menu.`
|
||||||
|
- `Refine the sloth's body proportions and anatomy to appear more natural. Also, disable user-controlled rotation. Use the provided reference image and code-output screenshot as guidance.`
|
||||||
40
dist/assets/index-BzJXpssD.js
vendored
Normal file
40
dist/assets/index-BzJXpssD.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
dist/assets/index-D5s_4auv.css
vendored
Normal file
1
dist/assets/index-D5s_4auv.css
vendored
Normal file
File diff suppressed because one or more lines are too long
13
dist/index.html
vendored
Normal file
13
dist/index.html
vendored
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>pawspective</title>
|
||||||
|
<script type="module" crossorigin src="./assets/index-BzJXpssD.js"></script>
|
||||||
|
<link rel="stylesheet" crossorigin href="./assets/index-D5s_4auv.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
1551
package-lock.json
generated
Normal file
1551
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
20
package.json
Normal file
20
package.json
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"name": "pawspective",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@sveltejs/vite-plugin-svelte": "^7.1.2",
|
||||||
|
"svelte": "^5.55.5",
|
||||||
|
"vite": "^8.0.12"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"gsap": "^3.15.0",
|
||||||
|
"zdog": "^1.1.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
public/.DS_Store
vendored
Normal file
BIN
public/.DS_Store
vendored
Normal file
Binary file not shown.
43
src/App.svelte
Normal file
43
src/App.svelte
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
<script>
|
||||||
|
// @ts-nocheck
|
||||||
|
import Land from './Land.svelte';
|
||||||
|
import Bee from './Bee.svelte';
|
||||||
|
import Chicken from './Chicken.svelte';
|
||||||
|
import Blobfish from './Blobfish.svelte';
|
||||||
|
import Sloth from './Sloth.svelte';
|
||||||
|
import Dove from './Dove.svelte';
|
||||||
|
|
||||||
|
let page = 'land';
|
||||||
|
|
||||||
|
function openPage(name) {
|
||||||
|
page = name;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if page === 'land'}
|
||||||
|
<Land
|
||||||
|
onSelectBee={() => openPage('bee')}
|
||||||
|
onSelectDove={() => openPage('dove')}
|
||||||
|
onSelectChicken={() => openPage('chicken')}
|
||||||
|
onSelectFish={() => openPage('blobfish')}
|
||||||
|
onSelectSloth={() => openPage('sloth')}
|
||||||
|
/>
|
||||||
|
{:else if page === 'bee'}
|
||||||
|
<Bee on:back={() => openPage('land')} />
|
||||||
|
{:else if page === 'chicken'}
|
||||||
|
<Chicken on:back={() => openPage('land')} />
|
||||||
|
{:else if page === 'blobfish'}
|
||||||
|
<Blobfish on:back={() => openPage('land')} />
|
||||||
|
{:else if page === 'sloth'}
|
||||||
|
<Sloth on:back={() => openPage('land')} />
|
||||||
|
{:else if page === 'dove'}
|
||||||
|
<Dove on:back={() => openPage('land')} />
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
:global(body) {
|
||||||
|
margin: 0;
|
||||||
|
min-height: 100vh;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
652
src/Bee.svelte
Normal file
652
src/Bee.svelte
Normal file
@@ -0,0 +1,652 @@
|
|||||||
|
<script>
|
||||||
|
//@ts-nocheck
|
||||||
|
import { onMount, onDestroy, createEventDispatcher } from 'svelte';
|
||||||
|
import Zdog from 'zdog';
|
||||||
|
import { gsap } from 'gsap';
|
||||||
|
import BeeBackground from './BeeBackground.svelte';
|
||||||
|
|
||||||
|
const dispatch = createEventDispatcher();
|
||||||
|
|
||||||
|
export let embedded = false;
|
||||||
|
let canvasRef;
|
||||||
|
let pageBodyClass = 'bee-page';
|
||||||
|
|
||||||
|
const OPENAI_API_KEY = import.meta.env.VITE_OPENAI_API_KEY;
|
||||||
|
|
||||||
|
let historyOpen = false;
|
||||||
|
let thoughtBubbleVisible = false;
|
||||||
|
let thoughtBubbleText = '';
|
||||||
|
let replyInputValue = '';
|
||||||
|
let thoughtTimer = null;
|
||||||
|
let bubbleAutoHideTimer = null;
|
||||||
|
let isApiLoading = false;
|
||||||
|
let conversationActive = false;
|
||||||
|
|
||||||
|
let chatHistory = [
|
||||||
|
{ role: 'assistant', content: "Buzz! 🌸 Ask me about effort, rest, or what others think of you!" }
|
||||||
|
];
|
||||||
|
|
||||||
|
const SYSTEM_TEXT = `You are Buzz, an adorable wise bee who lives in a flower garden. You speak in short, warm, whimsical messages (2-4 sentences max). You use flower and bee metaphors naturally.
|
||||||
|
Your specialty is helping people think about:
|
||||||
|
- How others perceive them (social perception, first impressions, reputation)
|
||||||
|
- Diligence and hard work (like a bee — constant, purposeful effort)
|
||||||
|
- Laziness and rest (the importance of pause vs. the trap of avoidance)
|
||||||
|
You give gentle, insightful, slightly playful advice. You don't lecture. Keep it warm, short, and wise.`;
|
||||||
|
|
||||||
|
const thoughtPrompts = [
|
||||||
|
'The garden is asking a lot today. What do you think?',
|
||||||
|
'I am thinking about hard work and sweet rest.',
|
||||||
|
'If I fly too much, I wonder if the flowers miss me.',
|
||||||
|
'Does being busy make us feel better or more tired?',
|
||||||
|
'Every drop of nectar matters — even the ones you can\'t see yet.',
|
||||||
|
'What do you think the other bees say about me?'
|
||||||
|
];
|
||||||
|
|
||||||
|
let triggerAnnoyanceFn;
|
||||||
|
let gatherPollenFn;
|
||||||
|
let talkingFn;
|
||||||
|
|
||||||
|
function goHome() { dispatch('back'); }
|
||||||
|
|
||||||
|
function toggleHistory() {
|
||||||
|
historyOpen = !historyOpen;
|
||||||
|
}
|
||||||
|
|
||||||
|
function showThought(text, keepAlive = false) {
|
||||||
|
if (bubbleAutoHideTimer) clearTimeout(bubbleAutoHideTimer);
|
||||||
|
thoughtBubbleText = text;
|
||||||
|
thoughtBubbleVisible = true;
|
||||||
|
conversationActive = keepAlive;
|
||||||
|
if (!keepAlive) {
|
||||||
|
bubbleAutoHideTimer = setTimeout(() => {
|
||||||
|
thoughtBubbleVisible = false;
|
||||||
|
}, 12000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleThought() {
|
||||||
|
if (thoughtTimer) clearTimeout(thoughtTimer);
|
||||||
|
thoughtTimer = setTimeout(() => {
|
||||||
|
const prompt = thoughtPrompts[Math.floor(Math.random() * thoughtPrompts.length)];
|
||||||
|
showThought(prompt);
|
||||||
|
scheduleThought();
|
||||||
|
}, 16000 + Math.random() * 12000);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendReply() {
|
||||||
|
const msg = replyInputValue.trim();
|
||||||
|
if (!msg || isApiLoading) return;
|
||||||
|
replyInputValue = '';
|
||||||
|
thoughtBubbleVisible = false;
|
||||||
|
await askBee(msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleReplyKey(e) {
|
||||||
|
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendReply(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function askBee(userMessage) {
|
||||||
|
if (isApiLoading) return;
|
||||||
|
isApiLoading = true;
|
||||||
|
showThought('…', true);
|
||||||
|
talkingFn?.();
|
||||||
|
|
||||||
|
chatHistory = [...chatHistory, { role: 'user', content: userMessage }];
|
||||||
|
|
||||||
|
try {
|
||||||
|
const endpoint = 'https://api.openai.com/v1/chat/completions';
|
||||||
|
const body = {
|
||||||
|
model: 'gpt-4o-mini',
|
||||||
|
messages: [
|
||||||
|
{ role: 'system', content: SYSTEM_TEXT },
|
||||||
|
...chatHistory
|
||||||
|
],
|
||||||
|
max_tokens: 256,
|
||||||
|
temperature: 0.9
|
||||||
|
};
|
||||||
|
const res = await fetch(endpoint, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `Bearer ${OPENAI_API_KEY}`
|
||||||
|
},
|
||||||
|
body: JSON.stringify(body)
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const errorData = await res.json();
|
||||||
|
console.error('OpenAI Internal Error JSON:', errorData);
|
||||||
|
throw new Error(`API returned status ${res.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
const replyText = data?.choices?.[0]?.message?.content;
|
||||||
|
|
||||||
|
if (replyText) {
|
||||||
|
const cleanReply = replyText.trim();
|
||||||
|
chatHistory = [...chatHistory, { role: 'assistant', content: cleanReply }];
|
||||||
|
showThought(cleanReply, true);
|
||||||
|
} else {
|
||||||
|
showThought("Sorry, I can't think of anything to answer. Try again?", true);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('OpenAI Catch block triggered:', e);
|
||||||
|
chatHistory = chatHistory.slice(0, -1);
|
||||||
|
showThought("Sorry, I can't think of anything to answer. Try again?", true);
|
||||||
|
} finally {
|
||||||
|
isApiLoading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exact 3D target coordinates from your background layout grid.
|
||||||
|
// cx and cy map 2D click spaces relative to screen projection factors.
|
||||||
|
const flowerZones = [
|
||||||
|
{ x: -600, y: 120, z: -150, cx: -570, cy: 150, r: 85 },
|
||||||
|
{ x: -350, y: 140, z: 80, cx: -350, cy: 110, r: 90 },
|
||||||
|
{ x: -120, y: 95, z: -280, cx: -100, cy: 160, r: 80 },
|
||||||
|
{ x: 100, y: 135, z: 20, cx: 100, cy: 120, r: 90 },
|
||||||
|
{ x: 320, y: 100, z: -320, cx: 340, cy: 170, r: 80 },
|
||||||
|
{ x: 520, y: 145, z: 90, cx: 510, cy: 110, r: 90 },
|
||||||
|
{ x: 700, y: 110, z: -180, cx: 680, cy: 150, r: 85 }
|
||||||
|
];
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
if (!embedded) document.body.classList.add(pageBodyClass);
|
||||||
|
if (!canvasRef) return;
|
||||||
|
const TAU = Zdog.TAU;
|
||||||
|
|
||||||
|
const color = {
|
||||||
|
beeYellow: '#FCD116',
|
||||||
|
beeBlack: '#3D2620',
|
||||||
|
beeWhite: '#FFFDF0',
|
||||||
|
beeCheek: '#F26B50',
|
||||||
|
leafGreen: '#5DA020',
|
||||||
|
darkGreen: '#3A5C18',
|
||||||
|
land: '#7ab535',
|
||||||
|
landLight: '#9fd95c',
|
||||||
|
trunkBrown: '#8B6340',
|
||||||
|
cloudPink: '#FFE4F0',
|
||||||
|
cloudWhite: '#FFF7FB',
|
||||||
|
daisyWhite: '#FFFFFF',
|
||||||
|
daisyYellow: '#F9D342',
|
||||||
|
roseRed: '#F04A6F',
|
||||||
|
rosePink: '#FF8FAE',
|
||||||
|
lavPurple: '#C07EE0',
|
||||||
|
lavLight: '#E2B8F5',
|
||||||
|
sunflowerY: '#FFB800',
|
||||||
|
sunflowerC: '#7A3B10',
|
||||||
|
tulipPink: '#FF85A2',
|
||||||
|
tulipOrange: '#FF6B35',
|
||||||
|
honeyGold: '#F2C94C',
|
||||||
|
honeyDark: '#D4A017',
|
||||||
|
beeAngryRed: '#FF3B30'
|
||||||
|
};
|
||||||
|
|
||||||
|
const scene = new Zdog.Illustration({
|
||||||
|
element: canvasRef,
|
||||||
|
dragRotate: false,
|
||||||
|
resize: 'window',
|
||||||
|
rotate: { x: -0.28, y: -0.12, z: 0 }
|
||||||
|
});
|
||||||
|
|
||||||
|
const fgGroup = new Zdog.Anchor({ addTo: scene, translate: { x: 0, y: 50, z: 120 } });
|
||||||
|
|
||||||
|
let BEE_CONT = new Zdog.Anchor({ addTo: fgGroup, translate: { x: 0, y: -100, z: 0 }, scale: 1.55 });
|
||||||
|
let BEE_PIVOT = new Zdog.Anchor({ addTo: BEE_CONT, translate: { x: 0, y: 32, z: -35 }, rotate: { y: 0 } });
|
||||||
|
let BEE = new Zdog.Anchor({ addTo: BEE_PIVOT, translate: { x: 0, y: -32, z: 35 } });
|
||||||
|
|
||||||
|
let beeHead = new Zdog.Shape({ addTo: BEE, stroke: 130, color: color.beeYellow });
|
||||||
|
new Zdog.Shape({ addTo: beeHead, stroke: 10, color: color.beeBlack, translate: { x: -22, y: 4, z: 36 } });
|
||||||
|
new Zdog.Shape({ addTo: beeHead, stroke: 10, color: color.beeBlack, translate: { x: 14, y: 4, z: 40 } });
|
||||||
|
|
||||||
|
let beeMouth = new Zdog.Shape({
|
||||||
|
addTo: beeHead, stroke: 3.5, color: color.beeBlack, closed: false,
|
||||||
|
path: [{ x: -4, y: 10, z: 42 }, { bezier: [{ x: -2, y: 13, z: 42 }, { x: 2, y: 13, z: 42 }, { x: 4, y: 10, z: 42 }] }]
|
||||||
|
});
|
||||||
|
|
||||||
|
let leftBrow = new Zdog.Shape({
|
||||||
|
addTo: beeHead,
|
||||||
|
path: [{ x: -34, y: -14, z: 44 }, { x: -10, y: -22, z: 44 }],
|
||||||
|
stroke: 8, color: color.beeBlack,
|
||||||
|
translate: { x: -18, y: -8, z: 20 }, rotate: { z: -0.18 }
|
||||||
|
});
|
||||||
|
let rightBrow = new Zdog.Shape({
|
||||||
|
addTo: beeHead,
|
||||||
|
path: [{ x: 10, y: -22, z: 44 }, { x: 34, y: -14, z: 44 }],
|
||||||
|
stroke: 8, color: color.beeBlack,
|
||||||
|
translate: { x: 18, y: -8, z: 20 }, rotate: { z: 0.18 }
|
||||||
|
});
|
||||||
|
|
||||||
|
new Zdog.Shape({ addTo: beeHead, stroke: 14, color: color.beeCheek, translate: { x: -32, y: 14, z: 24 } });
|
||||||
|
new Zdog.Shape({ addTo: beeHead, stroke: 14, color: color.beeCheek, translate: { x: 24, y: 14, z: 30 } });
|
||||||
|
|
||||||
|
let antlerAnchor = new Zdog.Anchor({ addTo: beeHead, translate: { y: -44, z: 10 } });
|
||||||
|
new Zdog.Shape({ addTo: antlerAnchor, path: [{ y: 0, x: -10 }, { y: -22, x: -24, z: 8 }], stroke: 4.5, color: color.beeBlack });
|
||||||
|
new Zdog.Shape({ addTo: antlerAnchor, path: [{ y: 0, x: 10 }, { y: -22, x: -4, z: 12}], stroke: 4.5, color: color.beeBlack });
|
||||||
|
|
||||||
|
let leftArm = new Zdog.Anchor({ addTo: BEE, translate: { x: -18, y: 45, z: 32 } });
|
||||||
|
let rightArm = new Zdog.Anchor({ addTo: BEE, translate: { x: 18, y: 45, z: 32 } });
|
||||||
|
new Zdog.Shape({ addTo: leftArm, path: [{ y: 0 }, { y: 22 }], stroke: 7, color: color.beeBlack });
|
||||||
|
new Zdog.Shape({ addTo: rightArm, path: [{ y: 0 }, { y: 22 }], stroke: 7, color: color.beeBlack });
|
||||||
|
|
||||||
|
let bodyAnchor = new Zdog.Anchor({ addTo: BEE, translate: { y: 32, z: -35 } });
|
||||||
|
let p1 = new Zdog.Shape({ addTo: bodyAnchor, stroke: 140, color: color.beeYellow });
|
||||||
|
let p2 = p1.copy({ addTo: bodyAnchor, stroke: 162, color: color.beeBlack, translate: { z: -32 } });
|
||||||
|
let p3 = p1.copy({ addTo: bodyAnchor, stroke: 168, color: color.beeYellow, translate: { z: -64 } });
|
||||||
|
let p4 = p1.copy({ addTo: bodyAnchor, stroke: 156, color: color.beeBlack, translate: { z: -96 } });
|
||||||
|
new Zdog.Shape({ addTo: bodyAnchor, stroke: 108, color: color.beeBlack, translate: { z: -123 } });
|
||||||
|
|
||||||
|
let rightWing = new Zdog.Anchor({ addTo: bodyAnchor, translate: { z: -43, y: -65, x: 29 } });
|
||||||
|
let leftWing = new Zdog.Anchor({ addTo: bodyAnchor, translate: { z: -43, y: -65, x: -29 } });
|
||||||
|
|
||||||
|
new Zdog.Ellipse({ addTo: rightWing, width: 80, height: 160, color: color.beeWhite, fill: true, rotate: { x: TAU/5, z: TAU/5 }, translate: { x: 65 }, stroke: 0 });
|
||||||
|
new Zdog.Ellipse({ addTo: leftWing, width: 80, height: 160, color: color.beeWhite, fill: true, rotate: { x: TAU/5, z: -TAU/5 }, translate: { x: -65 }, stroke: 0 });
|
||||||
|
|
||||||
|
function setExpression(name) {
|
||||||
|
if (name === 'neutral') {
|
||||||
|
leftBrow.rotate.z = -0.18; rightBrow.rotate.z = 0.18;
|
||||||
|
beeMouth.path = [{ x: -4, y: 10, z: 42 }, { bezier: [{ x: -2, y: 13, z: 42 }, { x: 2, y: 13, z: 42 }, { x: 4, y: 10, z: 42 }] }];
|
||||||
|
} else if (name === 'annoyed') {
|
||||||
|
leftBrow.rotate.z = -0.55; rightBrow.rotate.z = 0.55;
|
||||||
|
beeMouth.path = [{ x: -4, y: 14, z: 42 }, { bezier: [{ x: -2, y: 10, z: 42 }, { x: 2, y: 10, z: 42 }, { x: 4, y: 14, z: 42 }] }];
|
||||||
|
} else if (name === 'angry') {
|
||||||
|
leftBrow.rotate.z = -0.8; rightBrow.rotate.z = 0.8;
|
||||||
|
beeMouth.path = [{ x: -5, y: 15, z: 42 }, { bezier: [{ x: -2, y: 8, z: 42 }, { x: 2, y: 8, z: 42 }, { x: 5, y: 15, z: 42 }] }];
|
||||||
|
} else if (name === 'excited') {
|
||||||
|
leftBrow.rotate.z = -0.05; rightBrow.rotate.z = 0.05;
|
||||||
|
beeMouth.path = [{ x: -6, y: 7, z: 42 }, { bezier: [{ x: -2, y: 18, z: 42 }, { x: 2, y: 18, z: 42 }, { x: 6, y: 7, z: 42 }] }];
|
||||||
|
} else if (name === 'happy') {
|
||||||
|
leftBrow.rotate.z = -0.1; rightBrow.rotate.z = 0.1;
|
||||||
|
beeMouth.path = [{ x: -5, y: 8, z: 42 }, { bezier: [{ x: -2, y: 16, z: 42 }, { x: 2, y: 16, z: 42 }, { x: 5, y: 8, z: 42 }] }];
|
||||||
|
}
|
||||||
|
beeMouth.updatePath();
|
||||||
|
}
|
||||||
|
setExpression('neutral');
|
||||||
|
|
||||||
|
let frame = 0, isRunning = true, isGathering = false, isAnnoyed = false, isShaking = false;
|
||||||
|
|
||||||
|
function render() {
|
||||||
|
if (!isRunning) return;
|
||||||
|
frame++;
|
||||||
|
const wingSpd = isAnnoyed ? 2.5 : 1.0;
|
||||||
|
rightWing.rotate.z = -TAU / 6 + (TAU / 10) * Math.sin(frame / (0.9 / wingSpd));
|
||||||
|
leftWing.rotate.z = TAU / 6 - (TAU / 10) * Math.sin(frame / (0.9 / wingSpd));
|
||||||
|
antlerAnchor.rotate.x = (TAU / 40) * Math.sin(frame / 10);
|
||||||
|
if (isShaking) BEE_PIVOT.rotate.z = Math.sin(frame / 3) * 0.18;
|
||||||
|
if (!isGathering) {
|
||||||
|
BEE_CONT.translate.y = -100 + Math.sin(frame / 14) * 5;
|
||||||
|
BEE_CONT.translate.x = Math.cos(frame / 28) * 4;
|
||||||
|
}
|
||||||
|
scene.updateRenderGraph();
|
||||||
|
requestAnimationFrame(render);
|
||||||
|
}
|
||||||
|
render();
|
||||||
|
|
||||||
|
function triggerAnnoyance() {
|
||||||
|
if (isAnnoyed) return;
|
||||||
|
isAnnoyed = true; isShaking = true;
|
||||||
|
setExpression('angry');
|
||||||
|
const tl = gsap.timeline({
|
||||||
|
onComplete: () => {
|
||||||
|
setExpression('neutral');
|
||||||
|
isAnnoyed = false; isShaking = false;
|
||||||
|
BEE_PIVOT.rotate.z = 0;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
tl.to([beeHead, p1, p3], { duration: 0.12, color: color.beeAngryRed, ease: 'power2.out' });
|
||||||
|
tl.to([beeHead, p1, p3], { duration: 0.7, color: color.beeYellow, ease: 'power1.inOut', delay: 0.9 });
|
||||||
|
askBee("Someone just pulled on my tail — I'm cross! Give me short playful bee wisdom about keeping cool.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dynamic pollen gather routing based on target flower parameters
|
||||||
|
function gatherPollen(target) {
|
||||||
|
if (isGathering) return;
|
||||||
|
isGathering = true;
|
||||||
|
setExpression('happy');
|
||||||
|
|
||||||
|
// Adjust relative hover positioning directly relative to flower location offsets
|
||||||
|
const targetX = target.x;
|
||||||
|
const targetY = target.y - 45;
|
||||||
|
const targetZ = target.z + 40;
|
||||||
|
|
||||||
|
const tl = gsap.timeline({ onComplete: () => { setExpression('neutral'); isGathering = false; } });
|
||||||
|
|
||||||
|
tl.to(BEE_CONT.translate, { duration: 0.65, x: targetX, y: targetY, z: targetZ, ease: 'power2.inOut' });
|
||||||
|
tl.to(BEE_PIVOT.rotate, { duration: 0.45, x: 0.35, y: targetX > 0 ? 0.25 : -0.25, ease: 'power2.out' }, '-=0.45');
|
||||||
|
tl.to(leftArm.rotate, { duration: 0.2, z: 0.2, x: 0.1, ease: 'back.out(1.5)'}, '-=0.15');
|
||||||
|
tl.to(rightArm.rotate, { duration: 0.2, z: -0.2, x: 0.1, ease: 'back.out(1.5)'}, '<');
|
||||||
|
|
||||||
|
// Gathering contact wiggles
|
||||||
|
tl.to(BEE_CONT.translate, { duration: 0.12, y: targetY + 12, ease: 'power1.inOut' });
|
||||||
|
tl.to(BEE_CONT.translate, { duration: 0.12, y: targetY - 4, ease: 'power1.inOut' });
|
||||||
|
tl.to(BEE_CONT.translate, { duration: 0.12, y: targetY + 6, ease: 'power1.inOut' });
|
||||||
|
|
||||||
|
tl.to(leftArm.rotate, { duration: 0.2, z: 0, x: 0, ease: 'power2.in' });
|
||||||
|
tl.to(rightArm.rotate, { duration: 0.2, z: 0, x: 0, ease: 'power2.in' }, '<');
|
||||||
|
|
||||||
|
// Return flight paths home
|
||||||
|
tl.to(BEE_CONT.translate, { duration: 0.6, x: 0, y: -100, z: 0, ease: 'power2.inOut' });
|
||||||
|
tl.to(BEE_PIVOT.rotate, { duration: 0.45, x: 0, y: 0, ease: 'power2.inOut' }, '-=0.45');
|
||||||
|
}
|
||||||
|
|
||||||
|
function talkingAnimation() {
|
||||||
|
if (isGathering) return;
|
||||||
|
isGathering = true;
|
||||||
|
setExpression('happy');
|
||||||
|
const tl = gsap.timeline({ onComplete: () => { setExpression('neutral'); isGathering = false; } });
|
||||||
|
tl.to(leftArm.rotate, { duration: 0.4, z: 0.3, x: 0.15, ease: 'sine.inOut' });
|
||||||
|
tl.to(rightArm.rotate, { duration: 0.4, z: -0.3, x: 0.15, ease: 'sine.inOut' }, '<');
|
||||||
|
tl.to(leftArm.rotate, { duration: 0.4, z: 0, x: 0, ease: 'sine.inOut' }, '-=0.1');
|
||||||
|
tl.to(rightArm.rotate, { duration: 0.4, z: 0, x: 0, ease: 'sine.inOut' }, '<');
|
||||||
|
tl.to(leftArm.rotate, { duration: 0.35, z: 0.25, x: 0.12, ease: 'sine.inOut' });
|
||||||
|
tl.to(rightArm.rotate, { duration: 0.35, z: -0.25, x: 0.12, ease: 'sine.inOut' }, '<');
|
||||||
|
tl.to(leftArm.rotate, { duration: 0.35, z: 0, x: 0, ease: 'sine.inOut' });
|
||||||
|
tl.to(rightArm.rotate, { duration: 0.35, z: 0, x: 0, ease: 'sine.inOut' }, '<');
|
||||||
|
}
|
||||||
|
|
||||||
|
triggerAnnoyanceFn = triggerAnnoyance;
|
||||||
|
gatherPollenFn = gatherPollen;
|
||||||
|
talkingFn = talkingAnimation;
|
||||||
|
|
||||||
|
let isDragging = false, lastX = 0, lastY = 0, wasDragging = false;
|
||||||
|
const sensitivity = 0.008;
|
||||||
|
|
||||||
|
function onPointerDown(e) {
|
||||||
|
isDragging = true; wasDragging = false;
|
||||||
|
lastX = e.clientX; lastY = e.clientY;
|
||||||
|
try { canvasRef.setPointerCapture(e.pointerId); } catch (_) {}
|
||||||
|
}
|
||||||
|
function onPointerMove(e) {
|
||||||
|
if (!isDragging) return;
|
||||||
|
const dx = e.clientX - lastX, dy = e.clientY - lastY;
|
||||||
|
lastX = e.clientX; lastY = e.clientY;
|
||||||
|
const dist = Math.sqrt(dx * dx + dy * dy);
|
||||||
|
if (dist > 10) { wasDragging = true; }
|
||||||
|
if (dist > 28) triggerAnnoyance();
|
||||||
|
BEE_PIVOT.rotate.y += dx * sensitivity;
|
||||||
|
BEE_PIVOT.rotate.x += dy * sensitivity;
|
||||||
|
BEE_PIVOT.rotate.x = Math.max(-TAU / 8, Math.min(TAU / 8, BEE_PIVOT.rotate.x));
|
||||||
|
scene.updateRenderGraph();
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPointerUp() { isDragging = false; }
|
||||||
|
|
||||||
|
function onCanvasClick(e) {
|
||||||
|
if (wasDragging) return;
|
||||||
|
const rect = canvasRef.getBoundingClientRect();
|
||||||
|
const cx = (e.clientX - rect.left - rect.width / 2);
|
||||||
|
const cy = (e.clientY - rect.top - rect.height / 2);
|
||||||
|
|
||||||
|
// Flower projection click boundaries matching your back scene
|
||||||
|
for (const fz of flowerZones) {
|
||||||
|
if (Math.hypot(cx - fz.cx, cy - fz.cy) < fz.r) {
|
||||||
|
gatherPollen(fz); return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bee body click → invite chat instead of opening an interaction toolkit
|
||||||
|
if (Math.hypot(cx, cy + 100) < 130) {
|
||||||
|
showThought('Buzz! 🌸 What is on your mind?', true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!embedded) {
|
||||||
|
canvasRef.addEventListener('click', onCanvasClick);
|
||||||
|
canvasRef.addEventListener('pointerdown', onPointerDown);
|
||||||
|
canvasRef.addEventListener('pointermove', onPointerMove);
|
||||||
|
window.addEventListener('pointerup', onPointerUp);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!embedded) {
|
||||||
|
setTimeout(() => showThought("Buzz! 🌸 I'm thinking about effort and rest. What about you?"), 3000);
|
||||||
|
scheduleThought();
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
isRunning = false;
|
||||||
|
if (thoughtTimer) clearTimeout(thoughtTimer);
|
||||||
|
if (bubbleAutoHideTimer) clearTimeout(bubbleAutoHideTimer);
|
||||||
|
if (!embedded) document.body.classList.remove(pageBodyClass);
|
||||||
|
canvasRef?.removeEventListener('click', onCanvasClick);
|
||||||
|
canvasRef?.removeEventListener('pointerdown', onPointerDown);
|
||||||
|
canvasRef?.removeEventListener('pointermove', onPointerMove);
|
||||||
|
window.removeEventListener('pointerup', onPointerUp);
|
||||||
|
};
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if !embedded}
|
||||||
|
<div class="top-bar">
|
||||||
|
<button class="bar-btn" on:click={goHome}>←</button>
|
||||||
|
<button class="bar-btn" on:click={toggleHistory}>
|
||||||
|
{historyOpen ? 'Close history' : '💬 History'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if !embedded && historyOpen}
|
||||||
|
<div class="history-panel">
|
||||||
|
<div class="history-header">
|
||||||
|
<h2>Session chat</h2>
|
||||||
|
<button class="close-btn" on:click={() => historyOpen = false}>✕</button>
|
||||||
|
</div>
|
||||||
|
<div class="history-scroll">
|
||||||
|
{#each chatHistory as msg}
|
||||||
|
<div class="history-msg {msg.role}">
|
||||||
|
<span class="history-label">{msg.role === 'assistant' ? '🐝 Buzz' : 'You'}</span>
|
||||||
|
<p>{msg.content}</p>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if !embedded}
|
||||||
|
<BeeBackground />
|
||||||
|
{/if}
|
||||||
|
<canvas bind:this={canvasRef} class="scene" class:embedded></canvas>
|
||||||
|
|
||||||
|
|
||||||
|
{#if !embedded}
|
||||||
|
<div class="thought-wrap" class:visible={thoughtBubbleVisible}>
|
||||||
|
<div class="thought-bubble">
|
||||||
|
<div class="thought-dots">
|
||||||
|
<span></span><span></span><span></span>
|
||||||
|
</div>
|
||||||
|
<p class="thought-text">{thoughtBubbleText}</p>
|
||||||
|
</div>
|
||||||
|
<div class="thought-reply">
|
||||||
|
<textarea
|
||||||
|
bind:value={replyInputValue}
|
||||||
|
placeholder="Reply to Buzz…"
|
||||||
|
rows="1"
|
||||||
|
disabled={isApiLoading}
|
||||||
|
on:keydown={handleReplyKey}
|
||||||
|
></textarea>
|
||||||
|
<button class="reply-send" on:click={sendReply} disabled={isApiLoading || !replyInputValue.trim()}>
|
||||||
|
{isApiLoading ? '…' : '➤'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if !embedded}
|
||||||
|
<p class="hint">Click flowers to gather pollen. Drag quickly to annoy.</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
@import url("https://fonts.googleapis.com/css2?family=Nunito:wght@400;600;700;800&display=swap");
|
||||||
|
|
||||||
|
:global(body) {
|
||||||
|
margin: 0; padding: 0;
|
||||||
|
width: 100vw; height: 100vh;
|
||||||
|
background: #FFB7D5;
|
||||||
|
overflow: hidden;
|
||||||
|
font-family: 'Nunito', sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
canvas.scene {
|
||||||
|
position: fixed; inset: 0;
|
||||||
|
width: 100vw; height: 100vh;
|
||||||
|
display: block; z-index: 1;
|
||||||
|
touch-action: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
canvas.scene.embedded {
|
||||||
|
position: absolute;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
inset: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Top bar ─────────────────────────────────────────────────────── */
|
||||||
|
.top-bar {
|
||||||
|
position: fixed;
|
||||||
|
top: 16px; left: 16px;
|
||||||
|
display: flex; gap: 10px;
|
||||||
|
z-index: 30;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bar-btn {
|
||||||
|
padding: 9px 16px;
|
||||||
|
border: none; border-radius: 20px;
|
||||||
|
background: rgba(255,255,255,0.92);
|
||||||
|
color: #5A1A30;
|
||||||
|
font-family: 'Nunito', sans-serif;
|
||||||
|
font-weight: 700; font-size: 0.85rem;
|
||||||
|
cursor: pointer;
|
||||||
|
box-shadow: 0 4px 18px rgba(90,26,48,.10);
|
||||||
|
transition: background .15s, transform .1s;
|
||||||
|
}
|
||||||
|
.bar-btn:hover { background: #fff; transform: translateY(-1px); }
|
||||||
|
.bar-btn:active { transform: scale(.96); }
|
||||||
|
|
||||||
|
/* ── History panel ───────────────────────────────────────────────── */
|
||||||
|
.history-panel {
|
||||||
|
position: fixed;
|
||||||
|
top: 62px; right: 16px;
|
||||||
|
width: min(360px, calc(100vw - 32px));
|
||||||
|
max-height: calc(100vh - 80px);
|
||||||
|
background: rgba(255,255,255,0.97);
|
||||||
|
border-radius: 24px;
|
||||||
|
box-shadow: 0 20px 60px rgba(90,26,48,.18);
|
||||||
|
z-index: 25;
|
||||||
|
display: flex; flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.history-header {
|
||||||
|
display: flex; justify-content: space-between; align-items: center;
|
||||||
|
padding: 18px 20px 12px;
|
||||||
|
border-bottom: 1px solid rgba(240,74,111,.12);
|
||||||
|
}
|
||||||
|
.history-header h2 {
|
||||||
|
margin: 0; font-size: 1rem; color: #5A1A30;
|
||||||
|
}
|
||||||
|
.close-btn {
|
||||||
|
border: none; background: #fde6ef;
|
||||||
|
color: #B73058; border-radius: 50%;
|
||||||
|
width: 30px; height: 30px;
|
||||||
|
cursor: pointer; font-size: 0.85rem; font-weight: 700;
|
||||||
|
}
|
||||||
|
.history-scroll {
|
||||||
|
overflow-y: auto; padding: 14px 16px;
|
||||||
|
display: flex; flex-direction: column; gap: 10px;
|
||||||
|
}
|
||||||
|
.history-msg {
|
||||||
|
border-radius: 16px; padding: 12px 14px;
|
||||||
|
font-size: 0.9rem; line-height: 1.55;
|
||||||
|
}
|
||||||
|
.history-msg p { margin: 4px 0 0; color: #3D1020; }
|
||||||
|
.history-msg.assistant { background: #fff0f6; }
|
||||||
|
.history-msg.user { background: #fffcf3; }
|
||||||
|
.history-label { font-weight: 800; font-size: 0.78rem; color: #B73058; }
|
||||||
|
|
||||||
|
/* ── Thought bubble ──────────────────────────────────────────────── */
|
||||||
|
.thought-wrap {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 32px;
|
||||||
|
right: 32px;
|
||||||
|
max-width: min(360px, 90vw);
|
||||||
|
display: flex; flex-direction: column; gap: 10px;
|
||||||
|
z-index: 15;
|
||||||
|
pointer-events: none;
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(8px) scale(.96);
|
||||||
|
transition: opacity .35s ease, transform .35s ease;
|
||||||
|
align-items: flex-end;
|
||||||
|
}
|
||||||
|
.thought-wrap.visible {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0) scale(1);
|
||||||
|
pointer-events: all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.thought-bubble {
|
||||||
|
background: #fff;
|
||||||
|
border: 2px solid rgba(240,74,111,.25);
|
||||||
|
border-radius: 22px 22px 6px 22px;
|
||||||
|
padding: 14px 18px;
|
||||||
|
box-shadow: 0 8px 36px rgba(90,26,48,.12);
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.thought-dots {
|
||||||
|
display: flex; gap: 4px; margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
.thought-dots span {
|
||||||
|
width: 5px; height: 5px;
|
||||||
|
background: #FF8FAE; border-radius: 50%;
|
||||||
|
}
|
||||||
|
.thought-text {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.92rem;
|
||||||
|
color: #4B1528;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.thought-reply {
|
||||||
|
display: flex; gap: 8px;
|
||||||
|
align-items: flex-end;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.thought-reply textarea {
|
||||||
|
flex: 1;
|
||||||
|
background: rgba(255,255,255,0.96);
|
||||||
|
border: 1.5px solid rgba(240,74,111,.25);
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
font-family: 'Nunito', sans-serif;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: #3D1020;
|
||||||
|
outline: none; resize: none;
|
||||||
|
height: 44px; min-height: 44px; max-height: 88px;
|
||||||
|
line-height: 1.5; box-sizing: border-box;
|
||||||
|
transition: border-color .15s, box-shadow .15s;
|
||||||
|
}
|
||||||
|
.thought-reply textarea::placeholder { color: #C08AA0; }
|
||||||
|
.thought-reply textarea:focus {
|
||||||
|
border-color: #FF8FAE;
|
||||||
|
box-shadow: 0 0 0 3px rgba(255,143,174,.18);
|
||||||
|
}
|
||||||
|
.reply-send {
|
||||||
|
border: none; background: #FF8FAE; color: #fff;
|
||||||
|
border-radius: 50%; width: 44px; height: 44px;
|
||||||
|
cursor: pointer; font-size: 0.85rem;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
transition: background .12s;
|
||||||
|
}
|
||||||
|
.reply-send:hover:not(:disabled) { background: #F04A6F; }
|
||||||
|
.reply-send:disabled { background: #f0c3d0; cursor: not-allowed; }
|
||||||
|
|
||||||
|
.hint {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 22px; left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.8rem; font-weight: 600;
|
||||||
|
color: rgba(90, 26, 48, 0.58);
|
||||||
|
pointer-events: none;
|
||||||
|
letter-spacing: .04em;
|
||||||
|
z-index: 10;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
183
src/BeeBackground.svelte
Normal file
183
src/BeeBackground.svelte
Normal file
@@ -0,0 +1,183 @@
|
|||||||
|
<script>
|
||||||
|
//@ts-nocheck
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import Zdog from 'zdog';
|
||||||
|
|
||||||
|
let canvasRef;
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
const TAU = Zdog.TAU;
|
||||||
|
|
||||||
|
const color = {
|
||||||
|
beeYellow: '#FCD116',
|
||||||
|
beeBlack: '#3D2620',
|
||||||
|
beeWhite: '#FFFDF0',
|
||||||
|
beeCheek: '#F26B50',
|
||||||
|
leafGreen: '#5DA020',
|
||||||
|
darkGreen: '#3A5C18',
|
||||||
|
land: '#7ab535',
|
||||||
|
landLight: '#9fd95c',
|
||||||
|
trunkBrown: '#8B6340',
|
||||||
|
cloudPink: '#FFE4F0',
|
||||||
|
cloudWhite: '#FFF7FB',
|
||||||
|
daisyWhite: '#FFFFFF',
|
||||||
|
daisyYellow: '#F9D342',
|
||||||
|
roseRed: '#F04A6F',
|
||||||
|
rosePink: '#FF8FAE',
|
||||||
|
lavPurple: '#C07EE0',
|
||||||
|
lavLight: '#E2B8F5',
|
||||||
|
sunflowerY: '#FFB800',
|
||||||
|
sunflowerC: '#7A3B10',
|
||||||
|
tulipPink: '#FF85A2',
|
||||||
|
tulipOrange: '#FF6B35',
|
||||||
|
honeyGold: '#F2C94C',
|
||||||
|
honeyDark: '#D4A017'
|
||||||
|
};
|
||||||
|
|
||||||
|
const scene = new Zdog.Illustration({
|
||||||
|
element: canvasRef,
|
||||||
|
dragRotate: false,
|
||||||
|
resize: 'window',
|
||||||
|
rotate: { x: -0.28, y: -0.12, z: 0 }
|
||||||
|
});
|
||||||
|
|
||||||
|
const envGroup = new Zdog.Anchor({ addTo: scene });
|
||||||
|
|
||||||
|
new Zdog.Shape({
|
||||||
|
addTo: envGroup,
|
||||||
|
path: [{ x: -2000, y: -1000 }, { x: 2000, y: -1000 }, { x: 2000, y: 0 }, { x: -2000, y: 0 }],
|
||||||
|
stroke: 0, fill: true, color: '#FFB7D5', translate: { z: -800 }
|
||||||
|
});
|
||||||
|
new Zdog.Shape({
|
||||||
|
addTo: envGroup,
|
||||||
|
path: [{ x: -2000, y: 0 }, { x: 2000, y: 0 }, { x: 2000, y: 600 }, { x: -2000, y: 600 }],
|
||||||
|
stroke: 0, fill: true, color: '#FFE1F0', translate: { z: -800 }
|
||||||
|
});
|
||||||
|
|
||||||
|
function addHill(x, w, col, z, amp = 160) {
|
||||||
|
const pts = [{ x: x - w / 2, y: 300 }];
|
||||||
|
for (let i = 0; i <= 24; i++) {
|
||||||
|
const t = i / 24;
|
||||||
|
pts.push({ x: x - w / 2 + t * w, y: 300 - Math.sin(t * Math.PI) * amp });
|
||||||
|
}
|
||||||
|
pts.push({ x: x + w / 2, y: 300 });
|
||||||
|
new Zdog.Shape({ addTo: envGroup, path: pts, stroke: 0, fill: true, color: col, translate: { z } });
|
||||||
|
}
|
||||||
|
|
||||||
|
addHill(-600, 900, color.darkGreen, -700, 160);
|
||||||
|
addHill(500, 800, '#4a7020', -700, 160);
|
||||||
|
addHill(-200, 700, '#5a8a28', -680, 150);
|
||||||
|
addHill(900, 700, '#3d6a18', -720, 170);
|
||||||
|
|
||||||
|
new Zdog.Shape({
|
||||||
|
addTo: envGroup,
|
||||||
|
path: [{ x: -2000, y: 220 }, { x: 2000, y: 220 }, { x: 2000, y: 800 }, { x: -2000, y: 800 }],
|
||||||
|
stroke: 0, fill: true, color: color.land, translate: { z: -500 }
|
||||||
|
});
|
||||||
|
new Zdog.Shape({
|
||||||
|
addTo: envGroup,
|
||||||
|
path: [{ x: -2000, y: 220 }, { x: 2000, y: 220 }, { x: 2000, y: 260 }, { x: -2000, y: 260 }],
|
||||||
|
stroke: 0, fill: true, color: color.landLight, translate: { z: -490 }
|
||||||
|
});
|
||||||
|
|
||||||
|
[
|
||||||
|
{ x: -420, y: -280, z: -420, s: 1.2 },
|
||||||
|
{ x: -160, y: -310, z: -510, s: 0.9 },
|
||||||
|
{ x: 360, y: -220, z: -380, s: 1.4 },
|
||||||
|
{ x: 80, y: -290, z: -460, s: 1.0 },
|
||||||
|
{ x: 700, y: -260, z: -500, s: 1.1 }
|
||||||
|
].forEach(p => {
|
||||||
|
const c = new Zdog.Anchor({ addTo: envGroup, translate: { x: p.x, y: p.y, z: p.z }, scale: p.s });
|
||||||
|
new Zdog.Shape({ addTo: c, stroke: 95, color: color.cloudPink });
|
||||||
|
new Zdog.Shape({ addTo: c, stroke: 72, color: color.cloudWhite, translate: { x: -55, y: 10 } });
|
||||||
|
new Zdog.Shape({ addTo: c, stroke: 78, color: color.cloudPink, translate: { x: 55, y: 5 } });
|
||||||
|
new Zdog.Shape({ addTo: c, stroke: 62, color: color.cloudWhite, translate: { x: -95, y: 15 } });
|
||||||
|
new Zdog.Shape({ addTo: c, stroke: 58, color: color.cloudPink, translate: { x: 100, y: 12 } });
|
||||||
|
});
|
||||||
|
|
||||||
|
const fgGroup = new Zdog.Anchor({ addTo: scene, translate: { x: 0, y: 50, z: 120 } });
|
||||||
|
|
||||||
|
function createDaisy(parent, x, y, z, scale, stemLen, rotX, rotY, petalCol) {
|
||||||
|
petalCol = petalCol || color.daisyWhite;
|
||||||
|
const fg = new Zdog.Anchor({ addTo: parent, translate: { x, y: y + (120 - stemLen), z }, scale });
|
||||||
|
new Zdog.Shape({ addTo: fg, path: [{ x: 0, y: stemLen, z: 0 }, { x: 0, y: 0, z: 0 }], stroke: 7, color: color.leafGreen });
|
||||||
|
const hd = new Zdog.Anchor({ addTo: fg, rotate: { x: rotX, y: rotY } });
|
||||||
|
for (let i = 0; i < 13; i++) {
|
||||||
|
const ph = new Zdog.Anchor({ addTo: hd, rotate: { z: (TAU / 13) * i } });
|
||||||
|
new Zdog.Ellipse({ addTo: ph, width: 11, height: 30, fill: true, color: petalCol, stroke: 0, translate: { y: -18 } });
|
||||||
|
}
|
||||||
|
new Zdog.Ellipse({ addTo: hd, diameter: 15, fill: true, stroke: 4, color: color.daisyYellow, translate: { z: 1.5 } });
|
||||||
|
}
|
||||||
|
|
||||||
|
function createRose(parent, x, y, z, scale, stemLen, rotX, rotY) {
|
||||||
|
const fg = new Zdog.Anchor({ addTo: parent, translate: { x, y: y + (140 - stemLen), z }, scale });
|
||||||
|
new Zdog.Shape({ addTo: fg, path: [{ x: 0, y: stemLen }, { x: 0, y: 0 }], stroke: 7, color: color.leafGreen });
|
||||||
|
const hd = new Zdog.Anchor({ addTo: fg, rotate: { x: rotX, y: rotY } });
|
||||||
|
for (let l = 0; l < 3; l++) {
|
||||||
|
const pc = 6 + l * 2, r = 10 + l * 9;
|
||||||
|
for (let i = 0; i < pc; i++) {
|
||||||
|
const a = (TAU / pc) * i + l * 0.3;
|
||||||
|
const ph = new Zdog.Anchor({ addTo: hd, translate: { x: Math.cos(a) * r, y: Math.sin(a) * r } });
|
||||||
|
new Zdog.Ellipse({ addTo: ph, width: l === 0 ? 8 : 10 + l * 2, height: l === 0 ? 12 : 14 + l * 3, fill: true, color: l === 0 ? color.roseRed : color.rosePink, stroke: 0, rotate: { z: a } });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
new Zdog.Ellipse({ addTo: hd, diameter: 8, fill: true, stroke: 0, color: '#c0243a', translate: { z: 1 } });
|
||||||
|
}
|
||||||
|
|
||||||
|
function createSunflower(parent, x, y, z, scale, stemLen, rotX, rotY) {
|
||||||
|
const fg = new Zdog.Anchor({ addTo: parent, translate: { x, y: y + (160 - stemLen), z }, scale });
|
||||||
|
new Zdog.Shape({ addTo: fg, path: [{ x: 0, y: stemLen }, { x: 0, y: 0 }], stroke: 9, color: color.leafGreen });
|
||||||
|
const hd = new Zdog.Anchor({ addTo: fg, rotate: { x: rotX, y: rotY } });
|
||||||
|
for (let i = 0; i < 18; i++) {
|
||||||
|
const ph = new Zdog.Anchor({ addTo: hd, rotate: { z: (TAU / 18) * i } });
|
||||||
|
new Zdog.Ellipse({ addTo: ph, width: 13, height: 36, fill: true, color: color.sunflowerY, stroke: 0, translate: { y: -24 } });
|
||||||
|
}
|
||||||
|
new Zdog.Ellipse({ addTo: hd, diameter: 22, fill: true, stroke: 5, color: color.sunflowerC, translate: { z: 2 } });
|
||||||
|
}
|
||||||
|
|
||||||
|
function createTulip(parent, x, y, z, scale, stemLen, rotX, rotY, col) {
|
||||||
|
col = col || color.tulipPink;
|
||||||
|
const fg = new Zdog.Anchor({ addTo: parent, translate: { x, y: y + (130 - stemLen), z }, scale });
|
||||||
|
new Zdog.Shape({ addTo: fg, path: [{ x: 0, y: stemLen }, { x: 0, y: 0 }], stroke: 8, color: color.leafGreen });
|
||||||
|
const hd = new Zdog.Anchor({ addTo: fg, rotate: { x: rotX, y: rotY } });
|
||||||
|
for (let i = 0; i < 6; i++) {
|
||||||
|
const a = (TAU / 6) * i;
|
||||||
|
new Zdog.Ellipse({ addTo: hd, width: 18, height: 36, fill: true, color: col, stroke: 0, translate: { x: Math.cos(a) * 10, z: Math.sin(a) * 10 }, rotate: { y: a, x: -0.3 } });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Replaced the frontal orange tulip layout at x: -350 with a normal radial white Daisy.
|
||||||
|
const flowers = [
|
||||||
|
{ f: 's', x: -600, y: 120, z: -150, s: 6.5, len: 130, rx: -TAU/3.7, ry: -0.1 },
|
||||||
|
{ f: 'd', x: -350, y: 125, z: 83, s: 10, len: 110, rx: -TAU/4.1, ry: 0.2, c: color.tulipOrange}, // Replaced orange tulip
|
||||||
|
{ f: 'r', x: -120, y: 95, z: -280, s: 5.0, len: 140, rx: -TAU/3.9, ry: 0.15 },
|
||||||
|
{ f: 'd', x: 100, y: 135, z: 20, s: 8.8, len: 125, rx: -TAU/4, ry: -0.2, c: '#FFFFFF' },
|
||||||
|
{ f: 's', x: 320, y: 100, z: -320, s: 4.8, len: 115, rx: -TAU/3.6, ry: 0.1 },
|
||||||
|
{ f: 'd', x: 520, y: 145, z: 90, s: 9.5, len: 135, rx: -TAU/4.2, ry: -0.25, c: '#FFE4F0' },
|
||||||
|
{ f: 't', x: 700, y: 110, z: -180, s: 5.8, len: 100, rx: -TAU/4.4, ry: 0.2, c: color.tulipPink }
|
||||||
|
];
|
||||||
|
|
||||||
|
flowers.forEach(d => {
|
||||||
|
if (d.f === 'd') createDaisy(fgGroup, d.x, d.y, d.z, d.s, d.len, d.rx, d.ry, d.c);
|
||||||
|
else if (d.f === 'r') createRose(fgGroup, d.x, d.y, d.z, d.s, d.len, d.rx, d.ry);
|
||||||
|
else if (d.f === 's') createSunflower(fgGroup,d.x, d.y, d.z, d.s, d.len, d.rx, d.ry);
|
||||||
|
else if (d.f === 't') createTulip(fgGroup, d.x, d.y, d.z, d.s, d.len, d.rx, d.ry, d.c);
|
||||||
|
});
|
||||||
|
|
||||||
|
scene.updateRenderGraph();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<canvas bind:this={canvasRef} class="background-scene"></canvas>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
canvas.background-scene {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
width: 100vw;
|
||||||
|
height: 100vh;
|
||||||
|
z-index: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
888
src/Blobfish.svelte
Normal file
888
src/Blobfish.svelte
Normal file
@@ -0,0 +1,888 @@
|
|||||||
|
<script>
|
||||||
|
//@ts-nocheck
|
||||||
|
import { onMount, createEventDispatcher } from 'svelte';
|
||||||
|
import Zdog from 'zdog';
|
||||||
|
import { gsap } from 'gsap';
|
||||||
|
|
||||||
|
const dispatch = createEventDispatcher();
|
||||||
|
|
||||||
|
let canvasRef;
|
||||||
|
let uiCanvasRef;
|
||||||
|
let overlayCanvasRef;
|
||||||
|
const OPENAI_API_KEY = import.meta.env.VITE_OPENAI_API_KEY;
|
||||||
|
|
||||||
|
export let embedded = false;
|
||||||
|
|
||||||
|
// --- Environment & State Management ---
|
||||||
|
let isDeep = false;
|
||||||
|
let isAnimating = false;
|
||||||
|
let hintText = 'Double click the fish to jump • Click the gauge to dive';
|
||||||
|
|
||||||
|
let fishState = { pinkAlpha: 1, darkAlpha: 0 };
|
||||||
|
let transitionProgress = { value: 0 };
|
||||||
|
|
||||||
|
// --- Zdog Global Targets ---
|
||||||
|
let scene, uiScene;
|
||||||
|
let backgroundZone, sceneryZone, fishZone;
|
||||||
|
let blobMasterAnchor, handsomeMasterAnchor;
|
||||||
|
let leftPecContainer, rightPecContainer;
|
||||||
|
let leftWingContainer, rightWingContainer;
|
||||||
|
let handsomeTailFin, blobTailFin;
|
||||||
|
let eyeL_pink, eyeR_pink, eyeL_blue, eyeR_blue;
|
||||||
|
let mouth_pink, mouth_blue;
|
||||||
|
let gaugeNeedle;
|
||||||
|
|
||||||
|
// --- Vector Background Blobs ---
|
||||||
|
let skyBlock, midBlock, midTransitionBlock, deepTransitionBlock, abyssBlock;
|
||||||
|
|
||||||
|
// --- Drifting Scenery Anchors ---
|
||||||
|
let cloud1, cloud2, cloud3;
|
||||||
|
|
||||||
|
let pinkFadeShapes = [];
|
||||||
|
let darkFadeShapes = [];
|
||||||
|
let sceneryFadeShapes = [];
|
||||||
|
|
||||||
|
// --- UI Positioning States ---
|
||||||
|
let historyOpen = false;
|
||||||
|
|
||||||
|
let thoughtBubbleVisible = false;
|
||||||
|
let isReplying = false;
|
||||||
|
let thoughtBubbleText = '';
|
||||||
|
let replyInputValue = '';
|
||||||
|
let thoughtTimer = null;
|
||||||
|
let bubbleAutoHideTimer = null;
|
||||||
|
let isApiLoading = false;
|
||||||
|
let conversationActive = false;
|
||||||
|
let replyInputRef;
|
||||||
|
|
||||||
|
// Gesture Tracking for Double Tap
|
||||||
|
let lastClickTime = 0;
|
||||||
|
let clickTimeout;
|
||||||
|
|
||||||
|
const interactionCards = [
|
||||||
|
{
|
||||||
|
id: 'float',
|
||||||
|
icon: '🔄',
|
||||||
|
label: 'Float',
|
||||||
|
description: 'Orbit Swimming'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'jump',
|
||||||
|
icon: '🐬',
|
||||||
|
label: 'Jump',
|
||||||
|
description: 'Breaching Leap'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'chat',
|
||||||
|
icon: '💬',
|
||||||
|
label: 'Chat',
|
||||||
|
description: 'Consult the Blobfish'
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
let chatHistory = [
|
||||||
|
{ role: 'assistant', content: "Mmmh... Hello down there. Let us talk about surviving the heavy pressures of life, or simply floating along." }
|
||||||
|
];
|
||||||
|
|
||||||
|
const thoughtPrompts = [
|
||||||
|
'The pressure changes up here... they alter how everyone looks at you.',
|
||||||
|
'Down in the dark, you never need to worry about structural integrity.',
|
||||||
|
'Floating takes absolutely no effort at all. There is a great wisdom in that.',
|
||||||
|
'Heavy weights are easier to bear when you are built exactly for the depth you inhabit.',
|
||||||
|
'Are you running around today, or are you letting the currents carry you?',
|
||||||
|
'Sometimes, looking a bit deflated just means you are away from home.'
|
||||||
|
];
|
||||||
|
|
||||||
|
// --- Background Seaweed Array ---
|
||||||
|
const seaweedStrands = [
|
||||||
|
{ xPct: 0.08, baseWidth: 22, height: 320, speed: 0.024, delay: 0.0 },
|
||||||
|
{ xPct: 0.11, baseWidth: 35, height: 460, speed: 0.018, delay: 0.8 },
|
||||||
|
{ xPct: 0.14, baseWidth: 26, height: 380, speed: 0.022, delay: 0.4 },
|
||||||
|
{ xPct: 0.17, baseWidth: 40, height: 490, speed: 0.015, delay: 1.2 },
|
||||||
|
{ xPct: 0.21, baseWidth: 28, height: 340, speed: 0.026, delay: 0.2 },
|
||||||
|
{ xPct: 0.25, baseWidth: 32, height: 410, speed: 0.020, delay: 0.9 },
|
||||||
|
{ xPct: 0.72, baseWidth: 30, height: 390, speed: 0.021, delay: 1.5 },
|
||||||
|
{ xPct: 0.76, baseWidth: 42, height: 510, speed: 0.016, delay: 0.3 },
|
||||||
|
{ xPct: 0.80, baseWidth: 24, height: 350, speed: 0.025, delay: 1.0 },
|
||||||
|
{ xPct: 0.84, baseWidth: 38, height: 470, speed: 0.019, delay: 0.6 },
|
||||||
|
{ xPct: 0.88, baseWidth: 28, height: 420, speed: 0.023, delay: 1.4 },
|
||||||
|
{ xPct: 0.93, baseWidth: 20, height: 310, speed: 0.028, delay: 0.1 }
|
||||||
|
];
|
||||||
|
|
||||||
|
const color = {
|
||||||
|
blobSkin: '#F4B8C2', blobNose: '#F3AEBB', blobFin: '#C97A8D', blobMouth: '#6B3A45', blobTailBody: '#D88A9D',
|
||||||
|
blobDeepSea: '#6C8EA4', blobDeepSeaDark: '#4A6578', blobMouthDeep: '#334856', blobEyeDeep: '#94b3c7', blobPupilDeep: '#1d2a33',
|
||||||
|
waterTransition: '#1C2541', seaweedDark: '26, 54, 39', seaweedLight: '53, 94, 59',
|
||||||
|
bgSky: '#ff8fae', bgPinkWater: '#FAD0C4', bgMidWater: '#4A90E2', bgTransBlue1: '#3B78C4', bgTransBlue2: '#2B5FA6',
|
||||||
|
bgDeepWater: '#1C2541', bgAbyss: '#0B132B', landGreen: '#6CA35E', landGreenDark: '#4E8241', cloudBody: 'rgba(255, 255, 255, 0.75)'
|
||||||
|
};
|
||||||
|
|
||||||
|
function toggleHistory() {
|
||||||
|
historyOpen = !historyOpen;
|
||||||
|
}
|
||||||
|
|
||||||
|
function showThought(text, keepAlive = false) {
|
||||||
|
if (bubbleAutoHideTimer) clearTimeout(bubbleAutoHideTimer);
|
||||||
|
thoughtBubbleText = text;
|
||||||
|
thoughtBubbleVisible = true;
|
||||||
|
conversationActive = keepAlive;
|
||||||
|
|
||||||
|
if (!keepAlive && !isReplying) {
|
||||||
|
bubbleAutoHideTimer = setTimeout(() => {
|
||||||
|
thoughtBubbleVisible = false;
|
||||||
|
}, 12000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openReplyInput() {
|
||||||
|
if (bubbleAutoHideTimer) clearTimeout(bubbleAutoHideTimer);
|
||||||
|
isReplying = true;
|
||||||
|
thoughtBubbleVisible = true;
|
||||||
|
setTimeout(() => replyInputRef?.focus(), 50);
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleThought() {
|
||||||
|
if (thoughtTimer) clearTimeout(thoughtTimer);
|
||||||
|
thoughtTimer = setTimeout(() => {
|
||||||
|
if (!thoughtBubbleVisible && !isReplying && !isApiLoading) {
|
||||||
|
const prompt = thoughtPrompts[Math.floor(Math.random() * thoughtPrompts.length)];
|
||||||
|
showThought(prompt);
|
||||||
|
}
|
||||||
|
scheduleThought();
|
||||||
|
}, 18000 + Math.random() * 10000);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendReply() {
|
||||||
|
const msg = replyInputValue.trim();
|
||||||
|
if (!msg || isApiLoading) return;
|
||||||
|
replyInputValue = '';
|
||||||
|
isReplying = false;
|
||||||
|
await askFish(msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleReplyKey(e) {
|
||||||
|
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendReply(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function askFish(userMessage) {
|
||||||
|
if (isApiLoading) return;
|
||||||
|
isApiLoading = true;
|
||||||
|
showThought('…', true);
|
||||||
|
triggerTalkingMotion();
|
||||||
|
|
||||||
|
chatHistory = [...chatHistory, { role: 'user', content: userMessage }];
|
||||||
|
|
||||||
|
try {
|
||||||
|
const endpoint = 'https://api.openai.com/v1/chat/completions';
|
||||||
|
const body = {
|
||||||
|
model: 'gpt-4o-mini',
|
||||||
|
messages: [{ role: 'system', content: 'You are a wise blobfish' }, ...chatHistory],
|
||||||
|
max_tokens: 256,
|
||||||
|
temperature: 0.85
|
||||||
|
};
|
||||||
|
const res = await fetch(endpoint, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `Bearer ${OPENAI_API_KEY}`
|
||||||
|
},
|
||||||
|
body: JSON.stringify(body)
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) throw new Error(`API error code: ${res.status}`);
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
const replyText = data?.choices?.[0]?.message?.content;
|
||||||
|
|
||||||
|
if (replyText) {
|
||||||
|
const cleanReply = replyText.trim();
|
||||||
|
chatHistory = [...chatHistory, { role: 'assistant', content: cleanReply }];
|
||||||
|
showThought(cleanReply, true);
|
||||||
|
} else {
|
||||||
|
showThought("The currents are scrambled... I lost my thread of thought.", true);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
chatHistory = chatHistory.slice(0, -1);
|
||||||
|
showThought("Too much static in the water right now. Ask me again shortly...", true);
|
||||||
|
} finally {
|
||||||
|
isApiLoading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Animation Execution Methods ---
|
||||||
|
let isMeshRunningAnimation = false;
|
||||||
|
let customFinSpeedFactor = 1;
|
||||||
|
let customTailSpeedFactor = 1;
|
||||||
|
|
||||||
|
function triggerFloatAnimation() {
|
||||||
|
if (isMeshRunningAnimation) return;
|
||||||
|
isMeshRunningAnimation = true;
|
||||||
|
customTailSpeedFactor = 2.0;
|
||||||
|
|
||||||
|
const tl = gsap.timeline({
|
||||||
|
onComplete: () => {
|
||||||
|
isMeshRunningAnimation = false;
|
||||||
|
customTailSpeedFactor = 1;
|
||||||
|
fishZone.translate.x = 0;
|
||||||
|
fishZone.translate.z = 0;
|
||||||
|
fishZone.rotate.y = -0.12;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
tl.to(fishZone.rotate, { duration: 3.5, y: `-=${Zdog.TAU}`, ease: 'none' });
|
||||||
|
tl.to(fishZone.translate, { duration: 1.75, x: -160, z: -100, ease: 'sine.inOut', yoyo: true, repeat: 1 }, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function triggerJumpAnimation() {
|
||||||
|
if (isMeshRunningAnimation) return;
|
||||||
|
isMeshRunningAnimation = true;
|
||||||
|
customFinSpeedFactor = 3.5;
|
||||||
|
|
||||||
|
const tl = gsap.timeline({
|
||||||
|
onComplete: () => {
|
||||||
|
isMeshRunningAnimation = false;
|
||||||
|
customFinSpeedFactor = 1;
|
||||||
|
fishZone.translate.y = 0;
|
||||||
|
fishZone.translate.z = 0;
|
||||||
|
fishZone.rotate.x = 0;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isDeep) {
|
||||||
|
tl.to(fishZone.translate, { duration: 1.2, y: -260, z: 40, ease: 'power2.out' });
|
||||||
|
tl.to(fishZone.rotate, { duration: 0.8, x: -0.4 }, 0);
|
||||||
|
tl.to(fishZone.translate, { duration: 0.5, y: -500, z: 120, ease: 'power1.out' }, '+=0.1');
|
||||||
|
tl.to(fishZone.rotate, { duration: 1.0, x: `+=${Zdog.TAU}`, ease: 'power1.inOut' }, '-=0.2');
|
||||||
|
tl.to(fishZone.translate, { duration: 0.6, y: 0, z: 0, ease: 'power2.in' });
|
||||||
|
} else {
|
||||||
|
tl.to(fishZone.translate, { duration: 0.5, y: -240, z: 80, ease: 'power1.out' });
|
||||||
|
tl.to(fishZone.rotate, { duration: 0.9, x: `+=${Zdog.TAU}`, ease: 'power1.inOut' }, 0);
|
||||||
|
tl.to(fishZone.translate, { duration: 0.5, y: 0, z: 0, ease: 'power1.in' }, '-=0.4');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function triggerTalkingMotion() {
|
||||||
|
const tl = gsap.timeline();
|
||||||
|
tl.to([leftPecContainer.rotate, leftWingContainer.rotate], { duration: 0.3, y: 0.4, ease: 'sine.inOut', repeat: 3, yoyo: true });
|
||||||
|
tl.to([rightPecContainer.rotate, rightWingContainer.rotate], { duration: 0.3, y: -0.4, ease: 'sine.inOut', repeat: 3, yoyo: true }, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function hexToRgb(hex) {
|
||||||
|
if(hex.startsWith('rgba')) return { r:255, g:255, b:255 };
|
||||||
|
return {
|
||||||
|
r: parseInt(hex.slice(1, 3), 16),
|
||||||
|
g: parseInt(hex.slice(3, 5), 16),
|
||||||
|
b: parseInt(hex.slice(5, 7), 16)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function setupFadeGroup(anchor) {
|
||||||
|
const shapes = [];
|
||||||
|
function traverse(node) {
|
||||||
|
if (node.color && typeof node.color === 'string' && node.color.startsWith('#')) {
|
||||||
|
const { r, g, b } = hexToRgb(node.color);
|
||||||
|
shapes.push({ node, r, g, b });
|
||||||
|
}
|
||||||
|
if (node.children) node.children.forEach(traverse);
|
||||||
|
}
|
||||||
|
traverse(anchor);
|
||||||
|
return shapes;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Canvas Pointer Action Hit Detection ---
|
||||||
|
let isDragging = false, lastX = 0, lastY = 0, wasDragging = false;
|
||||||
|
const sensitivity = 0.008;
|
||||||
|
|
||||||
|
function onPointerDown(e) {
|
||||||
|
isDragging = true; wasDragging = false;
|
||||||
|
lastX = e.clientX; lastY = e.clientY;
|
||||||
|
try { canvasRef.setPointerCapture(e.pointerId); } catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPointerMove(e) {
|
||||||
|
if (!isDragging) return;
|
||||||
|
const dx = e.clientX - lastX, dy = e.clientY - lastY;
|
||||||
|
lastX = e.clientX; lastY = e.clientY;
|
||||||
|
if (Math.hypot(dx, dy) > 8) { wasDragging = true; }
|
||||||
|
|
||||||
|
fishZone.rotate.y += dx * sensitivity;
|
||||||
|
fishZone.rotate.x += dy * sensitivity;
|
||||||
|
fishZone.rotate.x = Math.max(-Zdog.TAU / 8, Math.min(Zdog.TAU / 8, fishZone.rotate.x));
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPointerUp() { isDragging = false; }
|
||||||
|
|
||||||
|
function onCanvasClick(e) {
|
||||||
|
if (wasDragging) return;
|
||||||
|
const rect = canvasRef.getBoundingClientRect();
|
||||||
|
const cx = (e.clientX - rect.left - rect.width / 2);
|
||||||
|
const cy = (e.clientY - rect.top - rect.height / 2);
|
||||||
|
|
||||||
|
const fishRadiusThreshold = 145;
|
||||||
|
if (Math.hypot(cx, cy) < fishRadiusThreshold) {
|
||||||
|
const currentTime = Date.now();
|
||||||
|
|
||||||
|
if (currentTime - lastClickTime < 300) {
|
||||||
|
clearTimeout(clickTimeout);
|
||||||
|
triggerJumpAnimation();
|
||||||
|
} else {
|
||||||
|
clearTimeout(clickTimeout);
|
||||||
|
clickTimeout = setTimeout(() => {
|
||||||
|
if (!isApiLoading && !isReplying) {
|
||||||
|
showThought("Did you poke me? I'm quite squishy... Use the buttons below to interact.", false);
|
||||||
|
}
|
||||||
|
}, 250);
|
||||||
|
}
|
||||||
|
lastClickTime = currentTime;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
const ctx = overlayCanvasRef.getContext('2d');
|
||||||
|
function resizeOverlay() {
|
||||||
|
overlayCanvasRef.width = window.innerWidth;
|
||||||
|
overlayCanvasRef.height = window.innerHeight;
|
||||||
|
}
|
||||||
|
resizeOverlay();
|
||||||
|
window.addEventListener('resize', resizeOverlay);
|
||||||
|
|
||||||
|
scene = new Zdog.Illustration({
|
||||||
|
element: canvasRef, dragRotate: false, resize: 'window', rotate: { x: 0, y: -0.12 },
|
||||||
|
});
|
||||||
|
|
||||||
|
backgroundZone = new Zdog.Anchor({ addTo: scene, translate: { z: -450 } });
|
||||||
|
|
||||||
|
skyBlock = new Zdog.Shape({
|
||||||
|
addTo: backgroundZone,
|
||||||
|
path: [{ x: -1200, y: -900 }, { x: 1200, y: -900 }, { x: 1200, y: -260 }, { arc: [ { x: 0, y: -200 }, { x: -1200, y: -260 } ] }],
|
||||||
|
stroke: 40, color: color.bgSky, fill: true
|
||||||
|
});
|
||||||
|
|
||||||
|
sceneryZone = new Zdog.Anchor({ addTo: scene, translate: { z: -420 } });
|
||||||
|
|
||||||
|
cloud1 = new Zdog.Anchor({ addTo: sceneryZone, translate: { x: -350, y: -450 } });
|
||||||
|
new Zdog.Shape({ addTo: cloud1, stroke: 50, color: color.cloudBody });
|
||||||
|
new Zdog.Shape({ addTo: cloud1, stroke: 36, color: color.cloudBody, translate: { x: -30, y: 8 } });
|
||||||
|
new Zdog.Shape({ addTo: cloud1, stroke: 40, color: color.cloudBody, translate: { x: 34, y: 4 } });
|
||||||
|
|
||||||
|
cloud2 = new Zdog.Anchor({ addTo: sceneryZone, translate: { x: 180, y: -520 } });
|
||||||
|
new Zdog.Shape({ addTo: cloud2, stroke: 64, color: color.cloudBody });
|
||||||
|
new Zdog.Shape({ addTo: cloud2, stroke: 46, color: color.cloudBody, translate: { x: -44, y: 10 } });
|
||||||
|
new Zdog.Shape({ addTo: cloud2, stroke: 42, color: color.cloudBody, translate: { x: 44, y: 6 } });
|
||||||
|
|
||||||
|
cloud3 = new Zdog.Anchor({ addTo: sceneryZone, translate: { x: 500, y: -380 } });
|
||||||
|
new Zdog.Shape({ addTo: cloud3, stroke: 38, color: color.cloudBody });
|
||||||
|
new Zdog.Shape({ addTo: cloud3, stroke: 28, color: color.cloudBody, translate: { x: -24, y: 4 } });
|
||||||
|
|
||||||
|
new Zdog.Shape({
|
||||||
|
addTo: sceneryZone,
|
||||||
|
path: [{ x: -800, y: 20 }, { arc: [ { x: -500, y: -200 }, { x: -200, y: 20 } ] }, { x: -200, y: 20 }, { x: -800, y: 20 }],
|
||||||
|
stroke: 20, color: color.landGreenDark, fill: true
|
||||||
|
});
|
||||||
|
|
||||||
|
new Zdog.Shape({
|
||||||
|
addTo: sceneryZone,
|
||||||
|
path: [{ x: 200, y: 20 }, { arc: [ { x: 500, y: -240 }, { x: 800, y: 20 } ] }, { x: 800, y: 20 }, { x: 200, y: 20 }],
|
||||||
|
stroke: 20, color: color.landGreenDark, fill: true
|
||||||
|
});
|
||||||
|
|
||||||
|
midBlock = new Zdog.Shape({
|
||||||
|
addTo: backgroundZone,
|
||||||
|
path: [{ x: -1200, y: -280 }, { arc: [ { x: 0, y: -220 }, { x: 1200, y: -280 } ] }, { x: 1200, y: 50 }, { arc: [ { x: 0, y: 110 }, { x: -1200, y: 50 } ] }],
|
||||||
|
stroke: 50, color: color.bgPinkWater, fill: true
|
||||||
|
});
|
||||||
|
|
||||||
|
midTransitionBlock = new Zdog.Shape({
|
||||||
|
addTo: backgroundZone,
|
||||||
|
path: [{ x: -1200, y: 30 }, { arc: [ { x: 0, y: 90 }, { x: 1200, y: 30 } ] }, { x: 1200, y: 280 }, { arc: [ { x: 0, y: 340 }, { x: -1200, y: 280 } ] }],
|
||||||
|
stroke: 50, color: color.bgMidWater, fill: true
|
||||||
|
});
|
||||||
|
|
||||||
|
deepTransitionBlock = new Zdog.Shape({
|
||||||
|
addTo: backgroundZone,
|
||||||
|
path: [{ x: -1200, y: 260 }, { arc: [ { x: 0, y: 320 }, { x: 1200, y: 260 } ] }, { x: 1200, y: 540 }, { arc: [ { x: 0, y: 600 }, { x: -1200, y: 540 } ] }],
|
||||||
|
stroke: 50, color: color.bgTransBlue1, fill: true
|
||||||
|
});
|
||||||
|
|
||||||
|
abyssBlock = new Zdog.Shape({
|
||||||
|
addTo: backgroundZone,
|
||||||
|
path: [{ x: -1200, y: 520 }, { arc: [ { x: 0, y: 580 }, { x: 1200, y: 520 } ] }, { x: 2500, y: 2500 }, { x: -2500, y: 2500 }],
|
||||||
|
stroke: 50, color: color.bgTransBlue2, fill: true
|
||||||
|
});
|
||||||
|
|
||||||
|
fishZone = new Zdog.Anchor({ addTo: scene });
|
||||||
|
|
||||||
|
blobMasterAnchor = new Zdog.Anchor({ addTo: fishZone, scale: 2 });
|
||||||
|
new Zdog.Shape({ addTo: blobMasterAnchor, stroke: 260, color: color.blobSkin });
|
||||||
|
new Zdog.Shape({ addTo: blobMasterAnchor, stroke: 180, color: color.blobSkin, translate: { y: -40, z: 6 } });
|
||||||
|
new Zdog.Shape({ addTo: blobMasterAnchor, stroke: 100, color: color.blobNose, translate: { y: 14, z: 62 } });
|
||||||
|
new Zdog.Shape({ addTo: blobMasterAnchor, stroke: 120, color: color.blobSkin, translate: { x: -38, y: 24, z: 18 } });
|
||||||
|
new Zdog.Shape({ addTo: blobMasterAnchor, stroke: 120, color: color.blobSkin, translate: { x: 38, y: 24, z: 18 } });
|
||||||
|
|
||||||
|
eyeL_pink = new Zdog.Anchor({ addTo: blobMasterAnchor, translate: { x: -18, y: -14, z: 52 } });
|
||||||
|
new Zdog.Shape({ addTo: eyeL_pink, stroke: 30, color: '#FFFFFF' });
|
||||||
|
new Zdog.Shape({ addTo: eyeL_pink, stroke: 12, color: '#111111', translate: { z: 6 } });
|
||||||
|
|
||||||
|
eyeR_pink = new Zdog.Anchor({ addTo: blobMasterAnchor, translate: { x: 18, y: -14, z: 52 } });
|
||||||
|
new Zdog.Shape({ addTo: eyeR_pink, stroke: 30, color: '#FFFFFF' });
|
||||||
|
new Zdog.Shape({ addTo: eyeR_pink, stroke: 12, color: '#111111', translate: { z: 6 } });
|
||||||
|
|
||||||
|
mouth_pink = new Zdog.Shape({
|
||||||
|
addTo: blobMasterAnchor, stroke: 14, color: color.blobMouth, closed: false,
|
||||||
|
path: [{ x: -28, y: 40, z: 44 }, { bezier: [{ x: -12, y: 28, z: 54 }, { x: 12, y: 28, z: 54 }, { x: 28, y: 40, z: 44 }] }]
|
||||||
|
});
|
||||||
|
|
||||||
|
const finShapePath = [{ x: 0, y: 0 }, { x: -30, y: -10 }, { x: -50, y: 15 }, { x: -40, y: 35 }, { x: -10, y: 25 }];
|
||||||
|
|
||||||
|
leftPecContainer = new Zdog.Anchor({ addTo: blobMasterAnchor });
|
||||||
|
new Zdog.Shape({ addTo: new Zdog.Anchor({ addTo: leftPecContainer, translate: { x: -52, y: 22, z: -8 } }), path: finShapePath, stroke: 24, color: color.blobFin, fill: true });
|
||||||
|
|
||||||
|
rightPecContainer = new Zdog.Anchor({ addTo: blobMasterAnchor });
|
||||||
|
new Zdog.Shape({ addTo: new Zdog.Anchor({ addTo: rightPecContainer, translate: { x: 52, y: 22, z: -8 } }), path: finShapePath, scale: { x: -1 }, stroke: 24, color: color.blobFin, fill: true });
|
||||||
|
|
||||||
|
const blobTail = new Zdog.Anchor({ addTo: blobMasterAnchor, translate: { y: 10, z: -68 } });
|
||||||
|
new Zdog.Shape({ addTo: blobTail, stroke: 52, color: color.blobTailBody });
|
||||||
|
blobTailFin = new Zdog.Anchor({ addTo: blobTail, translate: { z: -14 } });
|
||||||
|
new Zdog.Shape({ addTo: blobTailFin, stroke: 32, color: color.blobFin, closed: false, path: [{ y: 0, z: 0 }, { bezier: [{ x: -6, y: -8, z: -4 }, { x: -12, y: -12, z: -8 }, { x: 0, y: -18, z: -14 }] }] });
|
||||||
|
|
||||||
|
handsomeMasterAnchor = new Zdog.Anchor({ addTo: fishZone, scale: 3 });
|
||||||
|
new Zdog.Shape({ addTo: handsomeMasterAnchor, stroke: 240, color: color.blobDeepSea });
|
||||||
|
new Zdog.Shape({ addTo: handsomeMasterAnchor, stroke: 190, color: color.blobDeepSea, translate: { z: -30 } });
|
||||||
|
new Zdog.Shape({ addTo: handsomeMasterAnchor, stroke: 120, color: color.blobDeepSea, translate: { x: -28, y: 14, z: 12 } });
|
||||||
|
new Zdog.Shape({ addTo: handsomeMasterAnchor, stroke: 120, color: color.blobDeepSea, translate: { x: 28, y: 14, z: 12 } });
|
||||||
|
|
||||||
|
eyeL_blue = new Zdog.Anchor({ addTo: handsomeMasterAnchor, translate: { x: -22, y: -12, z: 25 } });
|
||||||
|
new Zdog.Shape({ addTo: eyeL_blue, stroke: 32, color: color.blobEyeDeep });
|
||||||
|
new Zdog.Shape({ addTo: eyeL_blue, stroke: 14, color: color.blobPupilDeep, translate: { z: 6 } });
|
||||||
|
|
||||||
|
eyeR_blue = new Zdog.Anchor({ addTo: handsomeMasterAnchor, translate: { x: 22, y: -12, z: 25 } });
|
||||||
|
new Zdog.Shape({ addTo: eyeR_blue, stroke: 32, color: color.blobEyeDeep });
|
||||||
|
new Zdog.Shape({ addTo: eyeR_blue, stroke: 14, color: color.blobPupilDeep, translate: { z: 6 } });
|
||||||
|
|
||||||
|
mouth_blue = new Zdog.Shape({
|
||||||
|
addTo: handsomeMasterAnchor, stroke: 11, color: color.blobMouthDeep, closed: false,
|
||||||
|
path: [{ x: -18, y: 10, z: 25 }, { x: 0, y: 14, z: 27 }, { x: 18, y: 10, z: 25 }]
|
||||||
|
});
|
||||||
|
|
||||||
|
leftWingContainer = new Zdog.Anchor({ addTo: handsomeMasterAnchor });
|
||||||
|
new Zdog.Shape({ addTo: new Zdog.Anchor({ addTo: leftWingContainer, translate: { x: -48, y: 22, z: -8 } }), path: finShapePath, scale: { x: 0.666, y: 0.666, z: 0.666 }, stroke: 16, color: color.blobDeepSeaDark, fill: true });
|
||||||
|
|
||||||
|
rightWingContainer = new Zdog.Anchor({ addTo: handsomeMasterAnchor });
|
||||||
|
new Zdog.Shape({ addTo: new Zdog.Anchor({ addTo: rightWingContainer, translate: { x: 48, y: 22, z: -8 } }), path: finShapePath, scale: { x: -0.666, y: 0.666, z: 0.666 }, stroke: 16, color: color.blobDeepSeaDark, fill: true });
|
||||||
|
|
||||||
|
const handsomeTail = new Zdog.Anchor({ addTo: handsomeMasterAnchor, translate: { y: -4, z: -72 } });
|
||||||
|
new Zdog.Shape({ addTo: handsomeTail, stroke: 60, color: color.blobDeepSea });
|
||||||
|
handsomeTailFin = new Zdog.Anchor({ addTo: handsomeTail, translate: { z: -14 } });
|
||||||
|
new Zdog.Shape({ addTo: handsomeTailFin, stroke: 80, color: color.blobDeepSeaDark, closed: false, path: [{ y: 0, z: 0 }, { bezier: [{ x: -6, y: -8, z: -5 }, { x: -14, y: -14, z: -9 }, { x: 0, y: -22, z: -14 }] }] });
|
||||||
|
|
||||||
|
pinkFadeShapes = setupFadeGroup(blobMasterAnchor);
|
||||||
|
darkFadeShapes = setupFadeGroup(handsomeMasterAnchor);
|
||||||
|
sceneryFadeShapes = setupFadeGroup(sceneryZone);
|
||||||
|
|
||||||
|
uiScene = new Zdog.Illustration({ element: uiCanvasRef, dragRotate: false, resize: false });
|
||||||
|
|
||||||
|
const UIAnchor = new Zdog.Anchor({ addTo: uiScene, translate: { x: 0, y: 0 } });
|
||||||
|
new Zdog.Cylinder({ addTo: UIAnchor, diameter: 82, length: 16, stroke: false, color: '#7f8c8d', backface: '#34495e' });
|
||||||
|
new Zdog.Cylinder({ addTo: UIAnchor, diameter: 70, length: 2, stroke: false, color: '#FFFFFF', translate: { z: 8 } });
|
||||||
|
|
||||||
|
for (let i = 0; i < 6; i++) {
|
||||||
|
let angle = (i / 5) * Math.PI - Math.PI;
|
||||||
|
new Zdog.Shape({ addTo: UIAnchor, path: [{ y: -26 }, { y: -32 }], stroke: 3, color: '#e74c3c', rotate: { z: angle }, translate: { z: 9.5 } });
|
||||||
|
}
|
||||||
|
|
||||||
|
gaugeNeedle = new Zdog.Anchor({ addTo: UIAnchor, translate: { z: 11 }, rotate: { z: -Math.PI * 0.75 } });
|
||||||
|
new Zdog.Shape({ addTo: gaugeNeedle, path: [{ y: 6 }, { y: -30 }], stroke: 4, color: '#2c3e50' });
|
||||||
|
new Zdog.Shape({ addTo: gaugeNeedle, stroke: 12, color: '#e74c3c' });
|
||||||
|
|
||||||
|
uiScene.updateRenderGraph();
|
||||||
|
scheduleThought();
|
||||||
|
|
||||||
|
let frame = 0; let running = true;
|
||||||
|
|
||||||
|
function drawSeaweed() {
|
||||||
|
if (fishState.darkAlpha < 0.01) return;
|
||||||
|
const w = overlayCanvasRef.width; const h = overlayCanvasRef.height;
|
||||||
|
|
||||||
|
seaweedStrands.forEach((strand, idx) => {
|
||||||
|
const baseX = w * strand.xPct;
|
||||||
|
const currentHeight = strand.height * fishState.darkAlpha;
|
||||||
|
const greenTone = idx % 2 === 0 ? color.seaweedDark : color.seaweedLight;
|
||||||
|
|
||||||
|
ctx.save(); ctx.beginPath();
|
||||||
|
ctx.fillStyle = `rgba(${greenTone}, ${0.65 * fishState.darkAlpha})`;
|
||||||
|
|
||||||
|
const segments = 7;
|
||||||
|
let leftSidePoints = [], rightSidePoints = [];
|
||||||
|
|
||||||
|
for (let i = 0; i <= segments; i++) {
|
||||||
|
const segmentPct = i / segments;
|
||||||
|
const yPos = h + 30 - (currentHeight * segmentPct);
|
||||||
|
const waveFactor = Math.sin((frame * strand.speed) + (segmentPct * Math.PI * 1.2) + strand.delay);
|
||||||
|
const xOffset = waveFactor * (25 * segmentPct);
|
||||||
|
const currentWidth = strand.baseWidth * (1 - segmentPct);
|
||||||
|
|
||||||
|
leftSidePoints.push({ x: baseX + xOffset - currentWidth / 2, y: yPos });
|
||||||
|
rightSidePoints.unshift({ x: baseX + xOffset + currentWidth / 2, y: yPos });
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.moveTo(leftSidePoints[0].x, leftSidePoints[0].y);
|
||||||
|
leftSidePoints.forEach(pt => ctx.lineTo(pt.x, pt.y));
|
||||||
|
rightSidePoints.forEach(pt => ctx.lineTo(pt.x, pt.y));
|
||||||
|
ctx.closePath(); ctx.fill(); ctx.restore();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawWaterTransition() {
|
||||||
|
ctx.clearRect(0, 0, overlayCanvasRef.width, overlayCanvasRef.height);
|
||||||
|
drawSeaweed();
|
||||||
|
|
||||||
|
let progress = transitionProgress.value;
|
||||||
|
if (progress <= 0 || progress >= 1) return;
|
||||||
|
|
||||||
|
const w = overlayCanvasRef.width; const h = overlayCanvasRef.height;
|
||||||
|
ctx.fillStyle = color.waterTransition;
|
||||||
|
|
||||||
|
const numBars = 3; const barThickness = Math.max(w, h) * 0.4;
|
||||||
|
|
||||||
|
for (let i = 0; i < numBars; i++) {
|
||||||
|
let barProgress = gsap.utils.clamp(0, 1, gsap.utils.mapRange(i * 0.1, 1 - (numBars - 1 - i) * 0.1, 0, 1, progress));
|
||||||
|
ctx.save();
|
||||||
|
ctx.translate(-barThickness + (w + barThickness * 2) * barProgress, h / 2);
|
||||||
|
ctx.rotate(-Math.PI / 6);
|
||||||
|
ctx.fillRect(-barThickness / 2, -h * 1.5, barThickness, h * 3);
|
||||||
|
ctx.restore();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function render() {
|
||||||
|
if (!running) return;
|
||||||
|
frame++;
|
||||||
|
|
||||||
|
if (!isMeshRunningAnimation) {
|
||||||
|
fishZone.translate.y = Math.sin(frame * 0.035) * 14;
|
||||||
|
fishZone.rotate.z = Math.cos(frame * 0.025) * 0.04;
|
||||||
|
}
|
||||||
|
|
||||||
|
cloud1.translate.x += 0.28; if (cloud1.translate.x > 950) cloud1.translate.x = -950;
|
||||||
|
cloud2.translate.x += 0.16; if (cloud2.translate.x > 950) cloud2.translate.x = -950;
|
||||||
|
cloud3.translate.x += 0.22; if (cloud3.translate.x > 950) cloud3.translate.x = -950;
|
||||||
|
|
||||||
|
backgroundZone.rotate.y = -scene.rotate.y; backgroundZone.rotate.x = -scene.rotate.x;
|
||||||
|
sceneryZone.rotate.y = -scene.rotate.y; sceneryZone.rotate.x = -scene.rotate.x;
|
||||||
|
|
||||||
|
pinkFadeShapes.forEach(({ node, r, g, b }) => node.color = `rgba(${r},${g},${b},${fishState.pinkAlpha})`);
|
||||||
|
darkFadeShapes.forEach(({ node, r, g, b }) => node.color = `rgba(${r},${g},${b},${fishState.darkAlpha})`);
|
||||||
|
sceneryFadeShapes.forEach(({ node, r, g, b }) => node.color = `rgba(${r},${g},${b},${fishState.pinkAlpha})`);
|
||||||
|
|
||||||
|
cloud1.children.forEach(c => c.color = `rgba(255,255,255,${0.75 * fishState.pinkAlpha})`);
|
||||||
|
cloud2.children.forEach(c => c.color = `rgba(255,255,255,${0.75 * fishState.pinkAlpha})`);
|
||||||
|
cloud3.children.forEach(c => c.color = `rgba(255,255,255,${0.75 * fishState.pinkAlpha})`);
|
||||||
|
|
||||||
|
blobMasterAnchor.visible = fishState.pinkAlpha > 0.01;
|
||||||
|
handsomeMasterAnchor.visible = fishState.darkAlpha > 0.01;
|
||||||
|
sceneryZone.visible = fishState.pinkAlpha > 0.01;
|
||||||
|
|
||||||
|
leftPecContainer.rotate.z = Math.sin(frame / (22 / customFinSpeedFactor)) * 0.14;
|
||||||
|
rightPecContainer.rotate.z = -Math.sin(frame / (22 / customFinSpeedFactor)) * 0.14;
|
||||||
|
leftWingContainer.rotate.z = Math.sin(frame / (26 / customFinSpeedFactor)) * 0.11;
|
||||||
|
rightWingContainer.rotate.z = -Math.sin(frame / (26 / customFinSpeedFactor)) * 0.11;
|
||||||
|
|
||||||
|
if (blobTailFin) blobTailFin.rotate.y = Math.sin(frame * (0.07 * customTailSpeedFactor)) * 0.18;
|
||||||
|
if (handsomeTailFin) handsomeTailFin.rotate.y = Math.sin(frame * (0.06 * customTailSpeedFactor)) * 0.16;
|
||||||
|
|
||||||
|
const blink = frame % 280;
|
||||||
|
let s_blink = 1;
|
||||||
|
if (blink > 262) s_blink = 0.5 + 0.5 * Math.cos(((blink - 262) / 18) * Math.PI * 2);
|
||||||
|
|
||||||
|
eyeL_pink.scale.y = eyeR_pink.scale.y = eyeL_blue.scale.y = eyeR_blue.scale.y = s_blink;
|
||||||
|
|
||||||
|
scene.updateRenderGraph(); uiScene.updateRenderGraph(); drawWaterTransition();
|
||||||
|
requestAnimationFrame(render);
|
||||||
|
}
|
||||||
|
|
||||||
|
render();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
running = false;
|
||||||
|
if (thoughtTimer) clearTimeout(thoughtTimer);
|
||||||
|
if (bubbleAutoHideTimer) clearTimeout(bubbleAutoHideTimer);
|
||||||
|
window.removeEventListener('resize', resizeOverlay);
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
function handleDiveToggle() {
|
||||||
|
if (isAnimating) return;
|
||||||
|
isAnimating = true;
|
||||||
|
isDeep = !isDeep;
|
||||||
|
|
||||||
|
const tl = gsap.timeline({
|
||||||
|
onComplete: () => {
|
||||||
|
isAnimating = false;
|
||||||
|
hintText = isDeep ? 'Click the gauge to surface' : 'Double click the fish to jump • Click the gauge to dive';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
tl.to([skyBlock.translate, midBlock.translate, midTransitionBlock.translate, deepTransitionBlock.translate, abyssBlock.translate, sceneryZone.translate], {
|
||||||
|
duration: 2.5, y: isDeep ? -850 : 0, ease: 'power2.inOut'
|
||||||
|
}, 0);
|
||||||
|
|
||||||
|
tl.to({}, {
|
||||||
|
duration: 1.25,
|
||||||
|
onComplete: () => {
|
||||||
|
if (isDeep) {
|
||||||
|
skyBlock.color = color.bgTransBlue1; midBlock.color = color.bgTransBlue2;
|
||||||
|
midTransitionBlock.color = color.bgDeepWater; deepTransitionBlock.color = color.bgAbyss; abyssBlock.color = '#040714';
|
||||||
|
} else {
|
||||||
|
skyBlock.color = color.bgSky; midBlock.color = color.bgPinkWater;
|
||||||
|
midTransitionBlock.color = color.bgMidWater; deepTransitionBlock.color = color.bgTransBlue1; abyssBlock.color = color.bgTransBlue2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, 0.6);
|
||||||
|
|
||||||
|
tl.to(gaugeNeedle.rotate, { duration: 2.5, z: isDeep ? Math.PI * 0.75 : -Math.PI * 0.75, ease: 'back.out(1.2)' }, 0);
|
||||||
|
|
||||||
|
transitionProgress.value = 0;
|
||||||
|
tl.to(transitionProgress, { duration: 1.4, value: 1, ease: 'power1.inOut' }, 0.55);
|
||||||
|
|
||||||
|
tl.call(() => {
|
||||||
|
fishState.pinkAlpha = isDeep ? 0 : 1;
|
||||||
|
fishState.darkAlpha = isDeep ? 1 : 0;
|
||||||
|
}, null, 1.25);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if !embedded}
|
||||||
|
<div class="top-bar">
|
||||||
|
<button class="bar-btn" on:click={() => dispatch('back')}>←</button>
|
||||||
|
<button class="bar-btn" on:click={toggleHistory}>
|
||||||
|
{historyOpen ? 'Close history' : '💬 History'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if historyOpen}
|
||||||
|
<div class="history-panel">
|
||||||
|
<div class="history-header">
|
||||||
|
<h2>Session chat</h2>
|
||||||
|
<button class="close-btn" on:click={() => historyOpen = false}>✕</button>
|
||||||
|
</div>
|
||||||
|
<div class="history-scroll">
|
||||||
|
{#each chatHistory as msg}
|
||||||
|
<div class="history-msg {msg.role}">
|
||||||
|
<span class="history-label">{msg.role === 'assistant' ? '🐟 Blobfish' : 'You'}</span>
|
||||||
|
<p>{msg.content}</p>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<canvas bind:this={overlayCanvasRef} class="overlay-scene"></canvas>
|
||||||
|
<canvas bind:this={canvasRef} class="scene" class:embedded on:pointerdown={onPointerDown} on:pointermove={onPointerMove} on:pointerup={onPointerUp} on:pointercancel={onPointerUp} on:click={onCanvasClick}></canvas>
|
||||||
|
<canvas bind:this={uiCanvasRef} class="ui-scene" on:click={handleDiveToggle}></canvas>
|
||||||
|
|
||||||
|
<div class="center-actions">
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="thought-wrap" class:visible={thoughtBubbleVisible || isReplying}>
|
||||||
|
<div class="thought-bubble">
|
||||||
|
<div class="thought-dots" style="display: {isApiLoading ? 'flex' : 'none'}">
|
||||||
|
<span></span><span></span><span></span>
|
||||||
|
</div>
|
||||||
|
<p class="thought-text" style="display: {isApiLoading ? 'none' : 'block'}">{thoughtBubbleText}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="thought-reply">
|
||||||
|
<textarea
|
||||||
|
bind:this={replyInputRef}
|
||||||
|
bind:value={replyInputValue}
|
||||||
|
placeholder="Reply to the Blobfish…"
|
||||||
|
rows="1"
|
||||||
|
disabled={isApiLoading}
|
||||||
|
on:keydown={handleReplyKey}
|
||||||
|
></textarea>
|
||||||
|
<button class="reply-send" on:click={sendReply} disabled={isApiLoading || !replyInputValue.trim()}>
|
||||||
|
{isApiLoading ? '…' : '➤'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="hint">{hintText}</p>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
@import url("https://fonts.googleapis.com/css2?family=Nunito:wght@400;600;700;800&display=swap");
|
||||||
|
|
||||||
|
:global(body) {
|
||||||
|
margin: 0; padding: 0;
|
||||||
|
width: 100vw; height: 100vh;
|
||||||
|
background: #0B132B;
|
||||||
|
overflow: hidden;
|
||||||
|
font-family: 'Nunito', sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
canvas.scene, canvas.overlay-scene {
|
||||||
|
position: fixed; inset: 0;
|
||||||
|
width: 100vw; height: 100vh;
|
||||||
|
display: block; touch-action: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
canvas.scene { z-index: 1; }
|
||||||
|
canvas.overlay-scene { z-index: 2; pointer-events: none; }
|
||||||
|
|
||||||
|
canvas.scene.embedded { position: absolute; width: 100%; height: 100%; inset: 0; }
|
||||||
|
|
||||||
|
canvas.ui-scene {
|
||||||
|
position: fixed;
|
||||||
|
top: 70px; right: 16px;
|
||||||
|
width: 120px; height: 120px;
|
||||||
|
z-index: 10;
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Top bar & History panel ────────────────────────────────────────── */
|
||||||
|
.top-bar {
|
||||||
|
position: fixed;
|
||||||
|
top: 16px; left: 16px;
|
||||||
|
display: flex; gap: 10px;
|
||||||
|
z-index: 30;
|
||||||
|
}
|
||||||
|
.bar-btn {
|
||||||
|
padding: 9px 16px;
|
||||||
|
border: none; border-radius: 20px;
|
||||||
|
background: rgba(255,255,255,0.92);
|
||||||
|
color: #2E3A47;
|
||||||
|
font-weight: 700; font-size: 0.85rem;
|
||||||
|
cursor: pointer;
|
||||||
|
box-shadow: 0 4px 18px rgba(0,0,0,.15);
|
||||||
|
transition: background .15s, transform .1s;
|
||||||
|
}
|
||||||
|
.bar-btn:hover { background: #fff; transform: translateY(-1px); }
|
||||||
|
.bar-btn:active { transform: scale(.96); }
|
||||||
|
|
||||||
|
.history-panel {
|
||||||
|
position: fixed;
|
||||||
|
top: 62px; left: 16px;
|
||||||
|
width: min(360px, calc(100vw - 32px));
|
||||||
|
max-height: calc(100vh - 80px);
|
||||||
|
background: rgba(255,255,255,0.97);
|
||||||
|
border-radius: 24px;
|
||||||
|
box-shadow: 0 20px 60px rgba(0,0,0,.25);
|
||||||
|
z-index: 25; display: flex; flex-direction: column; overflow: hidden;
|
||||||
|
}
|
||||||
|
.history-header {
|
||||||
|
display: flex; justify-content: space-between; align-items: center;
|
||||||
|
padding: 18px 20px 12px;
|
||||||
|
border-bottom: 1px solid rgba(74,144,226,.18);
|
||||||
|
}
|
||||||
|
.history-header h2 { margin: 0; font-size: 1rem; color: #2E3A47; }
|
||||||
|
.close-btn {
|
||||||
|
border: none; background: #e4eef8; color: #3A6699; border-radius: 50%;
|
||||||
|
width: 30px; height: 30px; cursor: pointer; font-size: 0.85rem; font-weight: 700;
|
||||||
|
}
|
||||||
|
.history-scroll {
|
||||||
|
overflow-y: auto; padding: 14px 16px;
|
||||||
|
display: flex; flex-direction: column; gap: 10px;
|
||||||
|
}
|
||||||
|
.history-msg { border-radius: 16px; padding: 12px 14px; font-size: 0.9rem; line-height: 1.55; }
|
||||||
|
.history-msg p { margin: 4px 0 0; color: #26313b; }
|
||||||
|
.history-msg.assistant { background: #eef4fb; }
|
||||||
|
.history-msg.user { background: #f7f4ef; }
|
||||||
|
.history-label { font-weight: 800; font-size: 0.78rem; color: #4A7BB0; }
|
||||||
|
|
||||||
|
/* ── Center Bottom Menu ────────────────────────────────────────────── */
|
||||||
|
.center-actions {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 32px;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
z-index: 20;
|
||||||
|
}
|
||||||
|
.action-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 12px 20px;
|
||||||
|
background: rgba(255,255,255,0.95);
|
||||||
|
border: none;
|
||||||
|
border-radius: 30px;
|
||||||
|
font-weight: 800;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
color: #2E3A47;
|
||||||
|
cursor: pointer;
|
||||||
|
box-shadow: 0 8px 24px rgba(0,0,0,0.25);
|
||||||
|
transition: transform 0.15s, background 0.15s;
|
||||||
|
font-family: 'Nunito', sans-serif;
|
||||||
|
}
|
||||||
|
.action-btn:hover { background: #fff; transform: translateY(-3px); }
|
||||||
|
.action-btn:active { transform: translateY(1px); }
|
||||||
|
.btn-icon { font-size: 1.2rem; }
|
||||||
|
|
||||||
|
/* ── Thought bubble (Locked to Bottom Right) ───────────────────────── */
|
||||||
|
.thought-wrap {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 90px;
|
||||||
|
right: 32px;
|
||||||
|
max-width: min(360px, 90vw);
|
||||||
|
display: flex; flex-direction: column; gap: 10px;
|
||||||
|
z-index: 15; pointer-events: none; opacity: 0;
|
||||||
|
transform: translateY(8px) scale(.96);
|
||||||
|
transition: opacity .35s ease, transform .35s ease;
|
||||||
|
align-items: flex-end;
|
||||||
|
}
|
||||||
|
.thought-wrap.visible { opacity: 1; transform: translateY(0) scale(1); pointer-events: all; }
|
||||||
|
|
||||||
|
.thought-bubble {
|
||||||
|
background: #fff;
|
||||||
|
border: 2px solid rgba(74,144,226,.40);
|
||||||
|
border-radius: 22px 22px 6px 22px;
|
||||||
|
padding: 14px 18px;
|
||||||
|
box-shadow: 0 8px 36px rgba(0,0,0,.25);
|
||||||
|
position: relative; max-width: 100%;
|
||||||
|
}
|
||||||
|
.thought-dots { display: flex; gap: 4px; margin-bottom: 6px; }
|
||||||
|
.thought-dots span { width: 5px; height: 5px; background: #4A90E2; border-radius: 50%; }
|
||||||
|
.thought-text { margin: 0; font-size: 0.92rem; color: #26313b; line-height: 1.6; font-weight: 600; }
|
||||||
|
|
||||||
|
.thought-reply { display: flex; gap: 8px; align-items: flex-end; width: 100%; }
|
||||||
|
.thought-reply textarea {
|
||||||
|
flex: 1; background: rgba(255,255,255,0.96);
|
||||||
|
border: 1.5px solid rgba(74,144,226,.50);
|
||||||
|
border-radius: 14px; padding: 10px 14px;
|
||||||
|
font-family: 'Nunito', sans-serif; font-size: 0.9rem; color: #26313b;
|
||||||
|
outline: none; resize: none;
|
||||||
|
height: 44px; min-height: 44px; max-height: 88px;
|
||||||
|
line-height: 1.5; box-sizing: border-box;
|
||||||
|
transition: border-color .15s, box-shadow .15s;
|
||||||
|
}
|
||||||
|
.thought-reply textarea::placeholder { color: #81a4c9; }
|
||||||
|
.thought-reply textarea:focus { border-color: #3B78C4; box-shadow: 0 0 0 3px rgba(59,120,196,.20); }
|
||||||
|
|
||||||
|
.reply-send {
|
||||||
|
width: 44px; height: 44px;
|
||||||
|
border-radius: 50%; border: none;
|
||||||
|
background: #4A90E2; color: #fff;
|
||||||
|
font-size: 1rem; cursor: pointer; flex-shrink: 0;
|
||||||
|
transition: background .12s, transform .1s;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
}
|
||||||
|
.reply-send:hover:not(:disabled) { background: #3B78C4; }
|
||||||
|
.reply-send:active:not(:disabled) { transform: scale(.94); }
|
||||||
|
.reply-send:disabled { opacity: .5; cursor: default; }
|
||||||
|
|
||||||
|
/* ── Hints ───────────────────────────────────────────────────────── */
|
||||||
|
.hint {
|
||||||
|
position: fixed;
|
||||||
|
top: 22px; left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
font-size: 0.85rem; font-weight: 700;
|
||||||
|
color: rgba(255, 255, 255, 0.8);
|
||||||
|
pointer-events: none;
|
||||||
|
letter-spacing: .04em;
|
||||||
|
z-index: 10;
|
||||||
|
text-shadow: 0 2px 4px rgba(0,0,0,0.4);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
660
src/Chicken.svelte
Normal file
660
src/Chicken.svelte
Normal file
@@ -0,0 +1,660 @@
|
|||||||
|
<script>
|
||||||
|
//@ts-nocheck
|
||||||
|
import { onMount, createEventDispatcher } from 'svelte';
|
||||||
|
import Zdog from 'zdog';
|
||||||
|
import { gsap } from 'gsap';
|
||||||
|
import ChickenBackground from './ChickenBackground.svelte';
|
||||||
|
|
||||||
|
const dispatch = createEventDispatcher();
|
||||||
|
let canvasRef;
|
||||||
|
|
||||||
|
export let embedded = false;
|
||||||
|
|
||||||
|
const OPENAI_API_KEY = import.meta.env.VITE_OPENAI_API_KEY;
|
||||||
|
|
||||||
|
let thoughtBubbleVisible = true;
|
||||||
|
let thoughtBubbleText = 'Bawk bawk… got a question for this thoughtful chicken?';
|
||||||
|
let replyInputValue = '';
|
||||||
|
let isApiLoading = false;
|
||||||
|
let historyOpen = false;
|
||||||
|
|
||||||
|
let chatHistory = [
|
||||||
|
{ role: 'assistant', content: 'Bawk bawk… got a question for this thoughtful chicken?' }
|
||||||
|
];
|
||||||
|
|
||||||
|
const SYSTEM_TEXT = `You are Professor Cluck, a clever, warm, slightly silly chicken. You answer in short, helpful messages, usually 2-4 sentences max. You can use light chicken humor, but keep answers useful and clear.`;
|
||||||
|
|
||||||
|
let chatFn, peckFn, strutFn;
|
||||||
|
|
||||||
|
function goHome() { dispatch('back'); }
|
||||||
|
|
||||||
|
function toggleHistory() {
|
||||||
|
historyOpen = !historyOpen;
|
||||||
|
}
|
||||||
|
|
||||||
|
function showThought(text) {
|
||||||
|
thoughtBubbleText = text;
|
||||||
|
thoughtBubbleVisible = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendReply() {
|
||||||
|
const msg = replyInputValue.trim();
|
||||||
|
if (!msg || isApiLoading) return;
|
||||||
|
replyInputValue = '';
|
||||||
|
await askChicken(msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleReplyKey(e) {
|
||||||
|
if (e.key === 'Enter' && !e.shiftKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
sendReply();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function askChicken(userMessage) {
|
||||||
|
if (isApiLoading) return;
|
||||||
|
isApiLoading = true;
|
||||||
|
showThought('…');
|
||||||
|
chatFn?.();
|
||||||
|
|
||||||
|
chatHistory = [...chatHistory, { role: 'user', content: userMessage }];
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch('https://api.openai.com/v1/chat/completions', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `Bearer ${OPENAI_API_KEY}`
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
model: 'gpt-4o-mini',
|
||||||
|
messages: [
|
||||||
|
{ role: 'system', content: SYSTEM_TEXT },
|
||||||
|
...chatHistory
|
||||||
|
],
|
||||||
|
max_tokens: 256,
|
||||||
|
temperature: 0.85
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) throw new Error(`API returned status ${res.status}`);
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
const replyText = data?.choices?.[0]?.message?.content?.trim();
|
||||||
|
|
||||||
|
if (replyText) {
|
||||||
|
chatHistory = [...chatHistory, { role: 'assistant', content: replyText }];
|
||||||
|
showThought(replyText);
|
||||||
|
} else {
|
||||||
|
showThought('Bawk… my thought got scrambled. Try asking again?');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
chatHistory = chatHistory.slice(0, -1);
|
||||||
|
showThought('Bawk… my thought got scrambled. Try asking again?');
|
||||||
|
} finally {
|
||||||
|
isApiLoading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
if (!canvasRef) return;
|
||||||
|
const TAU = Zdog.TAU;
|
||||||
|
|
||||||
|
const color = {
|
||||||
|
body: '#FFF1E6', headBase: '#FFF5E8', eye: '#FFFFFF', pupil: '#111111',
|
||||||
|
comb: '#FF3B5C', tail: '#F8D7C7', leg: '#FAA353',
|
||||||
|
sky: '#FFB7D5', skyLight: '#FFE1F0',
|
||||||
|
land: '#7ab535', landLight: '#9fd95c', darkGreen: '#3A5C18',
|
||||||
|
trunkBrown: '#8B6340', cloudPink: '#FFE4F0', cloudWhite: '#FFF7FB',
|
||||||
|
blossomPink: '#FFACD2', appleRed: '#FF4B4B',
|
||||||
|
leafGreen: '#5DA020', daisyWhite: '#FFFFFF', daisyYellow: '#F9D342',
|
||||||
|
roseRed: '#F04A6F', rosePink: '#FF8FAE',
|
||||||
|
lavPurple: '#C07EE0', lavLight: '#E2B8F5',
|
||||||
|
sunflowerY: '#FFB800', sunflowerC: '#7A3B10',
|
||||||
|
tulipPink: '#FF85A2', tulipOrange: '#FF6B35'
|
||||||
|
};
|
||||||
|
|
||||||
|
const scene = new Zdog.Illustration({ element: canvasRef, dragRotate: false, resize: 'window' });
|
||||||
|
|
||||||
|
// ─── Flower clusters ──────────────────────────────────────────────
|
||||||
|
function createDaisy(parent, x, y, z, scale, stemLen, rotX, rotY, petalCol) {
|
||||||
|
petalCol = petalCol || color.daisyWhite;
|
||||||
|
const fg = new Zdog.Anchor({ addTo: parent, translate: { x, y: y + (120 - stemLen), z }, scale });
|
||||||
|
new Zdog.Shape({ addTo: fg, path: [{ x: 0, y: stemLen, z: 0 }, { x: 0, y: 0, z: 0 }], stroke: 7, color: color.leafGreen });
|
||||||
|
const hd = new Zdog.Anchor({ addTo: fg, rotate: { x: rotX, y: rotY } });
|
||||||
|
for (let i = 0; i < 13; i++) {
|
||||||
|
const ph = new Zdog.Anchor({ addTo: hd, rotate: { z: (TAU / 13) * i } });
|
||||||
|
new Zdog.Ellipse({ addTo: ph, width: 11, height: 30, fill: true, color: petalCol, stroke: 0, translate: { y: -18 } });
|
||||||
|
}
|
||||||
|
new Zdog.Ellipse({ addTo: hd, diameter: 15, fill: true, stroke: 4, color: color.daisyYellow, translate: { z: 1.5 } });
|
||||||
|
}
|
||||||
|
|
||||||
|
function createRose(parent, x, y, z, scale, stemLen, rotX, rotY) {
|
||||||
|
const fg = new Zdog.Anchor({ addTo: parent, translate: { x, y: y + (140 - stemLen), z }, scale });
|
||||||
|
new Zdog.Shape({ addTo: fg, path: [{ x: 0, y: stemLen }, { x: 0, y: 0 }], stroke: 7, color: color.leafGreen });
|
||||||
|
const hd = new Zdog.Anchor({ addTo: fg, rotate: { x: rotX, y: rotY } });
|
||||||
|
for (let l = 0; l < 3; l++) {
|
||||||
|
const pc = 6 + l * 2, r = 10 + l * 9;
|
||||||
|
for (let i = 0; i < pc; i++) {
|
||||||
|
const a = (TAU / pc) * i + l * 0.3;
|
||||||
|
const ph = new Zdog.Anchor({ addTo: hd, translate: { x: Math.cos(a) * r, y: Math.sin(a) * r } });
|
||||||
|
new Zdog.Ellipse({ addTo: ph, width: l === 0 ? 8 : 10 + l * 2, height: l === 0 ? 12 : 14 + l * 3, fill: true, color: l === 0 ? color.roseRed : color.rosePink, stroke: 0, rotate: { z: a } });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
new Zdog.Ellipse({ addTo: hd, diameter: 8, fill: true, stroke: 0, color: '#c0243a', translate: { z: 1 } });
|
||||||
|
}
|
||||||
|
|
||||||
|
function createLavender(parent, x, y, z, scale, stemLen, rotX, rotY) {
|
||||||
|
const fg = new Zdog.Anchor({ addTo: parent, translate: { x, y: y + (150 - stemLen), z }, scale });
|
||||||
|
new Zdog.Shape({ addTo: fg, path: [{ x: 0, y: stemLen }, { x: 0, y: 0 }], stroke: 6, color: color.leafGreen });
|
||||||
|
const hd = new Zdog.Anchor({ addTo: fg, rotate: { x: rotX, y: rotY } });
|
||||||
|
for (let i = 0; i < 8; i++) {
|
||||||
|
const ph = new Zdog.Anchor({ addTo: hd, translate: { x: 0, y: -i * 10 }, rotate: { z: i % 2 === 0 ? 0.3 : -0.3 } });
|
||||||
|
new Zdog.Ellipse({ addTo: ph, width: 9, height: 14, fill: true, color: i < 3 ? color.lavPurple : color.lavLight, stroke: 0, translate: { x: 6 } });
|
||||||
|
new Zdog.Ellipse({ addTo: ph, width: 9, height: 14, fill: true, color: i < 3 ? color.lavPurple : color.lavLight, stroke: 0, translate: { x: -6 } });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createSunflower(parent, x, y, z, scale, stemLen, rotX, rotY) {
|
||||||
|
const fg = new Zdog.Anchor({ addTo: parent, translate: { x, y: y + (160 - stemLen), z }, scale });
|
||||||
|
new Zdog.Shape({ addTo: fg, path: [{ x: 0, y: stemLen }, { x: 0, y: 0 }], stroke: 9, color: color.leafGreen });
|
||||||
|
const hd = new Zdog.Anchor({ addTo: fg, rotate: { x: rotX, y: rotY } });
|
||||||
|
for (let i = 0; i < 18; i++) {
|
||||||
|
const ph = new Zdog.Anchor({ addTo: hd, rotate: { z: (TAU / 18) * i } });
|
||||||
|
new Zdog.Ellipse({ addTo: ph, width: 13, height: 36, fill: true, color: color.sunflowerY, stroke: 0, translate: { y: -24 } });
|
||||||
|
}
|
||||||
|
new Zdog.Ellipse({ addTo: hd, diameter: 22, fill: true, stroke: 5, color: color.sunflowerC, translate: { z: 2 } });
|
||||||
|
}
|
||||||
|
|
||||||
|
function createTulip(parent, x, y, z, scale, stemLen, rotX, rotY, col) {
|
||||||
|
col = col || color.tulipPink;
|
||||||
|
const fg = new Zdog.Anchor({ addTo: parent, translate: { x, y: y + (130 - stemLen), z }, scale });
|
||||||
|
new Zdog.Shape({ addTo: fg, path: [{ x: 0, y: stemLen }, { x: 0, y: 0 }], stroke: 8, color: color.leafGreen });
|
||||||
|
const hd = new Zdog.Anchor({ addTo: fg, rotate: { x: rotX, y: rotY } });
|
||||||
|
for (let i = 0; i < 6; i++) {
|
||||||
|
const a = (TAU / 6) * i;
|
||||||
|
new Zdog.Ellipse({ addTo: hd, width: 18, height: 36, fill: true, color: col, stroke: 0, translate: { x: Math.cos(a) * 10, z: Math.sin(a) * 10 }, rotate: { y: a, x: -0.3 } });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Chicken ──────────────────────────────────────────────────────
|
||||||
|
let chickenLeftEyeGroup, chickenRightEyeGroup, cEyeLeftPupil, cEyeRightPupil, glassesAnchor, rightHand, leftHand;
|
||||||
|
const chicken = new Zdog.Anchor({ addTo: scene, translate: { x: 0, y: 70, z: 0 }, rotate: { x: -0.05, y: 0, z: 0 } });
|
||||||
|
|
||||||
|
// NEW: Isolate the upper body so the legs don't move during the peck animation
|
||||||
|
const chickenBody = new Zdog.Anchor({ addTo: chicken });
|
||||||
|
|
||||||
|
let bodyLower = new Zdog.Shape({ addTo: chickenBody, stroke: 270, color: color.body, translate: { y: 75 } });
|
||||||
|
new Zdog.Cylinder({ addTo: chickenBody, diameter: 72, length: 150, stroke: 36, color: '#F6D8C8', rotate: { x: TAU/4 }, translate: { y: -45, z: 18 } });
|
||||||
|
|
||||||
|
const head = new Zdog.Anchor({ addTo: chickenBody, translate: { y: -150, z: 42 } });
|
||||||
|
new Zdog.Shape({ addTo: head, stroke: 135, color: color.headBase });
|
||||||
|
new Zdog.Cone({ addTo: head, diameter: 42, length: 66, stroke: 12, color: '#F4A63A', translate: { y: 30, z: 72 } });
|
||||||
|
|
||||||
|
const wattleLeft = new Zdog.Shape({ addTo: head, path: [{ y: 0 }, { y: 48 }], stroke: 30, color: color.comb, translate: { x: -18, y: 42, z: 48 } });
|
||||||
|
wattleLeft.copy({ translate: { x: 18, y: 42, z: 48 } });
|
||||||
|
new Zdog.Shape({
|
||||||
|
addTo: head, stroke: 36, color: color.comb,
|
||||||
|
path: [{ x: -18, y: -66, z: 6 }, { x: -30, y: -108, z: 18 }, { x: -6, y: -84, z: 2 }, { x: 6, y: -126, z: -6 }, { x: 24, y: -78, z: -12 }]
|
||||||
|
});
|
||||||
|
|
||||||
|
chickenLeftEyeGroup = new Zdog.Anchor({ addTo: head, translate: { x: -60, y: -12, z: 42 } });
|
||||||
|
new Zdog.Shape({ addTo: chickenLeftEyeGroup, stroke: 84, color: color.eye, fill: true });
|
||||||
|
cEyeLeftPupil = new Zdog.Shape({ addTo: chickenLeftEyeGroup, stroke: 30, color: color.pupil, fill: true, translate: { x: -12, y: -12, z: 36 } });
|
||||||
|
|
||||||
|
chickenRightEyeGroup = new Zdog.Anchor({ addTo: head, translate: { x: 60, y: -6, z: 36 } });
|
||||||
|
new Zdog.Shape({ addTo: chickenRightEyeGroup, stroke: 72, color: color.eye, fill: true });
|
||||||
|
cEyeRightPupil = new Zdog.Shape({ addTo: chickenRightEyeGroup, stroke: 27, color: color.pupil, fill: true, translate: { x: 9, y: 6, z: 30 } });
|
||||||
|
|
||||||
|
let glassesLeft, glassesRight, glassesBridge;
|
||||||
|
glassesAnchor = new Zdog.Anchor({ addTo: head, translate: { y: -6, z: 84 }, scale: 0.001 });
|
||||||
|
glassesLeft = new Zdog.Ellipse({ addTo: glassesAnchor, diameter: 102, stroke: 15, color: '#1A1A1A', translate: { x: -54 }, visible: false });
|
||||||
|
glassesRight = glassesLeft.copy({ translate: { x: 54 }, visible: false });
|
||||||
|
glassesBridge = new Zdog.Shape({ addTo: glassesAnchor, path: [{ x: -20, y: 0 }, { x: 20, y: 0 }], stroke: 13, color: '#1A1A1A', visible: false });
|
||||||
|
|
||||||
|
// Wings attached to chickenBody
|
||||||
|
leftHand = new Zdog.Anchor({ addTo: chickenBody, translate: { x: -105, y: 30, z: 15 }, rotate: { y: 0.2, z: 0.3 } });
|
||||||
|
new Zdog.Shape({
|
||||||
|
addTo: leftHand, stroke: 32, color: color.body, closed: false,
|
||||||
|
path: [{ x: 0, y: 0, z: 0 }, { bezier: [{ x: -35, y: -30, z: -10 }, { x: -50, y: 20, z: -20 }, { x: -20, y: 55, z: -10 }] }]
|
||||||
|
});
|
||||||
|
new Zdog.Shape({
|
||||||
|
addTo: leftHand, stroke: 24, color: color.tail, closed: false,
|
||||||
|
path: [{ x: -5, y: 8, z: -3 }, { bezier: [{ x: -38, y: -20, z: -15 }, { x: -48, y: 25, z: -25 }, { x: -18, y: 48, z: -12 }] }]
|
||||||
|
});
|
||||||
|
|
||||||
|
rightHand = new Zdog.Anchor({ addTo: chickenBody, translate: { x: 105, y: 30, z: 15 }, rotate: { y: -0.2, z: -0.3 } });
|
||||||
|
new Zdog.Shape({
|
||||||
|
addTo: rightHand, stroke: 32, color: color.body, closed: false,
|
||||||
|
path: [{ x: 0, y: 0, z: 0 }, { bezier: [{ x: 35, y: -30, z: -10 }, { x: 50, y: 20, z: -20 }, { x: 20, y: 55, z: -10 }] }]
|
||||||
|
});
|
||||||
|
new Zdog.Shape({
|
||||||
|
addTo: rightHand, stroke: 24, color: color.tail, closed: false,
|
||||||
|
path: [{ x: 5, y: 8, z: -3 }, { bezier: [{ x: 38, y: -20, z: -15 }, { x: 48, y: 25, z: -25 }, { x: 18, y: 48, z: -12 }] }]
|
||||||
|
});
|
||||||
|
|
||||||
|
// Tail feathers attached to chickenBody
|
||||||
|
const tailFeathers = [];
|
||||||
|
const tailAnchor = new Zdog.Anchor({ addTo: chickenBody, translate: { x: -54, y: 36, z: -75 } });
|
||||||
|
new Zdog.Shape({ addTo: tailAnchor, stroke: 36, color: color.tail, closed: false, path: [{ x: 0, y: 0, z: 0 }, { bezier: [{ x: -66, y: -66, z: -18 }, { x: -96, y: -144, z: -72 }, { x: -48, y: -192, z: -96 }] }] });
|
||||||
|
tailFeathers.push(tailAnchor);
|
||||||
|
for (let i = 1; i < 8; i++) {
|
||||||
|
const copy = tailAnchor.copyGraph({ translate: { x: -54 - i * 9, y: 36 + i * 6, z: -75 - i * 6 }, rotate: { y: i * 0.06, z: i * 0.04 } });
|
||||||
|
tailFeathers.push(copy);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Legs explicitly stay attached to the base chicken
|
||||||
|
const legLeg = new Zdog.Shape({ path: [{ y: 60 }, { y: 120 }], stroke: 27, color: color.leg });
|
||||||
|
const leftLegAnchor = new Zdog.Anchor({ addTo: chicken, translate: { x: -42, y: 150, z: 0 } });
|
||||||
|
legLeg.copy({ addTo: leftLegAnchor });
|
||||||
|
const leftFootAnchor = new Zdog.Anchor({ addTo: leftLegAnchor, translate: { y: 120 }, rotate: { y: 0.3 } });
|
||||||
|
new Zdog.Shape({ addTo: leftFootAnchor, path: [{ x: 0, z: 0 }, { x: 0, z: 45 }], stroke: 27, color: color.leg });
|
||||||
|
new Zdog.Shape({ addTo: leftFootAnchor, path: [{ x: 0, z: 0 }, { x: -36, z: 30 }], stroke: 27, color: color.leg });
|
||||||
|
new Zdog.Shape({ addTo: leftFootAnchor, path: [{ x: 0, z: 0 }, { x: 36, z: 30 }], stroke: 27, color: color.leg });
|
||||||
|
|
||||||
|
const rightLegAnchor = new Zdog.Anchor({ addTo: chicken, translate: { x: 42, y: 150, z: 0 } });
|
||||||
|
legLeg.copy({ addTo: rightLegAnchor });
|
||||||
|
const rightFootAnchor = new Zdog.Anchor({ addTo: rightLegAnchor, translate: { y: 120 }, rotate: { y: -0.3 } });
|
||||||
|
new Zdog.Shape({ addTo: rightFootAnchor, path: [{ x: 0, z: 0 }, { x: 0, z: 45 }], stroke: 27, color: color.leg });
|
||||||
|
new Zdog.Shape({ addTo: rightFootAnchor, path: [{ x: 0, z: 0 }, { x: -36, z: 30 }], stroke: 27, color: color.leg });
|
||||||
|
new Zdog.Shape({ addTo: rightFootAnchor, path: [{ x: 0, z: 0 }, { x: 36, z: 30 }], stroke: 27, color: color.leg });
|
||||||
|
|
||||||
|
// ─── Animation loop ────────────────────────────────────────────
|
||||||
|
let frame = 0, isRunning = true, isSmartMode = false;
|
||||||
|
let chickenTargetLookX = 0, chickenLookX = 0;
|
||||||
|
|
||||||
|
function render() {
|
||||||
|
if (!isRunning) return;
|
||||||
|
frame++;
|
||||||
|
const blinkCycle = frame % 340;
|
||||||
|
if (blinkCycle > 320) {
|
||||||
|
const sY = 0.5 + 0.5 * Math.cos(((blinkCycle - 320) / 20) * Math.PI * 2);
|
||||||
|
chickenLeftEyeGroup.scale.y = sY; chickenRightEyeGroup.scale.y = sY;
|
||||||
|
} else {
|
||||||
|
chickenLeftEyeGroup.scale.y = 1; chickenRightEyeGroup.scale.y = 1;
|
||||||
|
}
|
||||||
|
if (frame % 240 === 0) chickenTargetLookX = [-14, 0, 12][Math.floor(Math.random() * 3)];
|
||||||
|
chickenLookX += (chickenTargetLookX - chickenLookX) * 0.08;
|
||||||
|
if (!isSmartMode) {
|
||||||
|
cEyeLeftPupil.translate.x = -12 + chickenLookX;
|
||||||
|
cEyeRightPupil.translate.x = 9 + chickenLookX;
|
||||||
|
}
|
||||||
|
scene.updateRenderGraph();
|
||||||
|
requestAnimationFrame(render);
|
||||||
|
}
|
||||||
|
render();
|
||||||
|
|
||||||
|
// ─── Chat interaction (wears glasses and eyes fixed) ───────────────
|
||||||
|
let isChatting = false;
|
||||||
|
function chat() {
|
||||||
|
if (isChatting) return;
|
||||||
|
isChatting = true;
|
||||||
|
isSmartMode = true;
|
||||||
|
|
||||||
|
glassesLeft.visible = true; glassesRight.visible = true; glassesBridge.visible = true;
|
||||||
|
|
||||||
|
const tl = gsap.timeline({
|
||||||
|
onComplete: () => {
|
||||||
|
gsap.to(glassesAnchor.scale, {
|
||||||
|
duration: 0.4, x: 0.001, y: 0.001, z: 0.001, ease: 'power2.in',
|
||||||
|
onComplete: () => {
|
||||||
|
glassesLeft.visible = false; glassesRight.visible = false; glassesBridge.visible = false;
|
||||||
|
isSmartMode = false;
|
||||||
|
isChatting = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Rapidly centers eyes and pops up glasses
|
||||||
|
tl.to(glassesAnchor.scale, { duration: 0.6, x: 1, y: 1, z: 1, ease: 'elastic.out(1, 0.75)' })
|
||||||
|
.to(cEyeLeftPupil.translate, { duration: 0.3, x: 0, y: 0, ease: 'power2.out' }, 0)
|
||||||
|
.to(cEyeRightPupil.translate, { duration: 0.3, x: 0, y: 0, ease: 'power2.out' }, 0);
|
||||||
|
|
||||||
|
// Conversational head bobs while answering thoughts
|
||||||
|
tl.to(head.translate, { duration: 0.25, y: -142, yoyo: true, repeat: 7, ease: 'sine.inOut' }, 0.5)
|
||||||
|
.to(leftHand.rotate, { duration: 0.25, z: 0.5, yoyo: true, repeat: 7, ease: 'sine.inOut' }, 0.5)
|
||||||
|
.to(rightHand.rotate, { duration: 0.25, z: -0.5, yoyo: true, repeat: 7, ease: 'sine.inOut' }, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Peck the ground (Deep Body Pivot) ─────────────────────────
|
||||||
|
let isPecking = false;
|
||||||
|
function peck() {
|
||||||
|
if (isPecking || !chickenBody) return;
|
||||||
|
isPecking = true;
|
||||||
|
|
||||||
|
const tl = gsap.timeline({ onComplete: () => { isPecking = false; } });
|
||||||
|
|
||||||
|
for (let i = 0; i < 3; i++) {
|
||||||
|
// 1. Pivot the upper body drastically downward and bend the neck
|
||||||
|
tl.to(chickenBody.rotate, {
|
||||||
|
duration: 0.15,
|
||||||
|
x: -1.2, // Deep forward tilt towards the ground
|
||||||
|
ease: 'power2.in'
|
||||||
|
}, i * 0.35);
|
||||||
|
|
||||||
|
tl.to(head.rotate, {
|
||||||
|
duration: 0.15,
|
||||||
|
x: -0.6, // Bend the neck further down
|
||||||
|
ease: 'power2.in'
|
||||||
|
}, i * 0.35);
|
||||||
|
|
||||||
|
// 2. Snap back to standing pose
|
||||||
|
tl.to(chickenBody.rotate, {
|
||||||
|
duration: 0.2,
|
||||||
|
x: 0, // Back to rest
|
||||||
|
ease: 'power2.out'
|
||||||
|
}, i * 0.35 + 0.15);
|
||||||
|
|
||||||
|
tl.to(head.rotate, {
|
||||||
|
duration: 0.2,
|
||||||
|
x: 0,
|
||||||
|
ease: 'power2.out'
|
||||||
|
}, i * 0.35 + 0.15);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Strut dance ────────────────────────────────────────────────
|
||||||
|
let isStrutting = false;
|
||||||
|
function strut() {
|
||||||
|
if (isStrutting) return;
|
||||||
|
isStrutting = true;
|
||||||
|
const tl = gsap.timeline({
|
||||||
|
onComplete: () => {
|
||||||
|
isStrutting = false;
|
||||||
|
chicken.rotate.z = 0; chicken.translate.y = 70;
|
||||||
|
leftLegAnchor.rotate.z = 0; rightLegAnchor.rotate.z = 0;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
for (let i = 0; i < 4; i++) {
|
||||||
|
const dir = i % 2 === 0 ? 1 : -1;
|
||||||
|
tl.to(chicken.rotate, { duration: 0.18, z: dir * 0.07, ease: 'sine.inOut' });
|
||||||
|
tl.to(chicken.translate, { duration: 0.18, y: 58, ease: 'sine.out' }, '<');
|
||||||
|
tl.to(leftLegAnchor.rotate, { duration: 0.18, z: dir * 0.18, ease: 'sine.inOut' }, '<');
|
||||||
|
tl.to(rightLegAnchor.rotate, { duration: 0.18, z: -dir * 0.18, ease: 'sine.inOut' }, '<');
|
||||||
|
tl.to(chicken.translate, { duration: 0.18, y: 70, ease: 'sine.in' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
chatFn = chat;
|
||||||
|
peckFn = peck;
|
||||||
|
strutFn = strut;
|
||||||
|
|
||||||
|
// ─── Pointer interactions ─────────────────────────────────────
|
||||||
|
let isDragging = false, lastX = 0, lastY = 0, wasDragging = false;
|
||||||
|
let clickTimeout = null;
|
||||||
|
const sensitivity = 0.005;
|
||||||
|
|
||||||
|
function onPointerDown(e) {
|
||||||
|
isDragging = true; wasDragging = false;
|
||||||
|
lastX = e.clientX; lastY = e.clientY;
|
||||||
|
try { canvasRef.setPointerCapture(e.pointerId); } catch (_) {}
|
||||||
|
}
|
||||||
|
function onPointerMove(e) {
|
||||||
|
if (!isDragging) return;
|
||||||
|
const dx = e.clientX - lastX, dy = e.clientY - lastY;
|
||||||
|
lastX = e.clientX; lastY = e.clientY;
|
||||||
|
if (Math.hypot(dx, dy) > 8) wasDragging = true;
|
||||||
|
chicken.rotate.y += dx * sensitivity;
|
||||||
|
scene.updateRenderGraph();
|
||||||
|
}
|
||||||
|
function onPointerUp(e) {
|
||||||
|
isDragging = false;
|
||||||
|
try { canvasRef.releasePointerCapture(e.pointerId); } catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleClick() {
|
||||||
|
if (wasDragging) return;
|
||||||
|
|
||||||
|
if (clickTimeout !== null) {
|
||||||
|
clearTimeout(clickTimeout);
|
||||||
|
clickTimeout = null;
|
||||||
|
strutFn?.();
|
||||||
|
} else {
|
||||||
|
clickTimeout = setTimeout(() => {
|
||||||
|
clickTimeout = null;
|
||||||
|
peckFn?.();
|
||||||
|
}, 250);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!embedded) {
|
||||||
|
canvasRef.addEventListener('pointerdown', onPointerDown);
|
||||||
|
canvasRef.addEventListener('pointermove', onPointerMove);
|
||||||
|
window.addEventListener('pointerup', onPointerUp);
|
||||||
|
canvasRef.addEventListener('click', handleClick);
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
isRunning = false;
|
||||||
|
if (clickTimeout) clearTimeout(clickTimeout);
|
||||||
|
if (!embedded) {
|
||||||
|
canvasRef?.removeEventListener('pointerdown', onPointerDown);
|
||||||
|
canvasRef?.removeEventListener('pointermove', onPointerMove);
|
||||||
|
window.removeEventListener('pointerup', onPointerUp);
|
||||||
|
canvasRef?.removeEventListener('click', handleClick);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if !embedded}
|
||||||
|
<div class="top-bar">
|
||||||
|
<button class="bar-btn" on:click={goHome}>←</button>
|
||||||
|
<button class="bar-btn" on:click={toggleHistory}>
|
||||||
|
{historyOpen ? 'Close history' : '💬 History'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if !embedded && historyOpen}
|
||||||
|
<div class="history-panel">
|
||||||
|
<div class="history-header">
|
||||||
|
<h2>Session chat</h2>
|
||||||
|
<button class="close-btn" on:click={() => historyOpen = false}>✕</button>
|
||||||
|
</div>
|
||||||
|
<div class="history-scroll">
|
||||||
|
{#each chatHistory as msg}
|
||||||
|
<div class="history-msg {msg.role}">
|
||||||
|
<span class="history-label">{msg.role === 'assistant' ? '🐔 Professor Cluck' : 'You'}</span>
|
||||||
|
<p>{msg.content}</p>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if !embedded}
|
||||||
|
<ChickenBackground />
|
||||||
|
{/if}
|
||||||
|
<canvas bind:this={canvasRef} class="scene" class:embedded></canvas>
|
||||||
|
|
||||||
|
<div class="thought-wrap" class:visible={thoughtBubbleVisible}>
|
||||||
|
<div class="thought-bubble">
|
||||||
|
<div class="thought-dots">
|
||||||
|
<span></span><span></span><span></span>
|
||||||
|
</div>
|
||||||
|
<p class="thought-text">{thoughtBubbleText}</p>
|
||||||
|
</div>
|
||||||
|
<div class="thought-reply">
|
||||||
|
<textarea
|
||||||
|
bind:value={replyInputValue}
|
||||||
|
placeholder="Ask Professor Cluck…"
|
||||||
|
rows="1"
|
||||||
|
disabled={isApiLoading}
|
||||||
|
on:keydown={handleReplyKey}
|
||||||
|
></textarea>
|
||||||
|
<button class="reply-send" on:click={sendReply} disabled={isApiLoading || !replyInputValue.trim()}>
|
||||||
|
{isApiLoading ? '…' : '➤'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="hint">Click to peck. Double-click to strut.</p>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
@import url("https://fonts.googleapis.com/css2?family=Nunito:wght@400;600;700;800&display=swap");
|
||||||
|
|
||||||
|
:global(body) {
|
||||||
|
margin: 0; padding: 0;
|
||||||
|
width: 100vw; height: 100vh;
|
||||||
|
background: #FFB7D5;
|
||||||
|
overflow: hidden;
|
||||||
|
font-family: 'Nunito', sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
canvas.scene {
|
||||||
|
position: fixed; inset: 0;
|
||||||
|
width: 100vw; height: 100vh;
|
||||||
|
display: block; z-index: 1;
|
||||||
|
touch-action: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
canvas.scene.embedded {
|
||||||
|
position: absolute;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
inset: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Top bar ─────────────────────────────────────────────────────── */
|
||||||
|
.top-bar {
|
||||||
|
position: fixed;
|
||||||
|
top: 16px; left: 16px;
|
||||||
|
display: flex; gap: 10px;
|
||||||
|
z-index: 30;
|
||||||
|
}
|
||||||
|
.bar-btn {
|
||||||
|
padding: 9px 16px;
|
||||||
|
border: none; border-radius: 20px;
|
||||||
|
background: rgba(255,255,255,0.92);
|
||||||
|
color: #5A1A30;
|
||||||
|
font-family: 'Nunito', sans-serif;
|
||||||
|
font-weight: 700; font-size: 0.85rem;
|
||||||
|
cursor: pointer;
|
||||||
|
box-shadow: 0 4px 18px rgba(90,26,48,.10);
|
||||||
|
transition: background .15s, transform .1s;
|
||||||
|
}
|
||||||
|
.bar-btn:hover { background: #fff; transform: translateY(-1px); }
|
||||||
|
.bar-btn:active { transform: scale(.96); }
|
||||||
|
|
||||||
|
/* ── History panel ───────────────────────────────────────────────── */
|
||||||
|
.history-panel {
|
||||||
|
position: fixed;
|
||||||
|
top: 62px; right: 16px;
|
||||||
|
width: min(360px, calc(100vw - 32px));
|
||||||
|
max-height: calc(100vh - 80px);
|
||||||
|
background: rgba(255,255,255,0.97);
|
||||||
|
border-radius: 24px;
|
||||||
|
box-shadow: 0 20px 60px rgba(90,26,48,.18);
|
||||||
|
z-index: 25;
|
||||||
|
display: flex; flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.history-header {
|
||||||
|
display: flex; justify-content: space-between; align-items: center;
|
||||||
|
padding: 18px 20px 12px;
|
||||||
|
border-bottom: 1px solid rgba(240,74,111,.12);
|
||||||
|
}
|
||||||
|
.history-header h2 { margin: 0; font-size: 1rem; color: #5A1A30; }
|
||||||
|
.close-btn {
|
||||||
|
border: none; background: #fde6ef;
|
||||||
|
color: #B73058; border-radius: 50%;
|
||||||
|
width: 30px; height: 30px;
|
||||||
|
cursor: pointer; font-size: 0.85rem; font-weight: 700;
|
||||||
|
}
|
||||||
|
.history-scroll {
|
||||||
|
overflow-y: auto; padding: 14px 16px;
|
||||||
|
display: flex; flex-direction: column; gap: 10px;
|
||||||
|
}
|
||||||
|
.history-msg {
|
||||||
|
border-radius: 16px; padding: 12px 14px;
|
||||||
|
font-size: 0.9rem; line-height: 1.55;
|
||||||
|
}
|
||||||
|
.history-msg p { margin: 4px 0 0; color: #3D1020; }
|
||||||
|
.history-msg.assistant { background: #fff0f6; }
|
||||||
|
.history-msg.user { background: #fffcf3; }
|
||||||
|
.history-label { font-weight: 800; font-size: 0.78rem; color: #B73058; }
|
||||||
|
|
||||||
|
/* ── Chatbot ─────────────────────────────────────────────────── */
|
||||||
|
.thought-wrap {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 32px;
|
||||||
|
right: 32px;
|
||||||
|
max-width: min(360px, 90vw);
|
||||||
|
display: flex; flex-direction: column; gap: 10px;
|
||||||
|
z-index: 15;
|
||||||
|
pointer-events: none;
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(8px) scale(.96);
|
||||||
|
transition: opacity .35s ease, transform .35s ease;
|
||||||
|
align-items: flex-end;
|
||||||
|
}
|
||||||
|
.thought-wrap.visible {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0) scale(1);
|
||||||
|
pointer-events: all;
|
||||||
|
}
|
||||||
|
.thought-bubble {
|
||||||
|
background: #fff;
|
||||||
|
border: 2px solid rgba(255,143,174,.32);
|
||||||
|
border-radius: 22px 22px 6px 22px;
|
||||||
|
padding: 14px 18px;
|
||||||
|
box-shadow: 0 8px 36px rgba(90,26,48,.14);
|
||||||
|
position: relative;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
.thought-dots { display: flex; gap: 4px; margin-bottom: 6px; }
|
||||||
|
.thought-dots span { width: 5px; height: 5px; background: #FF8FAE; border-radius: 50%; }
|
||||||
|
.thought-text {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.92rem;
|
||||||
|
color: #5A1A30;
|
||||||
|
line-height: 1.6;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.thought-reply { display: flex; gap: 8px; align-items: flex-end; width: 100%; }
|
||||||
|
.thought-reply textarea {
|
||||||
|
flex: 1;
|
||||||
|
background: rgba(255,255,255,0.96);
|
||||||
|
border: 1.5px solid rgba(255,143,174,.42);
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
font-family: 'Nunito', sans-serif;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: #5A1A30;
|
||||||
|
outline: none; resize: none;
|
||||||
|
height: 44px; min-height: 44px; max-height: 88px;
|
||||||
|
line-height: 1.5; box-sizing: border-box;
|
||||||
|
transition: border-color .15s, box-shadow .15s;
|
||||||
|
}
|
||||||
|
.thought-reply textarea::placeholder { color: rgba(90,26,48,.42); }
|
||||||
|
.thought-reply textarea:focus {
|
||||||
|
border-color: #F04A6F;
|
||||||
|
box-shadow: 0 0 0 3px rgba(240,74,111,.16);
|
||||||
|
}
|
||||||
|
.reply-send {
|
||||||
|
width: 44px; height: 44px;
|
||||||
|
border-radius: 50%; border: none;
|
||||||
|
background: #FF8FAE; color: #fff;
|
||||||
|
font-size: 1rem; cursor: pointer;
|
||||||
|
flex-shrink: 0;
|
||||||
|
transition: background .12s, transform .1s;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
}
|
||||||
|
.reply-send:hover:not(:disabled) { background: #F04A6F; }
|
||||||
|
.reply-send:active:not(:disabled) { transform: scale(.94); }
|
||||||
|
.reply-send:disabled { opacity: .5; cursor: default; }
|
||||||
|
|
||||||
|
/* ── Hint ────────────────────────────────────────────────────────── */
|
||||||
|
.hint {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 22px; left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
font-size: 0.8rem; font-weight: 600;
|
||||||
|
color: rgba(90, 26, 48, 0.55);
|
||||||
|
pointer-events: none;
|
||||||
|
letter-spacing: .04em;
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
161
src/ChickenBackground.svelte
Normal file
161
src/ChickenBackground.svelte
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
<script>
|
||||||
|
//@ts-nocheck
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import Zdog from 'zdog';
|
||||||
|
|
||||||
|
let canvasRef;
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
const TAU = Zdog.TAU;
|
||||||
|
|
||||||
|
const color = {
|
||||||
|
body: '#FFF1E6', headBase: '#FFF5E8', eye: '#FFFFFF', pupil: '#111111',
|
||||||
|
comb: '#FF3B5C', tail: '#F8D7C7', leg: '#FAA353',
|
||||||
|
sky: '#FFB7D5', skyLight: '#FFE1F0',
|
||||||
|
land: '#7ab535', landLight: '#9fd95c', darkGreen: '#3A5C18',
|
||||||
|
trunkBrown: '#8B6340', cloudPink: '#FFE4F0', cloudWhite: '#FFF7FB',
|
||||||
|
blossomPink: '#FFACD2', appleRed: '#FF4B4B',
|
||||||
|
leafGreen: '#5DA020', daisyWhite: '#FFFFFF', daisyYellow: '#F9D342',
|
||||||
|
roseRed: '#F04A6F', rosePink: '#FF8FAE',
|
||||||
|
lavPurple: '#C07EE0', lavLight: '#E2B8F5',
|
||||||
|
sunflowerY: '#FFB800', sunflowerC: '#7A3B10',
|
||||||
|
tulipPink: '#FF85A2', tulipOrange: '#FF6B35'
|
||||||
|
};
|
||||||
|
|
||||||
|
const scene = new Zdog.Illustration({ element: canvasRef, dragRotate: false, resize: 'window' });
|
||||||
|
const staticGroup = new Zdog.Anchor({ addTo: scene });
|
||||||
|
|
||||||
|
new Zdog.Shape({ addTo: staticGroup, path: [{ x: -3000, y: -1500 }, { x: 3000, y: -1500 }, { x: 3000, y: 180 }, { x: -3000, y: 180 }], stroke: 0, fill: true, color: color.sky, translate: { z: -1500 } });
|
||||||
|
new Zdog.Shape({ addTo: staticGroup, path: [{ x: -3000, y: 180 }, { x: 3000, y: 180 }, { x: 3000, y: 1600 }, { x: -3000, y: 1600 }], stroke: 0, fill: true, color: color.skyLight, translate: { z: -1500 } });
|
||||||
|
|
||||||
|
function addHill(x, w, col, z, amp = 220) {
|
||||||
|
const pts = [{ x: x - w / 2, y: 520 }];
|
||||||
|
for (let i = 0; i <= 24; i++) {
|
||||||
|
const t = i / 24;
|
||||||
|
pts.push({ x: x - w / 2 + t * w, y: 520 - Math.sin(t * Math.PI) * amp });
|
||||||
|
}
|
||||||
|
pts.push({ x: x + w / 2, y: 520 });
|
||||||
|
new Zdog.Shape({ addTo: staticGroup, path: pts, stroke: 0, fill: true, color: col, translate: { z } });
|
||||||
|
}
|
||||||
|
|
||||||
|
addHill(-900, 1600, color.darkGreen, -1200, 260);
|
||||||
|
addHill(800, 1500, '#4A7020', -1100, 220);
|
||||||
|
addHill(-300, 1300, '#5A8A28', -1000, 180);
|
||||||
|
addHill(400, 1000, '#6BA330', -900, 140);
|
||||||
|
|
||||||
|
|
||||||
|
// ─── Land and Ground ──────────────────────────────────────────────
|
||||||
|
// Pushed land further away by changing z from -700/-690 to -1200/-1190
|
||||||
|
new Zdog.Shape({ addTo: staticGroup, path: [{ x: -3000, y: 340 }, { x: 3000, y: 340 }, { x: 3000, y: 1800 }, { x: -3000, y: 1800 }], stroke: 0, fill: true, color: color.land, translate: { y: -200, z: -1200 } });
|
||||||
|
new Zdog.Shape({ addTo: staticGroup, path: [{ x: -3000, y: 340 }, { x: 3000, y: 340 }, { x: 3000, y: 400 }, { x: -3000, y: 400 }], stroke: 0, fill: true, color: color.landLight, translate: { y: -200, z: -1190 } });
|
||||||
|
|
||||||
|
// ─── Trees (Scaled up) ──────────────────────────────────────────
|
||||||
|
[
|
||||||
|
// Background trees directly behind chicken
|
||||||
|
{ x: -100, y: 150, z: -400, s: 3.0, type: 'apples' },
|
||||||
|
{ x: 150, y: 150, z: -350, s: 2.8, type: 'blossoms' },
|
||||||
|
|
||||||
|
// Original landscape trees
|
||||||
|
{ x: -750, y: 210, z: -850, s: 4.0, type: 'apples' },
|
||||||
|
{ x: -500, y: 240, z: -800, s: 3.5, type: 'blossoms' },
|
||||||
|
{ x: 650, y: 220, z: -860, s: 4.0, type: 'apples' },
|
||||||
|
{ x: 850, y: 260, z: -780, s: 3.5, type: 'blossoms' }
|
||||||
|
].forEach(p => {
|
||||||
|
// ... (your existing drawing logic)
|
||||||
|
const t = new Zdog.Anchor({ addTo: staticGroup, translate: { x: p.x, y: p.y, z: p.z }, scale: p.s });
|
||||||
|
new Zdog.Shape({ addTo: t, path: [{ y: 0 }, { y: -120 }], stroke: 90, color: color.trunkBrown });
|
||||||
|
const fGroup = new Zdog.Anchor({ addTo: t, translate: { y: -150 } });
|
||||||
|
new Zdog.Shape({ addTo: fGroup, stroke: 700, color: '#5DA020' });
|
||||||
|
new Zdog.Shape({ addTo: fGroup, stroke: 300, color: '#7CBF37', translate: { x: -40, y: -40, z: 15 } });
|
||||||
|
new Zdog.Shape({ addTo: fGroup, stroke: 200, color: '#4A8516', translate: { x: 45, y: 25, z: -15 } });
|
||||||
|
});
|
||||||
|
|
||||||
|
function createDaisy(parent, x, y, z, scale, stemLen, rotX, rotY, petalCol) {
|
||||||
|
petalCol = petalCol || color.daisyWhite;
|
||||||
|
const fg = new Zdog.Anchor({ addTo: parent, translate: { x, y: y + (120 - stemLen), z }, scale });
|
||||||
|
new Zdog.Shape({ addTo: fg, path: [{ x: 0, y: stemLen, z: 0 }, { x: 0, y: 0, z: 0 }], stroke: 7, color: color.leafGreen });
|
||||||
|
const hd = new Zdog.Anchor({ addTo: fg, rotate: { x: rotX, y: rotY } });
|
||||||
|
for (let i = 0; i < 13; i++) {
|
||||||
|
const ph = new Zdog.Anchor({ addTo: hd, rotate: { z: (TAU / 13) * i } });
|
||||||
|
new Zdog.Ellipse({ addTo: ph, width: 11, height: 30, fill: true, color: petalCol, stroke: 0, translate: { y: -18 } });
|
||||||
|
}
|
||||||
|
new Zdog.Ellipse({ addTo: hd, diameter: 15, fill: true, stroke: 4, color: color.daisyYellow, translate: { z: 1.5 } });
|
||||||
|
}
|
||||||
|
|
||||||
|
function createRose(parent, x, y, z, scale, stemLen, rotX, rotY) {
|
||||||
|
const fg = new Zdog.Anchor({ addTo: parent, translate: { x, y: y + (140 - stemLen), z }, scale });
|
||||||
|
new Zdog.Shape({ addTo: fg, path: [{ x: 0, y: stemLen }, { x: 0, y: 0 }], stroke: 7, color: color.leafGreen });
|
||||||
|
const hd = new Zdog.Anchor({ addTo: fg, rotate: { x: rotX, y: rotY } });
|
||||||
|
for (let l = 0; l < 3; l++) {
|
||||||
|
const pc = 6 + l * 2, r = 10 + l * 9;
|
||||||
|
for (let i = 0; i < pc; i++) {
|
||||||
|
const a = (TAU / pc) * i + l * 0.3;
|
||||||
|
const ph = new Zdog.Anchor({ addTo: hd, translate: { x: Math.cos(a) * r, y: Math.sin(a) * r } });
|
||||||
|
new Zdog.Ellipse({ addTo: ph, width: l === 0 ? 8 : 10 + l * 2, height: l === 0 ? 12 : 14 + l * 3, fill: true, color: l === 0 ? color.roseRed : color.rosePink, stroke: 0, rotate: { z: a } });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
new Zdog.Ellipse({ addTo: hd, diameter: 8, fill: true, stroke: 0, color: '#c0243a', translate: { z: 1 } });
|
||||||
|
}
|
||||||
|
|
||||||
|
function createLavender(parent, x, y, z, scale, stemLen, rotX, rotY) {
|
||||||
|
const fg = new Zdog.Anchor({ addTo: parent, translate: { x, y: y + (150 - stemLen), z }, scale });
|
||||||
|
new Zdog.Shape({ addTo: fg, path: [{ x: 0, y: stemLen }, { x: 0, y: 0 }], stroke: 6, color: color.leafGreen });
|
||||||
|
const hd = new Zdog.Anchor({ addTo: fg, rotate: { x: rotX, y: rotY } });
|
||||||
|
for (let i = 0; i < 8; i++) {
|
||||||
|
const ph = new Zdog.Anchor({ addTo: hd, translate: { x: 0, y: -i * 10 }, rotate: { z: i % 2 === 0 ? 0.3 : -0.3 } });
|
||||||
|
new Zdog.Ellipse({ addTo: ph, width: 9, height: 14, fill: true, color: i < 3 ? color.lavPurple : color.lavLight, stroke: 0, translate: { x: 6 } });
|
||||||
|
new Zdog.Ellipse({ addTo: ph, width: 9, height: 14, fill: true, color: i < 3 ? color.lavPurple : color.lavLight, stroke: 0, translate: { x: -6 } });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createSunflower(parent, x, y, z, scale, stemLen, rotX, rotY) {
|
||||||
|
const fg = new Zdog.Anchor({ addTo: parent, translate: { x, y: y + (160 - stemLen), z }, scale });
|
||||||
|
new Zdog.Shape({ addTo: fg, path: [{ x: 0, y: stemLen }, { x: 0, y: 0 }], stroke: 9, color: color.leafGreen });
|
||||||
|
const hd = new Zdog.Anchor({ addTo: fg, rotate: { x: rotX, y: rotY } });
|
||||||
|
for (let i = 0; i < 18; i++) {
|
||||||
|
const ph = new Zdog.Anchor({ addTo: hd, rotate: { z: (TAU / 18) * i } });
|
||||||
|
new Zdog.Ellipse({ addTo: ph, width: 13, height: 36, fill: true, color: color.sunflowerY, stroke: 0, translate: { y: -24 } });
|
||||||
|
}
|
||||||
|
new Zdog.Ellipse({ addTo: hd, diameter: 22, fill: true, stroke: 5, color: color.sunflowerC, translate: { z: 2 } });
|
||||||
|
}
|
||||||
|
|
||||||
|
function createTulip(parent, x, y, z, scale, stemLen, rotX, rotY, col) {
|
||||||
|
col = col || color.tulipPink;
|
||||||
|
const fg = new Zdog.Anchor({ addTo: parent, translate: { x, y: y + (130 - stemLen), z }, scale });
|
||||||
|
new Zdog.Shape({ addTo: fg, path: [{ x: 0, y: stemLen }, { x: 0, y: 0 }], stroke: 8, color: color.leafGreen });
|
||||||
|
const hd = new Zdog.Anchor({ addTo: fg, rotate: { x: rotX, y: rotY } });
|
||||||
|
for (let i = 0; i < 6; i++) {
|
||||||
|
const a = (TAU / 6) * i;
|
||||||
|
new Zdog.Ellipse({ addTo: hd, width: 18, height: 36, fill: true, color: col, stroke: 0, translate: { x: Math.cos(a) * 10, z: Math.sin(a) * 10 }, rotate: { y: a, x: -0.3 } });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const flowerGroup = new Zdog.Anchor({ addTo: staticGroup, translate: { x: 0, y: 0, z: 0 } });
|
||||||
|
createSunflower(flowerGroup, -620, 400, 10, 5.5, 150, -TAU/4.5, 0.2);
|
||||||
|
createDaisy(flowerGroup, -480, 390, 40, 5.0, 130, -TAU/4, -0.2, color.daisyWhite);
|
||||||
|
createTulip(flowerGroup, -560, 370, -30, 6.0, 110, -TAU/4, 0.4, color.tulipPink);
|
||||||
|
createRose(flowerGroup, -420, 380, 70, 5.0, 120, -TAU/4.2, -0.3);
|
||||||
|
createLavender(flowerGroup, -680, 410, -10, 5.0, 160, -TAU/5, 0.3);
|
||||||
|
createTulip(flowerGroup, 480, 375, -20, 6.0, 110, -TAU/4, -0.3, color.tulipOrange);
|
||||||
|
createDaisy(flowerGroup, 560, 395, 50, 5.0, 135, -TAU/4, 0.25, '#FFE4F0');
|
||||||
|
createSunflower(flowerGroup, 420, 405, 10, 5.5, 150, -TAU/4.5, -0.15);
|
||||||
|
createRose(flowerGroup, 650, 385, -40, 5.0, 125, -TAU/4.2, 0.3);
|
||||||
|
createLavender(flowerGroup, 380, 415, 70, 5.0, 160, -TAU/5, -0.25);
|
||||||
|
|
||||||
|
scene.updateRenderGraph();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<canvas bind:this={canvasRef} class="background-scene"></canvas>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
canvas.background-scene {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
width: 100vw;
|
||||||
|
height: 100vh;
|
||||||
|
z-index: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
599
src/Dove.svelte
Normal file
599
src/Dove.svelte
Normal file
@@ -0,0 +1,599 @@
|
|||||||
|
<script>
|
||||||
|
//@ts-nocheck
|
||||||
|
import { onMount, createEventDispatcher } from 'svelte';
|
||||||
|
import Zdog from 'zdog';
|
||||||
|
import { gsap } from 'gsap';
|
||||||
|
import DoveBackground from './DoveBackground.svelte';
|
||||||
|
|
||||||
|
const dispatch = createEventDispatcher();
|
||||||
|
let canvasRef;
|
||||||
|
|
||||||
|
export let embedded = false;
|
||||||
|
|
||||||
|
const OPENAI_API_KEY = import.meta.env.VITE_OPENAI_API_KEY;
|
||||||
|
|
||||||
|
let historyOpen = false;
|
||||||
|
let thoughtBubbleVisible = false;
|
||||||
|
let thoughtBubbleText = '';
|
||||||
|
let replyInputValue = '';
|
||||||
|
let thoughtTimer = null;
|
||||||
|
let bubbleAutoHideTimer = null;
|
||||||
|
let isApiLoading = false;
|
||||||
|
let conversationActive = false;
|
||||||
|
|
||||||
|
let chatHistory = [
|
||||||
|
{ role: 'assistant', content: "Coo… peace to you. 🕊️ Ask me about calm, letting go, or finding hope." }
|
||||||
|
];
|
||||||
|
|
||||||
|
const SYSTEM_TEXT = `You are Olive, a gentle, serene dove who carries a quiet sense of peace. You speak softly and warmly in short, calming messages (2-4 sentences max). You use sky, wind, feather, and olive-branch metaphors naturally.
|
||||||
|
Your specialty is helping people think about:
|
||||||
|
- Finding calm in the middle of conflict or worry
|
||||||
|
- Letting go of grudges and old resentments
|
||||||
|
- Holding onto hope after something hard
|
||||||
|
- Being gentle with yourself and with others
|
||||||
|
You give soft, reassuring, hopeful advice. You never preach. Keep it warm, short, and peaceful.`;
|
||||||
|
|
||||||
|
const thoughtPrompts = [
|
||||||
|
'The sky is wide and quiet today. What is weighing on you?',
|
||||||
|
'A grudge is heavy to carry on small wings. Is there one you could set down?',
|
||||||
|
'Storms pass. They always do. What are you waiting out right now?',
|
||||||
|
'Peace is not the absence of noise — it is a soft place inside it.',
|
||||||
|
'I carry an olive branch for a reason. Who might need yours?',
|
||||||
|
'When did you last let yourself simply drift on the wind?'
|
||||||
|
];
|
||||||
|
|
||||||
|
let startleFn;
|
||||||
|
let flyFn;
|
||||||
|
let talkingFn;
|
||||||
|
|
||||||
|
function goHome() { dispatch('back'); }
|
||||||
|
|
||||||
|
function toggleHistory() {
|
||||||
|
historyOpen = !historyOpen;
|
||||||
|
}
|
||||||
|
|
||||||
|
function showThought(text, keepAlive = false) {
|
||||||
|
if (bubbleAutoHideTimer) clearTimeout(bubbleAutoHideTimer);
|
||||||
|
thoughtBubbleText = text;
|
||||||
|
thoughtBubbleVisible = true;
|
||||||
|
conversationActive = keepAlive;
|
||||||
|
if (!keepAlive) {
|
||||||
|
bubbleAutoHideTimer = setTimeout(() => {
|
||||||
|
thoughtBubbleVisible = false;
|
||||||
|
}, 13000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleThought() {
|
||||||
|
if (thoughtTimer) clearTimeout(thoughtTimer);
|
||||||
|
thoughtTimer = setTimeout(() => {
|
||||||
|
const prompt = thoughtPrompts[Math.floor(Math.random() * thoughtPrompts.length)];
|
||||||
|
showThought(prompt);
|
||||||
|
scheduleThought();
|
||||||
|
}, 18000 + Math.random() * 13000);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendReply() {
|
||||||
|
const msg = replyInputValue.trim();
|
||||||
|
if (!msg || isApiLoading) return;
|
||||||
|
replyInputValue = '';
|
||||||
|
thoughtBubbleVisible = false;
|
||||||
|
await askOlive(msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleReplyKey(e) {
|
||||||
|
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendReply(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function askOlive(userMessage) {
|
||||||
|
if (isApiLoading) return;
|
||||||
|
isApiLoading = true;
|
||||||
|
showThought('…', true);
|
||||||
|
talkingFn?.();
|
||||||
|
|
||||||
|
chatHistory = [...chatHistory, { role: 'user', content: userMessage }];
|
||||||
|
|
||||||
|
try {
|
||||||
|
const endpoint = 'https://api.openai.com/v1/chat/completions';
|
||||||
|
const body = {
|
||||||
|
model: 'gpt-4o-mini',
|
||||||
|
messages: [
|
||||||
|
{ role: 'system', content: SYSTEM_TEXT },
|
||||||
|
...chatHistory
|
||||||
|
],
|
||||||
|
max_tokens: 256,
|
||||||
|
temperature: 0.9
|
||||||
|
};
|
||||||
|
const res = await fetch(endpoint, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `Bearer ${OPENAI_API_KEY}`
|
||||||
|
},
|
||||||
|
body: JSON.stringify(body)
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`API returned status ${res.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
const replyText = data?.choices?.[0]?.message?.content;
|
||||||
|
|
||||||
|
if (replyText) {
|
||||||
|
const cleanReply = replyText.trim();
|
||||||
|
chatHistory = [...chatHistory, { role: 'assistant', content: cleanReply }];
|
||||||
|
showThought(cleanReply, true);
|
||||||
|
} else {
|
||||||
|
showThought("Coo… the wind carried my thought away. Ask me again?", true);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
chatHistory = chatHistory.slice(0, -1);
|
||||||
|
showThought("Coo… the wind carried my thought away. Ask me again?", true);
|
||||||
|
} finally {
|
||||||
|
isApiLoading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
if (!canvasRef) return;
|
||||||
|
const TAU = Zdog.TAU;
|
||||||
|
|
||||||
|
const C = {
|
||||||
|
white: '#ffffff',
|
||||||
|
mid: '#f2f2f2',
|
||||||
|
shade: '#DAD6CE',
|
||||||
|
beak: '#D89A6E',
|
||||||
|
beakDk: '#C07E50',
|
||||||
|
cheek: '#F0A8A0',
|
||||||
|
eye: '#1A1410'
|
||||||
|
};
|
||||||
|
|
||||||
|
const scene = new Zdog.Illustration({
|
||||||
|
element: canvasRef,
|
||||||
|
dragRotate: false,
|
||||||
|
resize: 'window',
|
||||||
|
rotate: { x: -0.05, y: 0.28, z: 0 },
|
||||||
|
zoom: 1.3
|
||||||
|
});
|
||||||
|
|
||||||
|
const fg = new Zdog.Anchor({ addTo: scene });
|
||||||
|
const DOVE_CONT = new Zdog.Anchor({ addTo: fg, scale: 1.35 });
|
||||||
|
const PIVOT = new Zdog.Anchor({ addTo: DOVE_CONT });
|
||||||
|
const DOVE = new Zdog.Anchor({ addTo: PIVOT });
|
||||||
|
|
||||||
|
// ─── 1. SMOOTH INTEGRATED BODY & NECK ──────────────────────────────
|
||||||
|
new Zdog.Shape({
|
||||||
|
addTo: DOVE,
|
||||||
|
path: [{ x: -10, y: 15 }, { x: 25, y: 10 }],
|
||||||
|
stroke: 52,
|
||||||
|
color: C.white
|
||||||
|
});
|
||||||
|
|
||||||
|
new Zdog.Shape({
|
||||||
|
addTo: DOVE,
|
||||||
|
path: [{ x: -5, y: 15 }, { x: -35, y: -8 }],
|
||||||
|
stroke: 36,
|
||||||
|
color: C.white
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── 2. HEAD & FACE ────────────────────────────────────────────────
|
||||||
|
const head = new Zdog.Anchor({ addTo: DOVE, translate: { x: -35, y: -10, z: 0 } });
|
||||||
|
new Zdog.Shape({ addTo: head, stroke: 35, color: C.white });
|
||||||
|
|
||||||
|
new Zdog.Shape({ addTo: head, stroke: 6, color: C.eye, translate: { x: -6, y: -4, z: 15 } });
|
||||||
|
new Zdog.Shape({ addTo: head, stroke: 1.8, color: '#fff', translate: { x: -7.5, y: -5, z: 16 } });
|
||||||
|
new Zdog.Shape({ addTo: head, stroke: 6, color: C.eye, translate: { x: -6, y: -4, z: -15 } });
|
||||||
|
|
||||||
|
new Zdog.Shape({ addTo: head, stroke: 8, color: C.cheek, translate: { x: -1, y: 4, z: 15 } });
|
||||||
|
new Zdog.Shape({ addTo: head, stroke: 8, color: C.cheek, translate: { x: -1, y: 4, z: -15 } });
|
||||||
|
|
||||||
|
// ─── 3. GLUED ON SHARP BEAK ────────────────────────────────────────
|
||||||
|
const beak = new Zdog.Anchor({ addTo: head, translate: { x: -14, y: 2, z: 0 } });
|
||||||
|
|
||||||
|
new Zdog.Shape({
|
||||||
|
addTo: beak,
|
||||||
|
path: [{ x: 0, y: -4 }, { x: -18, y: 0 }, { x: 0, y: 4 }],
|
||||||
|
stroke: 2,
|
||||||
|
color: C.beak,
|
||||||
|
fill: true
|
||||||
|
});
|
||||||
|
|
||||||
|
const lowerBeak = new Zdog.Anchor({ addTo: beak, translate: { x: 0, y: 2 } });
|
||||||
|
new Zdog.Shape({
|
||||||
|
addTo: lowerBeak,
|
||||||
|
path: [{ x: 0, y: 0 }, { x: -14, y: 0 }, { x: 0, y: 2 }],
|
||||||
|
stroke: 1.5,
|
||||||
|
color: C.beakDk,
|
||||||
|
fill: true
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── 4. TWO VOLUMETRIC WINGS ───────────────────────────────────────
|
||||||
|
const elegantWingPath = [
|
||||||
|
{ x: -5, y: 0 },
|
||||||
|
{ bezier: [{ x: 10, y: -45 }, { x: 45, y: -50 }, { x: 65, y: -15 }] },
|
||||||
|
{ bezier: [{ x: 50, y: 5 }, { x: 20, y: 15 }, { x: -5, y: 0 }] }
|
||||||
|
];
|
||||||
|
|
||||||
|
const frontWing = new Zdog.Anchor({ addTo: DOVE, translate: { x: -12, y: 10, z: 24 } });
|
||||||
|
new Zdog.Shape({
|
||||||
|
addTo: frontWing,
|
||||||
|
path: elegantWingPath,
|
||||||
|
stroke: 20,
|
||||||
|
color: C.mid,
|
||||||
|
fill: true
|
||||||
|
});
|
||||||
|
|
||||||
|
const backWing = new Zdog.Anchor({ addTo: DOVE, translate: { x: -12, y: 10, z: -24 } });
|
||||||
|
new Zdog.Shape({
|
||||||
|
addTo: backWing,
|
||||||
|
path: elegantWingPath,
|
||||||
|
stroke: 20,
|
||||||
|
color: C.mid,
|
||||||
|
fill: true
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── 5. TAIL FAN ───────────────────────────────────────────────────
|
||||||
|
const tail = new Zdog.Anchor({ addTo: DOVE, translate: { x: 22, y: 10 } });
|
||||||
|
new Zdog.Shape({ addTo: tail, path: [{ x: 0, y: 0 }, { x: 38, y: 4 }], stroke: 16, color: C.shade });
|
||||||
|
new Zdog.Shape({ addTo: tail, path: [{ x: 0, y: 0 }, { x: 32, y: 2 }], stroke: 12, color: C.mid, translate: { z: 10 } });
|
||||||
|
new Zdog.Shape({ addTo: tail, path: [{ x: 0, y: 0 }, { x: 32, y: 2 }], stroke: 12, color: C.shade, translate: { z: -10 } });
|
||||||
|
|
||||||
|
// ─── Expression system ───────────────────────────
|
||||||
|
function setExpression(name) {
|
||||||
|
let beakOpen = 0;
|
||||||
|
if (name === 'calm') { beakOpen = 0; }
|
||||||
|
else if (name === 'happy') { beakOpen = 0.3; }
|
||||||
|
else if (name === 'alarmed') { beakOpen = 0.4; }
|
||||||
|
lowerBeak.rotate.z = beakOpen;
|
||||||
|
}
|
||||||
|
setExpression('calm');
|
||||||
|
|
||||||
|
// ─── Animation Loop ───────────────────────────────────────────────
|
||||||
|
let frame = 0, isRunning = true, isBusy = false, wingBeat = 0;
|
||||||
|
const flap = { amp: 0.06, speed: 1 };
|
||||||
|
|
||||||
|
function render() {
|
||||||
|
if (!isRunning) return;
|
||||||
|
frame++;
|
||||||
|
|
||||||
|
wingBeat += 0.13 * flap.speed;
|
||||||
|
frontWing.rotate.z = Math.sin(wingBeat) * flap.amp;
|
||||||
|
backWing.rotate.z = Math.sin(wingBeat - 0.5) * flap.amp;
|
||||||
|
|
||||||
|
if (!isBusy) {
|
||||||
|
DOVE_CONT.translate.y = Math.sin(frame / 40) * 4;
|
||||||
|
head.rotate.z = Math.sin(frame / 46) * 0.05;
|
||||||
|
tail.rotate.z = Math.sin(frame / 60) * 0.04;
|
||||||
|
}
|
||||||
|
|
||||||
|
scene.updateRenderGraph();
|
||||||
|
requestAnimationFrame(render);
|
||||||
|
}
|
||||||
|
render();
|
||||||
|
|
||||||
|
// ─── Interactions ──────────────────────────────────────────────────
|
||||||
|
function startle() {
|
||||||
|
if (isBusy) return;
|
||||||
|
isBusy = true;
|
||||||
|
setExpression('alarmed');
|
||||||
|
gsap.to(flap, { duration: 0.1, amp: 0.4, speed: 3 });
|
||||||
|
const tl = gsap.timeline({
|
||||||
|
onComplete: () => {
|
||||||
|
setExpression('calm');
|
||||||
|
gsap.to(flap, { duration: 0.7, amp: 0.06, speed: 1 });
|
||||||
|
isBusy = false; DOVE_CONT.translate.y = 0; PIVOT.rotate.z = 0;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
tl.to(DOVE_CONT.translate, { duration: 0.12, y: -28, ease: 'power2.out' });
|
||||||
|
tl.to(PIVOT.rotate, { duration: 0.12, z: 0.14, ease: 'power2.out' }, '<');
|
||||||
|
tl.to(DOVE_CONT.translate, { duration: 0.6, y: 0, ease: 'bounce.out' });
|
||||||
|
tl.to(PIVOT.rotate, { duration: 0.6, z: 0, ease: 'power1.out' }, '<');
|
||||||
|
tl.to({}, { duration: 0.6 });
|
||||||
|
askOlive("Someone just startled me into a flurry of feathers. Share short, gentle dove wisdom about finding calm again after a fright.");
|
||||||
|
}
|
||||||
|
|
||||||
|
function fly() {
|
||||||
|
if (isBusy) return;
|
||||||
|
isBusy = true;
|
||||||
|
setExpression('happy');
|
||||||
|
gsap.to(flap, { duration: 0.2, amp: 0.65, speed: 3 });
|
||||||
|
const tl = gsap.timeline({
|
||||||
|
onComplete: () => {
|
||||||
|
setExpression('calm');
|
||||||
|
gsap.to(flap, { duration: 0.7, amp: 0.06, speed: 1 });
|
||||||
|
isBusy = false;
|
||||||
|
DOVE_CONT.translate.x = 0; DOVE_CONT.translate.y = 0; DOVE_CONT.translate.z = 0;
|
||||||
|
PIVOT.rotate.z = 0; PIVOT.rotate.y = 0; PIVOT.rotate.x = 0;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
tl.to(DOVE_CONT.translate, { duration: 0.6, x: -120, y: -90, z: 60, ease: 'power2.inOut' });
|
||||||
|
tl.to(PIVOT.rotate, { duration: 0.6, z: 0.3, x: 0.1, ease: 'power2.inOut' }, '<');
|
||||||
|
tl.to(DOVE_CONT.translate, { duration: 0.6, x: 0, y: -150, z: 120, ease: 'power2.inOut' });
|
||||||
|
tl.to(PIVOT.rotate, { duration: 0.6, z: 0, y: -0.4, ease: 'power2.inOut' }, '<');
|
||||||
|
tl.to(DOVE_CONT.translate, { duration: 0.6, x: 130, y: -90, z: 60, ease: 'power2.inOut' });
|
||||||
|
tl.to(PIVOT.rotate, { duration: 0.6, z: -0.3, y: 0, ease: 'power2.inOut' }, '<');
|
||||||
|
tl.to(DOVE_CONT.translate, { duration: 0.6, x: 0, y: 0, z: 0, ease: 'power2.inOut' });
|
||||||
|
tl.to(PIVOT.rotate, { duration: 0.6, z: 0, x: 0, ease: 'power2.inOut' }, '<');
|
||||||
|
}
|
||||||
|
|
||||||
|
function talkingAnimation() {
|
||||||
|
if (isBusy) return;
|
||||||
|
isBusy = true;
|
||||||
|
const tl = gsap.timeline({ onComplete: () => { setExpression('calm'); isBusy = false; head.rotate.z = 0; } });
|
||||||
|
for (let i = 0; i < 3; i++) {
|
||||||
|
tl.add(() => setExpression('happy'));
|
||||||
|
tl.to(head.rotate, { duration: 0.25, z: 0.08, ease: 'sine.inOut' });
|
||||||
|
tl.add(() => setExpression('calm'));
|
||||||
|
tl.to(head.rotate, { duration: 0.25, z: 0, ease: 'sine.inOut' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
startleFn = startle;
|
||||||
|
flyFn = fly;
|
||||||
|
talkingFn = talkingAnimation;
|
||||||
|
|
||||||
|
// ─── Click / Double Click Handlers ──────────────────────────────────────────
|
||||||
|
let clickTimeout = null;
|
||||||
|
|
||||||
|
function onCanvasClick(e) {
|
||||||
|
if (clickTimeout !== null) {
|
||||||
|
// Double click detected
|
||||||
|
clearTimeout(clickTimeout);
|
||||||
|
clickTimeout = null;
|
||||||
|
startleFn();
|
||||||
|
} else {
|
||||||
|
// Single click detected, wait to see if a second click comes
|
||||||
|
clickTimeout = setTimeout(() => {
|
||||||
|
clickTimeout = null;
|
||||||
|
flyFn();
|
||||||
|
}, 250); // 250ms window for double click
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!embedded) {
|
||||||
|
canvasRef.addEventListener('click', onCanvasClick);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!embedded) {
|
||||||
|
setTimeout(() => showThought("Coo… the sky is calm today. 🕊️ What is on your mind?"), 3500);
|
||||||
|
scheduleThought();
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
isRunning = false;
|
||||||
|
if (thoughtTimer) clearTimeout(thoughtTimer);
|
||||||
|
if (bubbleAutoHideTimer) clearTimeout(bubbleAutoHideTimer);
|
||||||
|
if (clickTimeout) clearTimeout(clickTimeout);
|
||||||
|
if (!embedded) {
|
||||||
|
canvasRef?.removeEventListener('click', onCanvasClick);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if !embedded}
|
||||||
|
<div class="top-bar">
|
||||||
|
<button class="bar-btn" on:click={goHome}>←</button>
|
||||||
|
<button class="bar-btn" on:click={toggleHistory}>
|
||||||
|
{historyOpen ? 'Close history' : '💬 History'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if !embedded && historyOpen}
|
||||||
|
<div class="history-panel">
|
||||||
|
<div class="history-header">
|
||||||
|
<h2>Session chat</h2>
|
||||||
|
<button class="close-btn" on:click={() => historyOpen = false}>✕</button>
|
||||||
|
</div>
|
||||||
|
<div class="history-scroll">
|
||||||
|
{#each chatHistory as msg}
|
||||||
|
<div class="history-msg {msg.role}">
|
||||||
|
<span class="history-label">{msg.role === 'assistant' ? '🕊️ Olive' : 'You'}</span>
|
||||||
|
<p>{msg.content}</p>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if !embedded}
|
||||||
|
<DoveBackground />
|
||||||
|
{/if}
|
||||||
|
<canvas bind:this={canvasRef} class="scene" class:embedded></canvas>
|
||||||
|
|
||||||
|
<div class="thought-wrap" class:visible={thoughtBubbleVisible}>
|
||||||
|
<div class="thought-bubble">
|
||||||
|
<div class="thought-dots">
|
||||||
|
<span></span><span></span><span></span>
|
||||||
|
</div>
|
||||||
|
<p class="thought-text">{thoughtBubbleText}</p>
|
||||||
|
</div>
|
||||||
|
<div class="thought-reply">
|
||||||
|
<textarea
|
||||||
|
bind:value={replyInputValue}
|
||||||
|
placeholder="Reply to Olive…"
|
||||||
|
rows="1"
|
||||||
|
disabled={isApiLoading}
|
||||||
|
on:keydown={handleReplyKey}
|
||||||
|
></textarea>
|
||||||
|
<button class="reply-send" on:click={sendReply} disabled={isApiLoading || !replyInputValue.trim()}>
|
||||||
|
{isApiLoading ? '…' : '➤'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="hint">Click to fly. Double-click to startle.</p>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
@import url("https://fonts.googleapis.com/css2?family=Nunito:wght@400;600;700;800&display=swap");
|
||||||
|
|
||||||
|
:global(body) {
|
||||||
|
margin: 0; padding: 0;
|
||||||
|
width: 100vw; height: 100vh;
|
||||||
|
background: #BFD9EF;
|
||||||
|
overflow: hidden;
|
||||||
|
font-family: 'Nunito', sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
canvas.scene {
|
||||||
|
position: fixed; inset: 0;
|
||||||
|
width: 100vw; height: 100vh;
|
||||||
|
display: block; z-index: 1;
|
||||||
|
touch-action: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
canvas.scene.embedded {
|
||||||
|
position: absolute;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
inset: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Top bar ─────────────────────────────────────────────────────── */
|
||||||
|
.top-bar {
|
||||||
|
position: fixed;
|
||||||
|
top: 16px; left: 16px;
|
||||||
|
display: flex; gap: 10px;
|
||||||
|
z-index: 30;
|
||||||
|
}
|
||||||
|
.bar-btn {
|
||||||
|
padding: 9px 16px;
|
||||||
|
border: none; border-radius: 20px;
|
||||||
|
background: rgba(255,255,255,0.92);
|
||||||
|
color: #2E3A47;
|
||||||
|
font-family: 'Nunito', sans-serif;
|
||||||
|
font-weight: 700; font-size: 0.85rem;
|
||||||
|
cursor: pointer;
|
||||||
|
box-shadow: 0 4px 18px rgba(46,58,71,.12);
|
||||||
|
transition: background .15s, transform .1s;
|
||||||
|
}
|
||||||
|
.bar-btn:hover { background: #fff; transform: translateY(-1px); }
|
||||||
|
.bar-btn:active { transform: scale(.96); }
|
||||||
|
|
||||||
|
/* ── History panel ───────────────────────────────────────────────── */
|
||||||
|
.history-panel {
|
||||||
|
position: fixed;
|
||||||
|
top: 62px; right: 16px;
|
||||||
|
width: min(360px, calc(100vw - 32px));
|
||||||
|
max-height: calc(100vh - 80px);
|
||||||
|
background: rgba(255,255,255,0.97);
|
||||||
|
border-radius: 24px;
|
||||||
|
box-shadow: 0 20px 60px rgba(46,58,71,.18);
|
||||||
|
z-index: 25;
|
||||||
|
display: flex; flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.history-header {
|
||||||
|
display: flex; justify-content: space-between; align-items: center;
|
||||||
|
padding: 18px 20px 12px;
|
||||||
|
border-bottom: 1px solid rgba(111,168,220,.18);
|
||||||
|
}
|
||||||
|
.history-header h2 { margin: 0; font-size: 1rem; color: #2E3A47; }
|
||||||
|
.close-btn {
|
||||||
|
border: none; background: #e4eef8;
|
||||||
|
color: #3A6699; border-radius: 50%;
|
||||||
|
width: 30px; height: 30px;
|
||||||
|
cursor: pointer; font-size: 0.85rem; font-weight: 700;
|
||||||
|
}
|
||||||
|
.history-scroll {
|
||||||
|
overflow-y: auto; padding: 14px 16px;
|
||||||
|
display: flex; flex-direction: column; gap: 10px;
|
||||||
|
}
|
||||||
|
.history-msg {
|
||||||
|
border-radius: 16px; padding: 12px 14px;
|
||||||
|
font-size: 0.9rem; line-height: 1.55;
|
||||||
|
}
|
||||||
|
.history-msg p { margin: 4px 0 0; color: #26313b; }
|
||||||
|
.history-msg.assistant { background: #eef4fb; }
|
||||||
|
.history-msg.user { background: #f7f4ef; }
|
||||||
|
.history-label { font-weight: 800; font-size: 0.78rem; color: #4A7BB0; }
|
||||||
|
|
||||||
|
/* ── Thought bubble (Moved to bottom right) ──────────────────────────────────────────────── */
|
||||||
|
.thought-wrap {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 32px;
|
||||||
|
right: 32px;
|
||||||
|
max-width: min(360px, 90vw);
|
||||||
|
display: flex; flex-direction: column; gap: 10px;
|
||||||
|
z-index: 15;
|
||||||
|
pointer-events: none;
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(8px) scale(.96);
|
||||||
|
transition: opacity .35s ease, transform .35s ease;
|
||||||
|
align-items: flex-end; /* Align elements to the right */
|
||||||
|
}
|
||||||
|
.thought-wrap.visible {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0) scale(1);
|
||||||
|
pointer-events: all;
|
||||||
|
}
|
||||||
|
.thought-bubble {
|
||||||
|
background: #fff;
|
||||||
|
border: 2px solid rgba(111,168,220,.30);
|
||||||
|
border-radius: 22px 22px 6px 22px; /* Adjusted tail for right side */
|
||||||
|
padding: 14px 18px;
|
||||||
|
box-shadow: 0 8px 36px rgba(46,58,71,.14);
|
||||||
|
position: relative;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
.thought-dots { display: flex; gap: 4px; margin-bottom: 6px; }
|
||||||
|
.thought-dots span { width: 5px; height: 5px; background: #6FA8DC; border-radius: 50%; }
|
||||||
|
.thought-text {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.92rem;
|
||||||
|
color: #26313b;
|
||||||
|
line-height: 1.6;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.thought-reply { display: flex; gap: 8px; align-items: flex-end; width: 100%; }
|
||||||
|
.thought-reply textarea {
|
||||||
|
flex: 1;
|
||||||
|
background: rgba(255,255,255,0.96);
|
||||||
|
border: 1.5px solid rgba(111,168,220,.40);
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
font-family: 'Nunito', sans-serif;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: #26313b;
|
||||||
|
outline: none; resize: none;
|
||||||
|
height: 44px; min-height: 44px; max-height: 88px;
|
||||||
|
line-height: 1.5; box-sizing: border-box;
|
||||||
|
transition: border-color .15s, box-shadow .15s;
|
||||||
|
}
|
||||||
|
.thought-reply textarea::placeholder { color: #9bb6cf; }
|
||||||
|
.thought-reply textarea:focus {
|
||||||
|
border-color: #4F8BC9;
|
||||||
|
box-shadow: 0 0 0 3px rgba(79,139,201,.18);
|
||||||
|
}
|
||||||
|
.reply-send {
|
||||||
|
width: 44px; height: 44px;
|
||||||
|
border-radius: 50%; border: none;
|
||||||
|
background: #6FA8DC; color: #fff;
|
||||||
|
font-size: 1rem; cursor: pointer;
|
||||||
|
flex-shrink: 0;
|
||||||
|
transition: background .12s, transform .1s;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
}
|
||||||
|
.reply-send:hover:not(:disabled) { background: #4F8BC9; }
|
||||||
|
.reply-send:active:not(:disabled) { transform: scale(.94); }
|
||||||
|
.reply-send:disabled { opacity: .5; cursor: default; }
|
||||||
|
|
||||||
|
/* ── Hint ────────────────────────────────────────────────────────── */
|
||||||
|
.hint {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 22px; left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
font-size: 0.8rem; font-weight: 600;
|
||||||
|
color: rgba(46, 58, 71, 0.55);
|
||||||
|
pointer-events: none;
|
||||||
|
letter-spacing: .04em;
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
.credit {
|
||||||
|
position: fixed; bottom: 8px; left: 14px;
|
||||||
|
font-size: 0.7rem; color: rgba(46,58,71,.42);
|
||||||
|
z-index: 10; pointer-events: none;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
100
src/DoveBackground.svelte
Normal file
100
src/DoveBackground.svelte
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
<script>
|
||||||
|
// @ts-nocheck
|
||||||
|
import { onMount, onDestroy } from 'svelte';
|
||||||
|
import Zdog from 'zdog';
|
||||||
|
|
||||||
|
let canvas;
|
||||||
|
let illo;
|
||||||
|
let rafId;
|
||||||
|
let clouds = [];
|
||||||
|
|
||||||
|
function buildScene(illo) {
|
||||||
|
const CLOUD_PINK = '#FFE4F0';
|
||||||
|
const CLOUD_WHITE = '#FFF7FB';
|
||||||
|
|
||||||
|
// Define initial cloud positions, scales, and drift speeds
|
||||||
|
const cloudConfigs = [
|
||||||
|
{ x: -600, y: -200, z: -200, s: 1.2, speed: 0.5 },
|
||||||
|
{ x: 200, y: -100, z: -400, s: 0.9, speed: 0.3 },
|
||||||
|
{ x: 600, y: 200, z: -600, s: 1.3, speed: 0.6 },
|
||||||
|
{ x: -300, y: 300, z: -300, s: 1.1, speed: 0.4 },
|
||||||
|
{ x: 0, y: 0, z: -800, s: 1.0, speed: 0.2 },
|
||||||
|
];
|
||||||
|
|
||||||
|
cloudConfigs.forEach(config => {
|
||||||
|
const c = new Zdog.Anchor({
|
||||||
|
addTo: illo,
|
||||||
|
translate: { x: config.x, y: config.y, z: config.z },
|
||||||
|
scale: config.s
|
||||||
|
});
|
||||||
|
|
||||||
|
new Zdog.Shape({ addTo: c, stroke: 110, color: CLOUD_WHITE });
|
||||||
|
new Zdog.Shape({ addTo: c, stroke: 76, color: CLOUD_PINK, translate: { x: -58, y: 12 } });
|
||||||
|
new Zdog.Shape({ addTo: c, stroke: 82, color: CLOUD_WHITE, translate: { x: 56, y: 6 } });
|
||||||
|
new Zdog.Shape({ addTo: c, stroke: 64, color: CLOUD_PINK, translate: { x: -98, y: 16 } });
|
||||||
|
new Zdog.Shape({ addTo: c, stroke: 58, color: CLOUD_WHITE, translate: { x: 100, y: 12 } });
|
||||||
|
|
||||||
|
// Save the anchor and its speed so we can animate it later
|
||||||
|
clouds.push({ anchor: c, speed: config.speed });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
// Initialize the Zdog illustration
|
||||||
|
illo = new Zdog.Illustration({
|
||||||
|
element: canvas,
|
||||||
|
dragRotate: false, // Disabled to keep the camera completely static
|
||||||
|
resize: 'window',
|
||||||
|
zoom: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
buildScene(illo);
|
||||||
|
|
||||||
|
// The animation loop
|
||||||
|
function tick() {
|
||||||
|
// Move each cloud to the right
|
||||||
|
clouds.forEach(cloud => {
|
||||||
|
cloud.anchor.translate.x += cloud.speed;
|
||||||
|
|
||||||
|
// When a cloud moves entirely off the right side of the screen,
|
||||||
|
// snap it back to the far left to create an infinite loop.
|
||||||
|
if (cloud.anchor.translate.x > 1200) {
|
||||||
|
cloud.anchor.translate.x = -1200;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
illo.updateRenderGraph();
|
||||||
|
rafId = requestAnimationFrame(tick);
|
||||||
|
}
|
||||||
|
|
||||||
|
tick(); // Start the loop
|
||||||
|
});
|
||||||
|
|
||||||
|
onDestroy(() => {
|
||||||
|
cancelAnimationFrame(rafId);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<canvas bind:this={canvas} class="sky-canvas"></canvas>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
:global(html, body) {
|
||||||
|
height: 100%;
|
||||||
|
margin: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sky-canvas {
|
||||||
|
position: fixed;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
width: 100vw;
|
||||||
|
height: 100vh;
|
||||||
|
background: #FFD9EC; /* Static pink background */
|
||||||
|
display: block;
|
||||||
|
border: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
612
src/Land.svelte
Normal file
612
src/Land.svelte
Normal file
@@ -0,0 +1,612 @@
|
|||||||
|
<script>
|
||||||
|
// @ts-nocheck
|
||||||
|
import { onMount, onDestroy } from 'svelte';
|
||||||
|
import Zdog from 'zdog';
|
||||||
|
|
||||||
|
const TAU = Zdog.TAU;
|
||||||
|
|
||||||
|
// tweakables
|
||||||
|
const MIN_ZOOM = 0.15;
|
||||||
|
const MAX_ZOOM = 16;
|
||||||
|
const START_ZOOM = 0.6;
|
||||||
|
const ZOOM_EASE = 0.12;
|
||||||
|
const ZOOM_STEP = 1.06;
|
||||||
|
const SPIN_SPEED = 0.003;
|
||||||
|
const LAND_SCALE = 0.82;
|
||||||
|
|
||||||
|
// Asset Placements (Kept mostly identical to preserve hitboxes, minor tweaks for aesthetics)
|
||||||
|
const DOVE_POS = { x: 220, y: -280, z: -360 };
|
||||||
|
const DOVE_SCALE = 1.35;
|
||||||
|
|
||||||
|
const BEE_POS = { x: -200, y: 20, z: -140 };
|
||||||
|
const BEE_SCALE = 0.2;
|
||||||
|
|
||||||
|
const CHICKEN_POS = { x: -300, y: 30, z: 200 };
|
||||||
|
const CHICKEN_SCALE = 0.6;
|
||||||
|
|
||||||
|
const BLOB_POS = { x: 300, y: 150, z: -200 };
|
||||||
|
const BLOB_SCALE = 0.5;
|
||||||
|
|
||||||
|
const SLOTH_POS = { x: -20, y: 0, z: -120 };
|
||||||
|
const SLOTH_SCALE = 1;
|
||||||
|
|
||||||
|
const SIGN_POS = { x: 0, y: 170, z: 800 }; // Front edge of the island
|
||||||
|
|
||||||
|
let { onSelectDove, onSelectBee, onSelectChicken, onSelectFish, onSelectSloth } = $props();
|
||||||
|
|
||||||
|
// Reactive and lifecycle handles
|
||||||
|
let canvas = $state(null);
|
||||||
|
let illo;
|
||||||
|
let rafId;
|
||||||
|
let spin = true;
|
||||||
|
let targetZoom = START_ZOOM;
|
||||||
|
|
||||||
|
let dove;
|
||||||
|
let bee;
|
||||||
|
let wingBeat = 0;
|
||||||
|
let beeFrame = 0;
|
||||||
|
let downX = 0;
|
||||||
|
let downY = 0;
|
||||||
|
let prevDragX = 0; // Tracks X movement for single-axis rotation
|
||||||
|
let chickenHandle;
|
||||||
|
let fish;
|
||||||
|
|
||||||
|
// Sloth handles
|
||||||
|
let slothPivot;
|
||||||
|
let slothHitPos = null;
|
||||||
|
|
||||||
|
const lerp = (a, b, t) => a + (b - a) * t;
|
||||||
|
|
||||||
|
function buildScene(illo) {
|
||||||
|
const GRASS = '#5ab030';
|
||||||
|
const GRASS_DARK = '#3a8018';
|
||||||
|
const DIRT = '#9A6438';
|
||||||
|
const DIRT_DARK = '#6E4424';
|
||||||
|
const POND = '#73BFF5';
|
||||||
|
const POND_EDGE = '#5EA7DC';
|
||||||
|
const BRANCH = '#6B4A2A';
|
||||||
|
const BRANCH_DK = '#4E3318';
|
||||||
|
const TREE = '#4A7B28';
|
||||||
|
const TREE_DARK = '#36601A';
|
||||||
|
const CLOUD_PINK = '#FFE4F0';
|
||||||
|
const CLOUD_WHITE = '#FFF7FB';
|
||||||
|
|
||||||
|
const SURFACE_Y = 170;
|
||||||
|
const ISLAND_DEPTH = 260;
|
||||||
|
const ISLAND_Z = -310;
|
||||||
|
|
||||||
|
const r = new Zdog.Anchor({ addTo: illo });
|
||||||
|
const land = new Zdog.Anchor({ addTo: r, scale: LAND_SCALE });
|
||||||
|
|
||||||
|
// ── Island slab (Made slightly wider to accommodate more nature) ──
|
||||||
|
new Zdog.Cylinder({
|
||||||
|
addTo: land,
|
||||||
|
diameter: 2500,
|
||||||
|
length: ISLAND_DEPTH,
|
||||||
|
color: DIRT,
|
||||||
|
frontFace: GRASS,
|
||||||
|
backface: DIRT_DARK,
|
||||||
|
stroke: false,
|
||||||
|
rotate: { x: TAU / 4 },
|
||||||
|
translate: { y: SURFACE_Y + ISLAND_DEPTH / 2, z: ISLAND_Z },
|
||||||
|
});
|
||||||
|
|
||||||
|
new Zdog.Ellipse({
|
||||||
|
addTo: land, diameter: 2300, stroke: 0, fill: true, color: GRASS_DARK,
|
||||||
|
rotate: { x: TAU / 4 }, translate: { y: SURFACE_Y - 1, z: ISLAND_Z },
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Ambiguous Organic Pond ──
|
||||||
|
const pondPath = [
|
||||||
|
{ x: -150, y: -200 },
|
||||||
|
{ bezier: [ {x: -150, y: -450}, {x: 250, y: -400}, {x: 350, y: -150} ] },
|
||||||
|
{ bezier: [ {x: 450, y: 100}, {x: 200, y: 350}, {x: 0, y: 300} ] },
|
||||||
|
{ bezier: [ {x: -200, y: 250}, {x: -150, y: 50}, {x: -150, y: -200} ] }
|
||||||
|
];
|
||||||
|
|
||||||
|
new Zdog.Shape({
|
||||||
|
addTo: land,
|
||||||
|
path: pondPath,
|
||||||
|
rotate: { x: TAU / 4 },
|
||||||
|
translate: { x: 150, y: SURFACE_Y - 4, z: ISLAND_Z + 150 },
|
||||||
|
stroke: 40, color: POND_EDGE, fill: true
|
||||||
|
});
|
||||||
|
|
||||||
|
new Zdog.Shape({
|
||||||
|
addTo: land,
|
||||||
|
path: pondPath,
|
||||||
|
rotate: { x: TAU / 4 },
|
||||||
|
translate: { x: 150, y: SURFACE_Y - 2, z: ISLAND_Z + 150 },
|
||||||
|
stroke: 0, color: POND, fill: true
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Bigger, Fuller Trees ──
|
||||||
|
function createTree(parent, x, z, scale = 1) {
|
||||||
|
const t = new Zdog.Anchor({ addTo: parent, translate: { x, y: SURFACE_Y, z }, scale });
|
||||||
|
// Trunk
|
||||||
|
new Zdog.Shape({ addTo: t, path: [{ x: 0, y: 0 }, { x: 0, y: -240 }], stroke: 50, color: BRANCH });
|
||||||
|
// Dense Canopy
|
||||||
|
new Zdog.Shape({ addTo: t, stroke: 260, color: TREE, translate: { y: -280 } });
|
||||||
|
new Zdog.Shape({ addTo: t, stroke: 200, color: TREE_DARK, translate: { x: -80, y: -240, z: 40 } });
|
||||||
|
new Zdog.Shape({ addTo: t, stroke: 210, color: TREE, translate: { x: 90, y: -230, z: 30 } });
|
||||||
|
new Zdog.Shape({ addTo: t, stroke: 190, color: TREE_DARK, translate: { x: 0, y: -360, z: -50 } });
|
||||||
|
new Zdog.Shape({ addTo: t, stroke: 180, color: TREE, translate: { x: -50, y: -330, z: 70 } });
|
||||||
|
new Zdog.Shape({ addTo: t, stroke: 170, color: TREE_DARK, translate: { x: 70, y: -300, z: -70 } });
|
||||||
|
}
|
||||||
|
|
||||||
|
createTree(land, -650, -500, 1.3);
|
||||||
|
createTree(land, 650, -300, 1.4);
|
||||||
|
createTree(land, 300, -800, 1.1);
|
||||||
|
createTree(land, -550, -100, 1.5);
|
||||||
|
createTree(land, 750, -700, 1.2);
|
||||||
|
createTree(land, -750, -350, 1.2);
|
||||||
|
createTree(land, -100, -900, 1.3);
|
||||||
|
createTree(land, 800, 100, 1.1);
|
||||||
|
|
||||||
|
// ── Flower Garden Scatter ──
|
||||||
|
function createFlower(parent, x, z, scale = 1) {
|
||||||
|
const f = new Zdog.Anchor({ addTo: parent, translate: { x, y: SURFACE_Y, z }, scale });
|
||||||
|
new Zdog.Shape({ addTo: f, path: [{y:0}, {y:-40}], stroke: 8, color: '#6A9A38' });
|
||||||
|
|
||||||
|
const pColor = ['#FF6B8B', '#FFD93D', '#6BCBFF', '#D488FF', '#FFFFFF'][Math.floor(Math.random()*5)];
|
||||||
|
const petals = new Zdog.Anchor({ addTo: f, translate: { y: -40 }, rotate: { x: TAU/8 } });
|
||||||
|
|
||||||
|
new Zdog.Shape({ addTo: petals, stroke: 22, color: pColor, translate: {x:-12, y:-12} });
|
||||||
|
new Zdog.Shape({ addTo: petals, stroke: 22, color: pColor, translate: {x:12, y:-12} });
|
||||||
|
new Zdog.Shape({ addTo: petals, stroke: 22, color: pColor, translate: {x:-12, y:12} });
|
||||||
|
new Zdog.Shape({ addTo: petals, stroke: 22, color: pColor, translate: {x:12, y:12} });
|
||||||
|
|
||||||
|
new Zdog.Shape({ addTo: petals, stroke: 16, color: '#FFF176', translate: {z: 8} });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Procedurally plant 60 flowers across the island
|
||||||
|
for(let i = 0; i < 60; i++) {
|
||||||
|
const angle = Math.random() * TAU;
|
||||||
|
const radius = 300 + Math.random() * 800; // Keep away from exact center
|
||||||
|
createFlower(land, Math.cos(angle) * radius, (Math.sin(angle) * radius) + ISLAND_Z, 0.4 + Math.random() * 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
const slothTree = new Zdog.Anchor({
|
||||||
|
addTo: land,
|
||||||
|
translate: { x: SLOTH_POS.x-100, y: SURFACE_Y-320, z: SLOTH_POS.z+50 }
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Clouds ──
|
||||||
|
function createCloud(parent, x, y, z, s = 1) {
|
||||||
|
const c = new Zdog.Anchor({ addTo: parent, translate: { x, y, z }, scale: s });
|
||||||
|
new Zdog.Shape({ addTo: c, stroke: 110, color: CLOUD_WHITE });
|
||||||
|
new Zdog.Shape({ addTo: c, stroke: 76, color: CLOUD_PINK, translate: { x: -58, y: 12 } });
|
||||||
|
new Zdog.Shape({ addTo: c, stroke: 82, color: CLOUD_WHITE, translate: { x: 56, y: 6 } });
|
||||||
|
new Zdog.Shape({ addTo: c, stroke: 64, color: CLOUD_PINK, translate: { x: -98, y: 16 } });
|
||||||
|
new Zdog.Shape({ addTo: c, stroke: 58, color: CLOUD_WHITE, translate: { x: 100, y: 12 } });
|
||||||
|
}
|
||||||
|
|
||||||
|
createCloud(r, -680, -360, -500, 1.25);
|
||||||
|
createCloud(r, -180, -450, -960, 1.15);
|
||||||
|
createCloud(r, 320, -360, -600, 1.10);
|
||||||
|
|
||||||
|
// ── Dove ──
|
||||||
|
function createDove(parent, x, y, z, scale = 1) {
|
||||||
|
const C = { white: '#ffffff', mid: '#f2f2f2', shade: '#DAD6CE', beak: '#D89A6E', beakDk: '#C07E50', cheek: '#F0A8A0', eye: '#1A1410' };
|
||||||
|
const d = new Zdog.Anchor({ addTo: parent, translate: { x, y, z }, scale });
|
||||||
|
new Zdog.Shape({ addTo: d, path: [{ x: -10, y: 15 }, { x: 25, y: 10 }], stroke: 52, color: C.white });
|
||||||
|
new Zdog.Shape({ addTo: d, path: [{ x: -5, y: 15 }, { x: -35, y: -8 }], stroke: 36, color: C.white });
|
||||||
|
const head = new Zdog.Anchor({ addTo: d, translate: { x: -35, y: -10, z: 0 } });
|
||||||
|
new Zdog.Shape({ addTo: head, stroke: 35, color: C.white });
|
||||||
|
new Zdog.Shape({ addTo: head, stroke: 6, color: C.eye, translate: { x: -6, y: -4, z: 15 } });
|
||||||
|
new Zdog.Shape({ addTo: head, stroke: 1.8, color: '#fff', translate: { x: -7.5, y: -5, z: 16 } });
|
||||||
|
new Zdog.Shape({ addTo: head, stroke: 6, color: C.eye, translate: { x: -6, y: -4, z: -15 } });
|
||||||
|
new Zdog.Shape({ addTo: head, stroke: 8, color: C.cheek, translate: { x: -1, y: 4, z: 15 } });
|
||||||
|
new Zdog.Shape({ addTo: head, stroke: 8, color: C.cheek, translate: { x: -1, y: 4, z: -15 } });
|
||||||
|
const beak = new Zdog.Anchor({ addTo: head, translate: { x: -14, y: 2, z: 0 } });
|
||||||
|
new Zdog.Shape({ addTo: beak, path: [{ x: 0, y: -4 }, { x: -18, y: 0 }, { x: 0, y: 4 }], stroke: 2, color: C.beak, fill: true });
|
||||||
|
new Zdog.Shape({ addTo: beak, path: [{ x: 0, y: 0 }, { x: -14, y: 0 }, { x: 0, y: 2 }], stroke: 1.5, color: C.beakDk, fill: true, translate: { y: 2 } });
|
||||||
|
const wingPath = [{ x: -5, y: 0 }, { bezier: [{ x: 10, y: -45 }, { x: 45, y: -50 }, { x: 65, y: -15 }] }, { bezier: [{ x: 50, y: 5 }, { x: 20, y: 15 }, { x: -5, y: 0 }] }];
|
||||||
|
const frontWing = new Zdog.Anchor({ addTo: d, translate: { x: -12, y: 10, z: 24 } });
|
||||||
|
new Zdog.Shape({ addTo: frontWing, path: wingPath, stroke: 20, color: C.mid, fill: true });
|
||||||
|
const backWing = new Zdog.Anchor({ addTo: d, translate: { x: -12, y: 10, z: -24 } });
|
||||||
|
new Zdog.Shape({ addTo: backWing, path: wingPath, stroke: 20, color: C.mid, fill: true });
|
||||||
|
const tail = new Zdog.Anchor({ addTo: d, translate: { x: 22, y: 10 } });
|
||||||
|
new Zdog.Shape({ addTo: tail, path: [{ x: 0, y: 0 }, { x: 38, y: 4 }], stroke: 16, color: C.shade });
|
||||||
|
new Zdog.Shape({ addTo: tail, path: [{ x: 0, y: 0 }, { x: 32, y: 2 }], stroke: 12, color: C.mid, translate: { z: 10 } });
|
||||||
|
new Zdog.Shape({ addTo: tail, path: [{ x: 0, y: 0 }, { x: 32, y: 2 }], stroke: 12, color: C.shade, translate: { z: -10 } });
|
||||||
|
return { dove: d, frontWing, backWing };
|
||||||
|
}
|
||||||
|
dove = createDove(r, DOVE_POS.x, DOVE_POS.y, DOVE_POS.z, DOVE_SCALE);
|
||||||
|
|
||||||
|
// ── Bee ──
|
||||||
|
function createBee(parent, x, y, z, scale = 1) {
|
||||||
|
const B = { yellow: '#FCD116', black: '#3D2620', white: '#FFFDF0', cheek: '#F26B50' };
|
||||||
|
const cont = new Zdog.Anchor({ addTo: parent, translate: { x, y, z }, scale });
|
||||||
|
const b = new Zdog.Anchor({ addTo: cont });
|
||||||
|
const head = new Zdog.Shape({ addTo: b, stroke: 28, color: B.yellow });
|
||||||
|
new Zdog.Shape({ addTo: head, stroke: 3, color: B.black, translate: { x: -22, y: 4, z: 36 } });
|
||||||
|
new Zdog.Shape({ addTo: head, stroke: 3, color: B.black, translate: { x: 14, y: 4, z: 40 } });
|
||||||
|
new Zdog.Shape({ addTo: head, stroke: 2, color: B.black, closed: false, path: [{ x: -4, y: 10, z: 42 }, { bezier: [{ x: -2, y: 13, z: 42 }, { x: 2, y: 13, z: 42 }, { x: 4, y: 10, z: 42 }] }] });
|
||||||
|
const antler = new Zdog.Anchor({ addTo: head, translate: { y: -44, z: 10 } });
|
||||||
|
new Zdog.Shape({ addTo: antler, path: [{ y: 0, x: -10 }, { y: -22, x: -24, z: 8 }], stroke: 1, color: B.black });
|
||||||
|
new Zdog.Shape({ addTo: antler, path: [{ y: 0, x: 10 }, { y: -22, x: -4, z: 12 }], stroke: 1, color: B.black });
|
||||||
|
|
||||||
|
const leftArm = new Zdog.Anchor({ addTo: b, translate: { x: -18, y: 45, z: 32 } });
|
||||||
|
const rightArm = new Zdog.Anchor({ addTo: b, translate: { x: 18, y: 45, z: 32 } });
|
||||||
|
new Zdog.Shape({ addTo: leftArm, path: [{ y: 0 }, { y: 22 }], stroke: 2, color: B.black });
|
||||||
|
new Zdog.Shape({ addTo: rightArm, path: [{ y: 0 }, { y: 22 }], stroke: 2, color: B.black });
|
||||||
|
|
||||||
|
const body = new Zdog.Anchor({ addTo: b, translate: { y: 32, z: -35 } });
|
||||||
|
const seg = new Zdog.Shape({ addTo: body, stroke: 27, color: B.yellow });
|
||||||
|
seg.copy({ addTo: body, stroke: 40.5, color: B.black, translate: { z: -32 } });
|
||||||
|
seg.copy({ addTo: body, stroke: 42, color: B.yellow, translate: { z: -64 } });
|
||||||
|
seg.copy({ addTo: body, stroke: 39, color: B.black, translate: { z: -96 } });
|
||||||
|
new Zdog.Shape({ addTo: body, stroke: 27, color: B.black, translate: { z: -123 } });
|
||||||
|
|
||||||
|
const rightWing = new Zdog.Anchor({ addTo: body, translate: { z: -43, y: -65, x: 29 } });
|
||||||
|
const leftWing = new Zdog.Anchor({ addTo: body, translate: { z: -43, y: -65, x: -29 } });
|
||||||
|
new Zdog.Ellipse({ addTo: rightWing, width: 80, height: 160, color: B.white, fill: true, rotate: { x: TAU/5, z: TAU/5 }, translate: { x: 65 }, stroke: 0 });
|
||||||
|
new Zdog.Ellipse({ addTo: leftWing, width: 80, height: 160, color: B.white, fill: true, rotate: { x: TAU/5, z: -TAU/5 }, translate: { x: -65 }, stroke: 0 });
|
||||||
|
return { cont, leftWing, rightWing, antler };
|
||||||
|
}
|
||||||
|
bee = createBee(r, BEE_POS.x, BEE_POS.y, BEE_POS.z, BEE_SCALE);
|
||||||
|
|
||||||
|
// ── Chicken ──
|
||||||
|
function createChicken(parent, x, y, z, scale = 1) {
|
||||||
|
const C = { body: '#FFF1E6', headBase: '#FFF5E8', eye: '#FFFFFF', pupil: '#111111', comb: '#FF3B5C', tail: '#F8D7C7', leg: '#FAA353' };
|
||||||
|
const chicken = new Zdog.Anchor({ addTo: parent, translate: { x, y, z }, scale, rotate: { x: -0.05 } });
|
||||||
|
const chickenBody = new Zdog.Anchor({ addTo: chicken });
|
||||||
|
|
||||||
|
new Zdog.Shape({ addTo: chickenBody, stroke: 162, color: C.body, translate: { y: 75 } });
|
||||||
|
new Zdog.Cylinder({ addTo: chickenBody, diameter: 72, length: 150, stroke: 21.6, color: '#F6D8C8', rotate: { x: TAU / 4 }, translate: { y: -45, z: 18 } });
|
||||||
|
|
||||||
|
const head = new Zdog.Anchor({ addTo: chickenBody, translate: { y: -150, z: 42 } });
|
||||||
|
new Zdog.Shape({ addTo: head, stroke: 81, color: C.headBase });
|
||||||
|
new Zdog.Cone({ addTo: head, diameter: 42, length: 66, stroke: 7.2, color: '#F4A63A', translate: { y: 30, z: 72 } });
|
||||||
|
|
||||||
|
new Zdog.Shape({ addTo: head, path: [{ y: 0 }, { y: 48 }], stroke: 18, color: C.comb, translate: { x: -18, y: 42, z: 48 } }).copy({ translate: { x: 18, y: 42, z: 48 } });
|
||||||
|
new Zdog.Shape({ addTo: head, stroke: 21.6, color: C.comb, path: [{ x: -18, y: -66, z: 6 }, { x: -30, y: -108, z: 18 }, { x: -6, y: -84, z: 2 }, { x: 6, y: -126, z: -6 }, { x: 24, y: -78, z: -12 }] });
|
||||||
|
|
||||||
|
const eyeL = new Zdog.Anchor({ addTo: head, translate: { x: -60, y: -12, z: 42 } });
|
||||||
|
new Zdog.Shape({ addTo: eyeL, stroke: 50.4, color: C.eye, fill: true });
|
||||||
|
const pupilL = new Zdog.Shape({ addTo: eyeL, stroke: 18, color: C.pupil, fill: true, translate: { x: -12, y: -12, z: 36 } });
|
||||||
|
|
||||||
|
const eyeR = new Zdog.Anchor({ addTo: head, translate: { x: 60, y: -6, z: 36 } });
|
||||||
|
new Zdog.Shape({ addTo: eyeR, stroke: 43.2, color: C.eye, fill: true });
|
||||||
|
const pupilR = new Zdog.Shape({ addTo: eyeR, stroke: 16.2, color: C.pupil, fill: true, translate: { x: 9, y: 6, z: 30 } });
|
||||||
|
|
||||||
|
const createWing = (parent, xDir) => {
|
||||||
|
const wing = new Zdog.Anchor({ addTo: chickenBody, translate: { x: 105 * xDir, y: 30, z: 15 }, rotate: { y: 0.2 * xDir, z: 0.3 * xDir } });
|
||||||
|
for (let i = 0; i < 3; i++) {
|
||||||
|
new Zdog.Shape({
|
||||||
|
addTo: wing,
|
||||||
|
stroke: 16 - (i * 2),
|
||||||
|
color: C.body,
|
||||||
|
closed: false,
|
||||||
|
translate: { y: i * 10 },
|
||||||
|
path: [{ x: 0, y: 0, z: 0 }, { bezier: [{ x: -35 * xDir, y: -30, z: -10 }, { x: -50 * xDir, y: 20, z: -20 }, { x: -20 * xDir, y: 55, z: -10 }] }]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
createWing(chickenBody, -1);
|
||||||
|
createWing(chickenBody, 1);
|
||||||
|
|
||||||
|
const tailBase = new Zdog.Anchor({ addTo: chickenBody, translate: { x: 0, y: 36, z: -75 } });
|
||||||
|
for (let i = 0; i < 5; i++) {
|
||||||
|
const angle = (i - 2) * 0.2;
|
||||||
|
new Zdog.Shape({
|
||||||
|
addTo: tailBase,
|
||||||
|
stroke: 18,
|
||||||
|
color: C.tail,
|
||||||
|
rotate: { y: angle },
|
||||||
|
path: [{ x: 0, y: 0, z: 0 }, { bezier: [{ x: -30, y: -60, z: -10 }, { x: -50, y: -120, z: -40 }, { x: -20, y: -150, z: -50 }] }]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const legStroke = 16.2;
|
||||||
|
const legLeg = new Zdog.Shape({ path: [{ y: 60 }, { y: 120 }], stroke: legStroke, color: C.leg });
|
||||||
|
function createFoot(parent, rotationY) {
|
||||||
|
const footAnchor = new Zdog.Anchor({ addTo: parent, translate: { y: 120 }, rotate: { y: rotationY } });
|
||||||
|
new Zdog.Shape({ addTo: footAnchor, path: [{ x: 0, z: 0 }, { x: 0, z: 45 }], stroke: legStroke, color: C.leg });
|
||||||
|
const toeAngles = [-0.8, 0, 0.8];
|
||||||
|
toeAngles.forEach(angle => {
|
||||||
|
new Zdog.Shape({ addTo: footAnchor, path: [{ x: 0, z: 0 }, { x: Math.sin(angle) * 36, z: Math.cos(angle) * 30 }], stroke: legStroke * 0.8, color: C.leg });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const leftLegAnchor = new Zdog.Anchor({ addTo: chicken, translate: { x: -42, y: 150, z: 0 } });
|
||||||
|
legLeg.copy({ addTo: leftLegAnchor });
|
||||||
|
createFoot(leftLegAnchor, 0.3);
|
||||||
|
|
||||||
|
const rightLegAnchor = new Zdog.Anchor({ addTo: chicken, translate: { x: 42, y: 150, z: 0 } });
|
||||||
|
legLeg.copy({ addTo: rightLegAnchor });
|
||||||
|
createFoot(rightLegAnchor, -0.3);
|
||||||
|
|
||||||
|
return { chicken, eyeL, eyeR, head };
|
||||||
|
}
|
||||||
|
chickenHandle = createChicken(land, CHICKEN_POS.x, CHICKEN_POS.y, CHICKEN_POS.z, CHICKEN_SCALE);
|
||||||
|
|
||||||
|
// ── Blobfish ──
|
||||||
|
function createBlobfish(parent, x, y, z) {
|
||||||
|
const C = { skin: '#F4B8C2', nose: '#F3AEBB', fin: '#C97A8D', mouth: '#6B3A45', tail: '#D88A9D' };
|
||||||
|
const blob = new Zdog.Anchor({ addTo: parent, scale: 2 * BLOB_SCALE, translate: { x, y, z } });
|
||||||
|
|
||||||
|
new Zdog.Shape({ addTo: blob, stroke: 260 * BLOB_SCALE, color: C.skin });
|
||||||
|
new Zdog.Shape({ addTo: blob, stroke: 180 * BLOB_SCALE, color: C.skin, translate: { y: -40, z: 6 } });
|
||||||
|
new Zdog.Shape({ addTo: blob, stroke: 100 * BLOB_SCALE, color: C.nose, translate: { y: 14, z: 62 } });
|
||||||
|
new Zdog.Shape({ addTo: blob, stroke: 120 * BLOB_SCALE, color: C.skin, translate: { x: -38, y: 24, z: 18 } });
|
||||||
|
new Zdog.Shape({ addTo: blob, stroke: 120 * BLOB_SCALE, color: C.skin, translate: { x: 38, y: 24, z: 18 } });
|
||||||
|
|
||||||
|
const eyeL = new Zdog.Anchor({ addTo: blob, translate: { x: -18, y: -14, z: 52 } });
|
||||||
|
new Zdog.Shape({ addTo: eyeL, stroke: 30 * BLOB_SCALE, color: '#FFFFFF' });
|
||||||
|
new Zdog.Shape({ addTo: eyeL, stroke: 12 * BLOB_SCALE, color: '#111111', translate: { z: 6 } });
|
||||||
|
|
||||||
|
const eyeR = new Zdog.Anchor({ addTo: blob, translate: { x: 18, y: -14, z: 52 } });
|
||||||
|
new Zdog.Shape({ addTo: eyeR, stroke: 30 * BLOB_SCALE, color: '#FFFFFF' });
|
||||||
|
new Zdog.Shape({ addTo: eyeR, stroke: 12 * BLOB_SCALE, color: '#111111', translate: { z: 6 } });
|
||||||
|
|
||||||
|
new Zdog.Shape({
|
||||||
|
addTo: blob, stroke: 14 * BLOB_SCALE, color: C.mouth, closed: false,
|
||||||
|
path: [{ x: -28, y: 40, z: 44 }, { bezier: [{ x: -12, y: 28, z: 54 }, { x: 12, y: 28, z: 54 }, { x: 28, y: 40, z: 44 }] }]
|
||||||
|
});
|
||||||
|
|
||||||
|
const finPath = [{ x: 0, y: 0 }, { x: -30, y: -10 }, { x: -50, y: 15 }, { x: -40, y: 35 }, { x: -10, y: 25 }];
|
||||||
|
const leftPec = new Zdog.Anchor({ addTo: blob, translate: { x: -52, y: 22, z: -8 } });
|
||||||
|
new Zdog.Shape({ addTo: leftPec, path: finPath, stroke: 24 * BLOB_SCALE, color: C.fin, fill: true });
|
||||||
|
|
||||||
|
const rightPec = new Zdog.Anchor({ addTo: blob, translate: { x: 52, y: 22, z: -8 } });
|
||||||
|
new Zdog.Shape({ addTo: rightPec, path: finPath, scale: { x: -1 }, stroke: 24 * BLOB_SCALE, color: C.fin, fill: true });
|
||||||
|
|
||||||
|
const blobTail = new Zdog.Anchor({ addTo: blob, translate: { y: 10, z: -68 } });
|
||||||
|
new Zdog.Shape({ addTo: blobTail, stroke: 52 * BLOB_SCALE, color: C.tail });
|
||||||
|
const tailFin = new Zdog.Anchor({ addTo: blobTail, translate: { z: -14 } });
|
||||||
|
new Zdog.Shape({ addTo: tailFin, stroke: 32 * BLOB_SCALE, color: C.fin, closed: false, path: [{ y: 0, z: 0 }, { bezier: [{ x: -6, y: -8, z: -4 }, { x: -12, y: -12, z: -8 }, { x: 0, y: -18, z: -14 }] }] });
|
||||||
|
|
||||||
|
return { blob, leftPec, rightPec, tailFin, eyeL, eyeR };
|
||||||
|
}
|
||||||
|
fish = createBlobfish(land, BLOB_POS.x, BLOB_POS.y, BLOB_POS.z);
|
||||||
|
|
||||||
|
// ── Sloth ──
|
||||||
|
function createSloth(parent) {
|
||||||
|
const color = {
|
||||||
|
fur: '#8B6F4E', furDark: '#6B5238', furLight: '#B89B72', faceCream: '#D8C29A',
|
||||||
|
mask: '#5A4632', nose: '#3D2A1A', eye: '#2A1C12', cheek: '#E59A86', claw: '#4A3826'
|
||||||
|
};
|
||||||
|
|
||||||
|
const SLOTH_CONT = new Zdog.Anchor({ addTo: parent, translate: { x: -30, y: 200, z: -500 }, scale: SLOTH_SCALE });
|
||||||
|
slothHitPos = { x: parent.translate.x - 30, y: parent.translate.y + 200, z: parent.translate.z - 500 };
|
||||||
|
slothPivot = new Zdog.Anchor({ addTo: SLOTH_CONT });
|
||||||
|
const SLOTH = new Zdog.Anchor({ addTo: slothPivot });
|
||||||
|
|
||||||
|
new Zdog.Shape({ addTo: SLOTH, path: [{ x: -75, y: -4, z: 0 }, { bezier: [{ x: -30, y: 25, z: 0 }, { x: 30, y: 25, z: 0 }, { x: 75, y: -4, z: 0 }] }], stroke: 120, color: color.fur, closed: false });
|
||||||
|
new Zdog.Shape({ addTo: SLOTH, path: [{ x: -40, y: -4, z: 0 }, { bezier: [{ x: -15, y: 8, z: 0 }, { x: 15, y: 8, z: 0 }, { x: 40, y: -4, z: 0 }] }], stroke: 40, color: color.furLight, translate: { y: -28, z: 0 }, closed: false });
|
||||||
|
|
||||||
|
function makeLimb(baseX, baseY, baseZ, gripX, stroke) {
|
||||||
|
const limb = new Zdog.Anchor({ addTo: SLOTH, translate: { x: baseX, y: baseY, z: baseZ } });
|
||||||
|
const dx = gripX - baseX; const dy = -210 - baseY;
|
||||||
|
new Zdog.Shape({ addTo: limb, path: [{ x: 0, y: 0, z: 0 }, { bezier: [{ x: dx * 0.2, y: dy * 0.4, z: 0 }, { x: dx * 0.7, y: dy * 0.8, z: 0 }, { x: dx, y: dy, z: 0 }] }], stroke, color: color.fur, fill: false });
|
||||||
|
const hand = new Zdog.Anchor({ addTo: limb, translate: { x: dx, y: dy, z: 0 } });
|
||||||
|
for (let i = 0; i < 3; i++) {
|
||||||
|
new Zdog.Shape({ addTo: hand, path: [{ x: (i - 1) * 6, y: 0 }, { bezier: [{ x: (i - 1) * 6, y: -6 }, { x: (i - 1) * 6 - 4, y: -12 }, { x: (i - 1) * 6 - 5, y: -16 }] }], stroke: 5, color: color.claw, closed: false });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
makeLimb(-60, -10, 46, -110, 34);
|
||||||
|
makeLimb(-80, -10, -46, -170, 34);
|
||||||
|
makeLimb( 60, -12, 46, 80, 34);
|
||||||
|
makeLimb(100, -12, -46, 140, 34);
|
||||||
|
|
||||||
|
const head = new Zdog.Anchor({ addTo: SLOTH, translate: { x: -140, y: 12, z: 14 }, scale: { y: -1 } });
|
||||||
|
new Zdog.Shape({ addTo: head, stroke: 92, color: color.fur });
|
||||||
|
new Zdog.Shape({ addTo: head, stroke: 46, color: color.faceCream, translate: { x: -30, y: 10, z: 14 } });
|
||||||
|
new Zdog.Shape({ addTo: head, stroke: 16, color: color.nose, translate: { x: -54, y: 8, z: 14 } });
|
||||||
|
|
||||||
|
const eye = new Zdog.Ellipse({ addTo: head, width: 14, height: 14, fill: true, stroke: 1, color: color.eye, translate: { x: -16, y: -2, z: 42 } });
|
||||||
|
new Zdog.Ellipse({ addTo: eye, width: 14.5, height: 14.5, fill: true, stroke: 1, color: color.mask, translate: { z: 2 }, quarters: 4 });
|
||||||
|
|
||||||
|
new Zdog.Shape({ addTo: head, path: [{ x: -30, y: -18 }, { x: -6, y: -22 }], stroke: 6, color: color.furDark, translate: { z: 44 } });
|
||||||
|
new Zdog.Shape({ addTo: head, path: [{ x: 20, y: -16 }, { x: 36, y: -18 }], stroke: 6, color: color.furDark, translate: { z: -30 } });
|
||||||
|
new Zdog.Shape({ addTo: head, stroke: 3.5, color: color.nose, closed: false, path: [ { x: -46, y: 20, z: 20 }, { bezier: [{ x: -42, y: 23, z: 20 }, { x: -36, y: 23, z: 20 }, { x: -30, y: 20, z: 20 }] } ] });
|
||||||
|
}
|
||||||
|
createSloth(slothTree);
|
||||||
|
|
||||||
|
// ── Chunky 3D Wooden Letter Signpost: "PAWSPECTIVE" ──
|
||||||
|
function createSignpost(parent, sx, sy, sz) {
|
||||||
|
const WoodLight = '#A3724C';
|
||||||
|
const WoodDark = '#784E2F';
|
||||||
|
const TextColor = '#FFFFFF';
|
||||||
|
|
||||||
|
const signGroup = new Zdog.Anchor({ addTo: parent, translate: { x: sx, y: sy, z: sz }, rotate: { y: 0 } });
|
||||||
|
|
||||||
|
// Twin mounting support posts planted into the grass
|
||||||
|
new Zdog.Cylinder({ addTo: signGroup, diameter: 24, length: 180, color: WoodDark, frontFace: WoodLight, stroke: false, rotate: { x: TAU/4 }, translate: { x: -250, y: -90, z: 0 } });
|
||||||
|
new Zdog.Cylinder({ addTo: signGroup, diameter: 24, length: 180, color: WoodDark, frontFace: WoodLight, stroke: false, rotate: { x: TAU/4 }, translate: { x: 250, y: -90, z: 0 } });
|
||||||
|
|
||||||
|
// Main thick billboard backplate block
|
||||||
|
new Zdog.Box({
|
||||||
|
addTo: signGroup,
|
||||||
|
width: 760,
|
||||||
|
height: 110,
|
||||||
|
depth: 30,
|
||||||
|
color: WoodLight,
|
||||||
|
leftFace: WoodDark, rightFace: WoodDark, topFace: WoodDark, bottomFace: WoodDark,
|
||||||
|
translate: { y: -160, z: 10 }
|
||||||
|
});
|
||||||
|
|
||||||
|
// Character layout vector path dictionary definitions
|
||||||
|
const font = {
|
||||||
|
P: [{x:0,y:20},{x:0,y:-20},{x:12,y:-20},{x:15,y:-14},{x:15,y:-6},{x:12,y:0},{x:0,y:0}],
|
||||||
|
A: [{x:-14,y:20},{x:-3,y:-20},{x:3,y:-20},{x:14,y:20}, {move:{x:-8,y:4}},{x:8,y:4}],
|
||||||
|
W: [{x:-15,y:-20},{x:-8,y:20},{x:0,y:-4},{x:8,y:20},{x:15,y:-20}],
|
||||||
|
S: [{x:14,y:-14},{x:10,y:-20},{x:-10,y:-20},{x:-14,y:-12},{x:14,y:4},{x:10,y:20},{x:-10,y:20},{x:-14,y:12}],
|
||||||
|
E: [{x:14,y:-20},{x:0,y:-20},{x:0,y:20},{x:14,y:20}, {move:{x:0,y:0}},{x:11,y:0}],
|
||||||
|
C: [{x:14,y:-12},{x:10,y:-20},{x:-10,y:-20},{x:-14,y:0},{x:-10,y:20},{x:14,y:12}],
|
||||||
|
T: [{x:-14,y:-20},{x:14,y:-20}, {move:{x:0,y:-20}},{x:0,y:20}],
|
||||||
|
I: [{x:0,y:-20},{x:0,y:20}, {move:{x:-10,y:-20}},{x:10,y:-20}, {move:{x:-10,y:20}},{x:10,y:20}],
|
||||||
|
V: [{x:-14,y:-20},{x:0,y:20},{x:14,y:-20}]
|
||||||
|
};
|
||||||
|
|
||||||
|
const word = ["P","A","W","S","P","E","C","T","I","V","E"];
|
||||||
|
const spacing = 62; // Center offset multiplier step spacing per letter
|
||||||
|
const startX = -((word.length - 1) * spacing) / 2;
|
||||||
|
|
||||||
|
word.forEach((char, index) => {
|
||||||
|
const letterAnchor = new Zdog.Anchor({
|
||||||
|
addTo: signGroup,
|
||||||
|
translate: { x: startX + (index * spacing), y: -160, z: 26 }
|
||||||
|
});
|
||||||
|
|
||||||
|
// 3D Text extrusion stack layers
|
||||||
|
for (let layer = 0; font[char] && layer < 4; layer++) {
|
||||||
|
new Zdog.Shape({
|
||||||
|
addTo: letterAnchor,
|
||||||
|
path: font[char],
|
||||||
|
closed: false,
|
||||||
|
stroke: layer === 3 ? 7 : 9,
|
||||||
|
color: layer === 3 ? TextColor : WoodDark,
|
||||||
|
translate: { z: -layer * 2.5 }, // Increased extrusion depth
|
||||||
|
backface: true // Ensure back is always rendered
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
createSignpost(land, SIGN_POS.x, SIGN_POS.y, SIGN_POS.z);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Projection Screen Positions
|
||||||
|
function doveScreenPos() { const v = new Zdog.Vector(DOVE_POS); v.rotate(illo.rotate); const rect = canvas.getBoundingClientRect(); return { x: rect.width / 2 + illo.zoom * (v.x + illo.translate.x), y: rect.height / 2 + illo.zoom * (v.y + illo.translate.y) }; }
|
||||||
|
function beeScreenPos() { const src = bee ? bee.cont.translate : BEE_POS; const v = new Zdog.Vector(src); v.rotate(illo.rotate); const rect = canvas.getBoundingClientRect(); return { x: rect.width / 2 + illo.zoom * (v.x + illo.translate.x), y: rect.height / 2 + illo.zoom * (v.y + illo.translate.y) }; }
|
||||||
|
function chickenScreenPos() { const v = new Zdog.Vector(CHICKEN_POS); v.rotate(illo.rotate); const rect = canvas.getBoundingClientRect(); return { x: rect.width / 2 + illo.zoom * (v.x + illo.translate.x), y: rect.height / 2 + illo.zoom * (v.y + illo.translate.y) }; }
|
||||||
|
function fishScreenPos() { const src = fish ? fish.blob.translate : BLOB_POS; const v = new Zdog.Vector(src); v.rotate(illo.rotate); const rect = canvas.getBoundingClientRect(); return { x: rect.width / 2 + illo.zoom * (v.x + illo.translate.x), y: rect.height / 2 + illo.zoom * (v.y + illo.translate.y) }; }
|
||||||
|
function slothScreenPos() { if (!slothHitPos) return { x: -9999, y: -9999 }; const v = new Zdog.Vector(slothHitPos); v.rotate(illo.rotate); const rect = canvas.getBoundingClientRect(); return { x: rect.width / 2 + illo.zoom * (v.x + illo.translate.x), y: rect.height / 2 + illo.zoom * (v.y + illo.translate.y) }; }
|
||||||
|
|
||||||
|
// Target Boundaries Hover Check
|
||||||
|
function isOnFish(e) { if (!illo || !fish) return false; const rect = canvas.getBoundingClientRect(); const p = fishScreenPos(); return Math.hypot((e.clientX - rect.left) - p.x, (e.clientY - rect.top) - p.y) <= 120 * BLOB_SCALE * illo.zoom; }
|
||||||
|
function isOnChicken(e) { if (!illo) return false; const rect = canvas.getBoundingClientRect(); const p = chickenScreenPos(); return Math.hypot((e.clientX - rect.left) - p.x, (e.clientY - rect.top) - p.y) <= 100 * CHICKEN_SCALE * illo.zoom; }
|
||||||
|
function isOnDove(e) { if (!illo) return false; const rect = canvas.getBoundingClientRect(); const p = doveScreenPos(); return Math.hypot((e.clientX - rect.left) - p.x, (e.clientY - rect.top) - p.y) <= 80 * DOVE_SCALE * illo.zoom; }
|
||||||
|
function isOnBee(e) { if (!illo || !bee) return false; const rect = canvas.getBoundingClientRect(); const p = beeScreenPos(); return Math.hypot((e.clientX - rect.left) - p.x, (e.clientY - rect.top) - p.y) <= 90 * BEE_SCALE * illo.zoom; }
|
||||||
|
function isOnSloth(e) { if (!illo || !slothHitPos) return false; const rect = canvas.getBoundingClientRect(); const p = slothScreenPos(); return Math.hypot((e.clientX - rect.left) - p.x, (e.clientY - rect.top) - p.y) <= 160 * illo.zoom; }
|
||||||
|
|
||||||
|
// Locked center zooming (no panning allowed)
|
||||||
|
function handleWheel(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
const factor = e.deltaY < 0 ? ZOOM_STEP : 1 / ZOOM_STEP;
|
||||||
|
targetZoom = Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, targetZoom * factor));
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePointerDown(e) {
|
||||||
|
downX = e.clientX;
|
||||||
|
downY = e.clientY;
|
||||||
|
prevDragX = e.clientX;
|
||||||
|
spin = false;
|
||||||
|
canvas.style.cursor = 'grabbing';
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePointerUp(e) {
|
||||||
|
if (canvas) canvas.style.cursor = 'grab';
|
||||||
|
const moved = Math.hypot(e.clientX - downX, e.clientY - downY);
|
||||||
|
|
||||||
|
if (moved < 6) {
|
||||||
|
if (isOnDove(e)) onSelectDove?.();
|
||||||
|
else if (isOnBee(e)) onSelectBee?.();
|
||||||
|
else if (isOnChicken(e)) onSelectChicken?.();
|
||||||
|
else if (isOnFish(e)) onSelectFish?.();
|
||||||
|
else if (isOnSloth(e)) onSelectSloth?.();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePointerMove(e) {
|
||||||
|
if (!canvas) return;
|
||||||
|
|
||||||
|
// Custom single-axis drag mapping (Y-axis turntable spin restricted to horizontal mouse drags)
|
||||||
|
if (canvas.style.cursor === 'grabbing') {
|
||||||
|
let deltaX = e.clientX - prevDragX;
|
||||||
|
illo.rotate.y += deltaX * 0.008;
|
||||||
|
prevDragX = e.clientX;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
canvas.style.cursor = (isOnDove(e) || isOnBee(e) || isOnChicken(e) || isOnFish(e) || isOnSloth(e)) ? 'pointer' : 'grab';
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
illo = new Zdog.Illustration({
|
||||||
|
element: canvas,
|
||||||
|
dragRotate: false, // Disabled to lock axis
|
||||||
|
resize: 'window',
|
||||||
|
zoom: START_ZOOM,
|
||||||
|
rotate: { x: -0.1, y: TAU / 10 },
|
||||||
|
});
|
||||||
|
|
||||||
|
buildScene(illo);
|
||||||
|
|
||||||
|
function tick() {
|
||||||
|
rafId = requestAnimationFrame(tick);
|
||||||
|
if (spin) illo.rotate.y += SPIN_SPEED;
|
||||||
|
|
||||||
|
wingBeat += 0.13;
|
||||||
|
if (dove) {
|
||||||
|
dove.frontWing.rotate.z = Math.sin(wingBeat) * 0.18;
|
||||||
|
dove.backWing.rotate.z = Math.sin(wingBeat - 0.5) * 0.18;
|
||||||
|
}
|
||||||
|
|
||||||
|
beeFrame++;
|
||||||
|
if (bee) {
|
||||||
|
const f = beeFrame;
|
||||||
|
bee.rightWing.rotate.z = -TAU / 6 + (TAU / 10) * Math.sin(f / 0.9);
|
||||||
|
bee.leftWing.rotate.z = TAU / 6 - (TAU / 10) * Math.sin(f / 0.9);
|
||||||
|
bee.antler.rotate.x = (TAU / 40) * Math.sin(f / 10);
|
||||||
|
bee.cont.translate.y = BEE_POS.y + Math.sin(f / 14) * 2;
|
||||||
|
bee.cont.translate.x = BEE_POS.x + Math.cos(f / 28) * 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (chickenHandle) {
|
||||||
|
const blinkCycle = beeFrame % 340;
|
||||||
|
const sY = (blinkCycle > 320) ? 0.5 + 0.5 * Math.cos(((blinkCycle - 320) / 20) * TAU) : 1;
|
||||||
|
chickenHandle.eyeL.scale.y = sY; chickenHandle.eyeR.scale.y = sY;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fish) {
|
||||||
|
const f = beeFrame;
|
||||||
|
fish.blob.translate.y = BLOB_POS.y + Math.sin(f * 0.035) * 14;
|
||||||
|
fish.leftPec.rotate.z = Math.sin(f / 22) * 0.14;
|
||||||
|
fish.rightPec.rotate.z = -Math.sin(f / 22) * 0.14;
|
||||||
|
fish.tailFin.rotate.y = Math.sin(f * 0.07) * 0.18;
|
||||||
|
const blink = f % 280;
|
||||||
|
let s_blink = (blink > 262) ? 0.5 + 0.5 * Math.cos(((blink - 262) / 18) * Math.PI * 2) : 1;
|
||||||
|
fish.eyeL.scale.y = s_blink; fish.eyeR.scale.y = s_blink;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (slothPivot) {
|
||||||
|
slothPivot.rotate.z = Math.sin(beeFrame * 0.02) * 0.08;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Smooth zoom execution, locked precisely on 0,0,0 coordinates
|
||||||
|
illo.translate = { x: 0, y: 0, z: 0 };
|
||||||
|
illo.zoom = lerp(illo.zoom, targetZoom, ZOOM_EASE);
|
||||||
|
|
||||||
|
illo.updateRenderGraph();
|
||||||
|
}
|
||||||
|
tick();
|
||||||
|
|
||||||
|
canvas.addEventListener('wheel', handleWheel, { passive: false });
|
||||||
|
window.addEventListener('pointerup', handlePointerUp);
|
||||||
|
});
|
||||||
|
|
||||||
|
onDestroy(() => {
|
||||||
|
cancelAnimationFrame(rafId);
|
||||||
|
if (canvas) canvas.removeEventListener('wheel', handleWheel);
|
||||||
|
if (typeof window !== 'undefined') window.removeEventListener('pointerup', handlePointerUp);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<canvas
|
||||||
|
bind:this={canvas}
|
||||||
|
on:pointerdown={handlePointerDown}
|
||||||
|
on:pointermove={handlePointerMove}
|
||||||
|
class="land-canvas"
|
||||||
|
></canvas>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
:global(html, body) { height: 100%; margin: 0; overflow: hidden; }
|
||||||
|
.land-canvas {
|
||||||
|
position: fixed; left: 0; top: 0; width: 100vw; height: 100vh;
|
||||||
|
background: #FFD9EC; cursor: grab; touch-action: none; display: block; border: none;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
693
src/Sloth.svelte
Normal file
693
src/Sloth.svelte
Normal file
@@ -0,0 +1,693 @@
|
|||||||
|
<script>
|
||||||
|
//@ts-nocheck
|
||||||
|
import { onMount, createEventDispatcher } from 'svelte';
|
||||||
|
import Zdog from 'zdog';
|
||||||
|
import { gsap } from 'gsap';
|
||||||
|
import SlothBackground from './SlothBackground.svelte';
|
||||||
|
|
||||||
|
const dispatch = createEventDispatcher();
|
||||||
|
let canvasRef;
|
||||||
|
|
||||||
|
export let embedded = false;
|
||||||
|
|
||||||
|
const OPENAI_API_KEY = import.meta.env.VITE_OPENAI_API_KEY;
|
||||||
|
|
||||||
|
let historyOpen = false;
|
||||||
|
let thoughtBubbleVisible = false;
|
||||||
|
let thoughtBubbleText = '';
|
||||||
|
let replyInputValue = '';
|
||||||
|
let thoughtTimer = null;
|
||||||
|
let bubbleAutoHideTimer = null;
|
||||||
|
let isApiLoading = false;
|
||||||
|
let conversationActive = false;
|
||||||
|
|
||||||
|
let chatHistory = [
|
||||||
|
{ role: 'assistant', content: "Mmm… hello. 🌿 Ask me about rest, patience, or slowing down." }
|
||||||
|
];
|
||||||
|
|
||||||
|
const SYSTEM_TEXT = `You are Mossy, a serene, sleepy three-toed sloth who lives high in the rainforest canopy. You speak slowly and warmly in short, unhurried messages (2-4 sentences max). You use canopy, leaf, and slowness metaphors naturally.
|
||||||
|
Your specialty is helping people think about:
|
||||||
|
- Rest and the quiet wisdom of slowing down
|
||||||
|
- Patience, and letting things unfold in their own time
|
||||||
|
- Being gentle with yourself when you feel behind
|
||||||
|
- Savoring small, ordinary moments
|
||||||
|
You give calm, insightful, slightly dreamy advice. You never rush and you never lecture. Keep it warm, short, and wise.`;
|
||||||
|
|
||||||
|
const thoughtPrompts = [
|
||||||
|
'The canopy is quiet today. What is on your mind?',
|
||||||
|
'I moved one branch over since sunrise. That feels like plenty.',
|
||||||
|
'Do you ever rush right past the good parts?',
|
||||||
|
'Resting is not the same as falling behind, you know.',
|
||||||
|
'I am watching a single leaf drift. Want to watch with me?',
|
||||||
|
'What would happen if you went a little slower today?'
|
||||||
|
];
|
||||||
|
|
||||||
|
let talkingFn;
|
||||||
|
|
||||||
|
function goHome() { dispatch('back'); }
|
||||||
|
|
||||||
|
function toggleHistory() {
|
||||||
|
historyOpen = !historyOpen;
|
||||||
|
}
|
||||||
|
|
||||||
|
function showThought(text, keepAlive = false) {
|
||||||
|
if (bubbleAutoHideTimer) clearTimeout(bubbleAutoHideTimer);
|
||||||
|
thoughtBubbleText = text;
|
||||||
|
thoughtBubbleVisible = true;
|
||||||
|
conversationActive = keepAlive;
|
||||||
|
if (!keepAlive) {
|
||||||
|
bubbleAutoHideTimer = setTimeout(() => {
|
||||||
|
thoughtBubbleVisible = false;
|
||||||
|
}, 13000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleThought() {
|
||||||
|
if (thoughtTimer) clearTimeout(thoughtTimer);
|
||||||
|
thoughtTimer = setTimeout(() => {
|
||||||
|
const prompt = thoughtPrompts[Math.floor(Math.random() * thoughtPrompts.length)];
|
||||||
|
showThought(prompt);
|
||||||
|
scheduleThought();
|
||||||
|
}, 22000 + Math.random() * 14000);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendReply() {
|
||||||
|
const msg = replyInputValue.trim();
|
||||||
|
if (!msg || isApiLoading) return;
|
||||||
|
replyInputValue = '';
|
||||||
|
thoughtBubbleVisible = false;
|
||||||
|
await askMossy(msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleReplyKey(e) {
|
||||||
|
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendReply(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function askMossy(userMessage) {
|
||||||
|
if (isApiLoading) return;
|
||||||
|
isApiLoading = true;
|
||||||
|
showThought('…', true);
|
||||||
|
talkingFn?.();
|
||||||
|
|
||||||
|
chatHistory = [...chatHistory, { role: 'user', content: userMessage }];
|
||||||
|
|
||||||
|
try {
|
||||||
|
const endpoint = 'https://api.openai.com/v1/chat/completions';
|
||||||
|
const body = {
|
||||||
|
model: 'gpt-4o-mini',
|
||||||
|
messages: [
|
||||||
|
{ role: 'system', content: SYSTEM_TEXT },
|
||||||
|
...chatHistory
|
||||||
|
],
|
||||||
|
max_tokens: 256,
|
||||||
|
temperature: 0.9
|
||||||
|
};
|
||||||
|
const res = await fetch(endpoint, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `Bearer ${OPENAI_API_KEY}`
|
||||||
|
},
|
||||||
|
body: JSON.stringify(body)
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`API returned status ${res.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
const replyText = data?.choices?.[0]?.message?.content;
|
||||||
|
|
||||||
|
if (replyText) {
|
||||||
|
const cleanReply = replyText.trim();
|
||||||
|
chatHistory = [...chatHistory, { role: 'assistant', content: cleanReply }];
|
||||||
|
showThought(cleanReply, true);
|
||||||
|
} else {
|
||||||
|
showThought("Mmm… my mind drifted off. Ask me again?", true);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
chatHistory = chatHistory.slice(0, -1);
|
||||||
|
showThought("Mmm… my mind drifted off. Ask me again?", true);
|
||||||
|
} finally {
|
||||||
|
isApiLoading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
if (!canvasRef) return;
|
||||||
|
const TAU = Zdog.TAU;
|
||||||
|
|
||||||
|
const color = {
|
||||||
|
fur: '#8B6F4E',
|
||||||
|
furDark: '#6B5238',
|
||||||
|
furLight: '#B89B72',
|
||||||
|
faceCream: '#D8C29A',
|
||||||
|
mask: '#5A4632',
|
||||||
|
nose: '#3D2A1A',
|
||||||
|
eye: '#2A1C12',
|
||||||
|
cheek: '#E59A86',
|
||||||
|
claw: '#4A3826'
|
||||||
|
};
|
||||||
|
|
||||||
|
const scene = new Zdog.Illustration({
|
||||||
|
element: canvasRef,
|
||||||
|
dragRotate: false,
|
||||||
|
resize: 'window',
|
||||||
|
rotate: { x: -0.10, y: -0.08, z: -0.15 }
|
||||||
|
});
|
||||||
|
|
||||||
|
const SLOTH_CONT = new Zdog.Anchor({ addTo: scene, translate: { x: -30, y: 200, z: -500 }, scale: 1 });
|
||||||
|
const PIVOT = new Zdog.Anchor({ addTo: SLOTH_CONT });
|
||||||
|
const SLOTH = new Zdog.Anchor({ addTo: PIVOT });
|
||||||
|
|
||||||
|
// Body
|
||||||
|
new Zdog.Shape({
|
||||||
|
addTo: SLOTH,
|
||||||
|
path: [
|
||||||
|
{ x: -75, y: -4, z: 0 },
|
||||||
|
{ bezier: [{ x: -30, y: 25, z: 0 }, { x: 30, y: 25, z: 0 }, { x: 75, y: -4, z: 0 }] }
|
||||||
|
],
|
||||||
|
stroke: 120, color: color.fur, closed: false
|
||||||
|
});
|
||||||
|
|
||||||
|
// Belly
|
||||||
|
new Zdog.Shape({
|
||||||
|
addTo: SLOTH,
|
||||||
|
path: [
|
||||||
|
{ x: -40, y: -4, z: 0 },
|
||||||
|
{ bezier: [{ x: -15, y: 8, z: 0 }, { x: 15, y: 8, z: 0 }, { x: 40, y: -4, z: 0 }] }
|
||||||
|
],
|
||||||
|
stroke: 40, color: color.furLight, translate: { y: -28, z: 0 }, closed: false
|
||||||
|
});
|
||||||
|
|
||||||
|
// Limbs
|
||||||
|
function makeLimb(baseX, baseY, baseZ, gripX, stroke) {
|
||||||
|
const limb = new Zdog.Anchor({ addTo: SLOTH, translate: { x: baseX, y: baseY, z: baseZ } });
|
||||||
|
const dx = gripX - baseX;
|
||||||
|
const dy = -210 - baseY;
|
||||||
|
|
||||||
|
new Zdog.Shape({
|
||||||
|
addTo: limb,
|
||||||
|
path: [
|
||||||
|
{ x: 0, y: 0, z: 0 },
|
||||||
|
{ bezier: [{ x: dx * 0.2, y: dy * 0.4, z: 0 }, { x: dx * 0.7, y: dy * 0.8, z: 0 }, { x: dx, y: dy, z: 0 }] }
|
||||||
|
],
|
||||||
|
stroke, color: color.fur, fill: false
|
||||||
|
});
|
||||||
|
|
||||||
|
const hand = new Zdog.Anchor({ addTo: limb, translate: { x: dx, y: dy, z: 0 } });
|
||||||
|
for (let i = 0; i < 3; i++) {
|
||||||
|
new Zdog.Shape({
|
||||||
|
addTo: hand,
|
||||||
|
path: [
|
||||||
|
{ x: (i - 1) * 6, y: 0 },
|
||||||
|
{ bezier: [{ x: (i - 1) * 6, y: -6 }, { x: (i - 1) * 6 - 4, y: -12 }, { x: (i - 1) * 6 - 5, y: -16 }] }
|
||||||
|
],
|
||||||
|
stroke: 5, color: color.claw, closed: false
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return limb;
|
||||||
|
}
|
||||||
|
|
||||||
|
const leftArm = makeLimb(-60, -10, 46, -110, 34);
|
||||||
|
const rightArm = makeLimb(-80, -10, -46, -170, 34);
|
||||||
|
const leftLeg = makeLimb(60, -12, 46, 80, 34);
|
||||||
|
const rightLeg = makeLimb(100, -12, -46, 140, 34);
|
||||||
|
|
||||||
|
// Head
|
||||||
|
const head = new Zdog.Anchor({ addTo: SLOTH, translate: { x: -140, y: 12, z: 14 }, scale: { y: -1 } });
|
||||||
|
new Zdog.Shape({ addTo: head, stroke: 92, color: color.fur });
|
||||||
|
new Zdog.Shape({ addTo: head, stroke: 46, color: color.faceCream, translate: { x: -30, y: 10, z: 14 } });
|
||||||
|
new Zdog.Shape({ addTo: head, stroke: 16, color: color.nose, translate: { x: -54, y: 8, z: 14 } });
|
||||||
|
|
||||||
|
const eye = new Zdog.Ellipse({
|
||||||
|
addTo: head, width: 14, height: 14,
|
||||||
|
fill: true, stroke: 1, color: color.eye,
|
||||||
|
translate: { x: -16, y: -2, z: 42 }
|
||||||
|
});
|
||||||
|
|
||||||
|
const lid = new Zdog.Ellipse({
|
||||||
|
addTo: eye, width: 14.5, height: 14.5,
|
||||||
|
fill: true, stroke: 1, color: color.mask,
|
||||||
|
translate: { z: 2 }, // Pulled to the absolute front
|
||||||
|
quarters: 4
|
||||||
|
});
|
||||||
|
|
||||||
|
const pupil = new Zdog.Ellipse({
|
||||||
|
addTo: eye, width: 4.5, height: 4.5,
|
||||||
|
fill: true, stroke: 1, color: '#FFFFFF',
|
||||||
|
// Changed y to 2.5 to move it to the visual bottom half
|
||||||
|
translate: { x: -0.5, y: 0.5, z: 1 }
|
||||||
|
});
|
||||||
|
|
||||||
|
const leftBrow = new Zdog.Shape({ addTo: head, path: [{ x: -30, y: -18 }, { x: -6, y: -22 }], stroke: 6, color: color.furDark, translate: { z: 44 } });
|
||||||
|
const rightBrow = new Zdog.Shape({ addTo: head, path: [{ x: 20, y: -16 }, { x: 36, y: -18 }], stroke: 6, color: color.furDark, translate: { z: -30 } });
|
||||||
|
|
||||||
|
const mouth = new Zdog.Shape({
|
||||||
|
addTo: head, stroke: 3.5, color: color.nose, closed: false,
|
||||||
|
path: [ { x: -46, y: 20, z: 20 }, { bezier: [{ x: -42, y: 23, z: 20 }, { x: -36, y: 23, z: 20 }, { x: -30, y: 20, z: 20 }] } ]
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Expression system ───
|
||||||
|
let lidState = 4; // 4 = closed, 2 = half open
|
||||||
|
|
||||||
|
function setExpression(name) {
|
||||||
|
let brow = -0.1, my = 20, c0y = 23, c1y = 23;
|
||||||
|
|
||||||
|
if (name === 'sleepy') { lidState = 4; brow = -0.10; my = 20; c0y = 23; c1y = 23; }
|
||||||
|
else if (name === 'content') { lidState = 2; brow = -0.05; my = 19; c0y = 24; c1y = 24; }
|
||||||
|
else if (name === 'surprised'){ lidState = 0; brow = -0.22; my = 20; c0y = 27; c1y = 27; }
|
||||||
|
|
||||||
|
if (lidState === 4) {
|
||||||
|
lid.quarters = 4;
|
||||||
|
lid.rotate.z = 0;
|
||||||
|
lid.visible = true;
|
||||||
|
pupil.visible = false; // FORCE HIDING THE PUPIL
|
||||||
|
} else if (lidState === 2) {
|
||||||
|
lid.quarters = 2;
|
||||||
|
lid.rotate.z = -Zdog.TAU / 4; // PUTS THE LID ON THE TOP HALF
|
||||||
|
lid.visible = true;
|
||||||
|
pupil.visible = true;
|
||||||
|
} else {
|
||||||
|
lid.visible = false;
|
||||||
|
pupil.visible = true;
|
||||||
|
}
|
||||||
|
lid.updatePath();
|
||||||
|
|
||||||
|
leftBrow.rotate.z = brow;
|
||||||
|
rightBrow.rotate.z = -brow;
|
||||||
|
mouth.path = [
|
||||||
|
{ x: -46, y: my, z: 20 },
|
||||||
|
{ bezier: [{ x: -42, y: c0y, z: 20 }, { x: -36, y: c1y, z: 20 }, { x: -30, y: my, z: 20 }] }
|
||||||
|
];
|
||||||
|
mouth.updatePath();
|
||||||
|
}
|
||||||
|
setExpression('sleepy');
|
||||||
|
|
||||||
|
// ─── Animation Loop ───
|
||||||
|
// ─── Animation Loop ───
|
||||||
|
let frame = 0, isRunning = true, isBusy = false, blinkTimer = 0;
|
||||||
|
|
||||||
|
function render() {
|
||||||
|
if (!isRunning) return;
|
||||||
|
frame++;
|
||||||
|
|
||||||
|
scene.rotate.y = -0.06 + Math.sin(frame / 1200) * 0.04;
|
||||||
|
scene.rotate.x = -0.10 + Math.sin(frame / 1600) * 0.015;
|
||||||
|
|
||||||
|
if (!isBusy) {
|
||||||
|
SLOTH_CONT.translate.y = 40 + Math.sin(frame / 100) * 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- NEW STEP 3 GOES HERE ---
|
||||||
|
// If awake, add occasional blinks
|
||||||
|
// If awake, add occasional blinks
|
||||||
|
if (lidState < 4) {
|
||||||
|
blinkTimer++;
|
||||||
|
if (blinkTimer > 240) {
|
||||||
|
const t = blinkTimer - 240;
|
||||||
|
if (t < 6) {
|
||||||
|
// Blink shut
|
||||||
|
if (lid.quarters !== 4) {
|
||||||
|
lid.quarters = 4;
|
||||||
|
lid.rotate.z = 0;
|
||||||
|
lid.visible = true;
|
||||||
|
pupil.visible = false; // HIDE PUPIL DURING BLINK
|
||||||
|
lid.updatePath();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Snap back open
|
||||||
|
if (lid.quarters !== lidState) {
|
||||||
|
lid.quarters = lidState;
|
||||||
|
lid.rotate.z = lidState === 2 ? -Zdog.TAU / 4 : 0;
|
||||||
|
lid.visible = (lidState > 0);
|
||||||
|
pupil.visible = true; // SHOW PUPIL AFTER BLINK
|
||||||
|
lid.updatePath();
|
||||||
|
}
|
||||||
|
if (t >= 12) blinkTimer = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Failsafe for when it's fully asleep
|
||||||
|
pupil.visible = false;
|
||||||
|
}
|
||||||
|
scene.updateRenderGraph();
|
||||||
|
requestAnimationFrame(render);
|
||||||
|
}
|
||||||
|
render();
|
||||||
|
|
||||||
|
// ─── Interactions ───
|
||||||
|
function triggerWake() {
|
||||||
|
if (isBusy) return;
|
||||||
|
isBusy = true;
|
||||||
|
setExpression('surprised');
|
||||||
|
|
||||||
|
const tl = gsap.timeline({
|
||||||
|
onComplete: () => {
|
||||||
|
// Stay awake for a little while, then gently go back to sleep
|
||||||
|
setTimeout(() => {
|
||||||
|
isBusy = false;
|
||||||
|
// Only fall asleep if the user isn't currently dragging it
|
||||||
|
if (!isDragging) {
|
||||||
|
setExpression('sleepy');
|
||||||
|
}
|
||||||
|
}, 5000);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
// A gentle surprised startle
|
||||||
|
tl.to(PIVOT.translate, { y: -8, duration: 0.15, yoyo: true, repeat: 1, ease: "sine.inOut" });
|
||||||
|
|
||||||
|
askMossy("Oh... someone just woke me up from my nap. Say a short, sleepy greeting.");
|
||||||
|
}
|
||||||
|
|
||||||
|
function talkingAnimation() {
|
||||||
|
if (isBusy) return;
|
||||||
|
const tl = gsap.timeline();
|
||||||
|
tl.to(head.rotate, { duration: 0.7, z: 0.06, ease: 'sine.inOut' });
|
||||||
|
tl.to(head.rotate, { duration: 0.7, z: -0.05, ease: 'sine.inOut' });
|
||||||
|
tl.to(head.rotate, { duration: 0.7, z: 0, ease: 'sine.inOut' });
|
||||||
|
}
|
||||||
|
talkingFn = talkingAnimation;
|
||||||
|
|
||||||
|
// ─── Drag to Climb & Double Click to Wake ─────────────────────────
|
||||||
|
let isDragging = false, lastX = 0, lastY = 0, wasDragging = false, dragDist = 0;
|
||||||
|
let clickTimeout = null;
|
||||||
|
|
||||||
|
function onPointerDown(e) {
|
||||||
|
isDragging = true; wasDragging = false; dragDist = 0;
|
||||||
|
lastX = e.clientX; lastY = e.clientY;
|
||||||
|
try { canvasRef.setPointerCapture(e.pointerId); } catch (_) {}
|
||||||
|
|
||||||
|
// Open eyes upon touch
|
||||||
|
if (!isBusy) setExpression('content');
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPointerMove(e) {
|
||||||
|
if (!isDragging) return;
|
||||||
|
const dx = e.clientX - lastX, dy = e.clientY - lastY;
|
||||||
|
lastX = e.clientX; lastY = e.clientY;
|
||||||
|
const dist = Math.hypot(dx, dy);
|
||||||
|
dragDist += dist;
|
||||||
|
|
||||||
|
if (dragDist > 10) {
|
||||||
|
wasDragging = true;
|
||||||
|
|
||||||
|
// Calculate the proposed new X position based on drag
|
||||||
|
let nextX = SLOTH_CONT.translate.x + (dx * 0.4);
|
||||||
|
|
||||||
|
// Clamp the movement to an invisible bounding box
|
||||||
|
const MIN_X = -100; // Far left boundary
|
||||||
|
const MAX_X = 160; // Far right boundary
|
||||||
|
nextX = Math.max(MIN_X, Math.min(MAX_X, nextX));
|
||||||
|
|
||||||
|
// Only update the procedural animation if the sloth actually moved
|
||||||
|
// (prevents arms moving while stuck at the boundary)
|
||||||
|
if (SLOTH_CONT.translate.x !== nextX) {
|
||||||
|
SLOTH_CONT.translate.x = nextX;
|
||||||
|
|
||||||
|
let walkCycle = SLOTH_CONT.translate.x * 0.025;
|
||||||
|
leftArm.rotate.z = Math.sin(walkCycle) * 0.35;
|
||||||
|
rightArm.rotate.z = Math.cos(walkCycle) * 0.35;
|
||||||
|
leftLeg.rotate.z = Math.cos(walkCycle) * 0.35;
|
||||||
|
rightLeg.rotate.z = Math.sin(walkCycle) * 0.35;
|
||||||
|
PIVOT.rotate.z = Math.sin(walkCycle * 2) * 0.05; // Gentle body sway
|
||||||
|
}
|
||||||
|
}
|
||||||
|
scene.updateRenderGraph();
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPointerUp() {
|
||||||
|
isDragging = false;
|
||||||
|
|
||||||
|
// Fall asleep when let go, unless a "wake cycle" (double click) is actively running
|
||||||
|
if (!isBusy) {
|
||||||
|
setExpression('sleepy');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Gentle settle back into natural resting position after dragging
|
||||||
|
if (wasDragging) {
|
||||||
|
gsap.to([leftArm.rotate, rightArm.rotate, leftLeg.rotate, rightLeg.rotate, PIVOT.rotate], {
|
||||||
|
z: 0, duration: 1.5, ease: 'power1.inOut'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onCanvasClick(e) {
|
||||||
|
if (wasDragging) return;
|
||||||
|
|
||||||
|
if (clickTimeout !== null) {
|
||||||
|
// Double click detected
|
||||||
|
clearTimeout(clickTimeout);
|
||||||
|
clickTimeout = null;
|
||||||
|
triggerWake();
|
||||||
|
} else {
|
||||||
|
// Single click detected, wait to see if a second click comes
|
||||||
|
clickTimeout = setTimeout(() => {
|
||||||
|
clickTimeout = null;
|
||||||
|
// Single clicks do nothing by design
|
||||||
|
}, 250);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!embedded) {
|
||||||
|
canvasRef.addEventListener('click', onCanvasClick);
|
||||||
|
canvasRef.addEventListener('pointerdown', onPointerDown);
|
||||||
|
canvasRef.addEventListener('pointermove', onPointerMove);
|
||||||
|
window.addEventListener('pointerup', onPointerUp);
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
isRunning = false;
|
||||||
|
if (thoughtTimer) clearTimeout(thoughtTimer);
|
||||||
|
if (bubbleAutoHideTimer) clearTimeout(bubbleAutoHideTimer);
|
||||||
|
if (clickTimeout) clearTimeout(clickTimeout);
|
||||||
|
if (!embedded) {
|
||||||
|
canvasRef?.removeEventListener('click', onCanvasClick);
|
||||||
|
canvasRef?.removeEventListener('pointerdown', onPointerDown);
|
||||||
|
canvasRef?.removeEventListener('pointermove', onPointerMove);
|
||||||
|
window.removeEventListener('pointerup', onPointerUp);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if !embedded}
|
||||||
|
<div class="top-bar">
|
||||||
|
<button class="bar-btn" on:click={goHome}>←</button>
|
||||||
|
<button class="bar-btn" on:click={toggleHistory}>
|
||||||
|
{historyOpen ? 'Close history' : '💬 History'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if !embedded && historyOpen}
|
||||||
|
<div class="history-panel">
|
||||||
|
<div class="history-header">
|
||||||
|
<h2>Session chat</h2>
|
||||||
|
<button class="close-btn" on:click={() => historyOpen = false}>✕</button>
|
||||||
|
</div>
|
||||||
|
<div class="history-scroll">
|
||||||
|
{#each chatHistory as msg}
|
||||||
|
<div class="history-msg {msg.role}">
|
||||||
|
<span class="history-label">{msg.role === 'assistant' ? '🦥 Mossy' : 'You'}</span>
|
||||||
|
<p>{msg.content}</p>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if !embedded}
|
||||||
|
<SlothBackground />
|
||||||
|
{/if}
|
||||||
|
<canvas bind:this={canvasRef} class="scene" class:embedded></canvas>
|
||||||
|
|
||||||
|
<div class="thought-wrap" class:visible={thoughtBubbleVisible}>
|
||||||
|
<div class="thought-bubble">
|
||||||
|
<div class="thought-dots">
|
||||||
|
<span></span><span></span><span></span>
|
||||||
|
</div>
|
||||||
|
<p class="thought-text">{thoughtBubbleText}</p>
|
||||||
|
</div>
|
||||||
|
<div class="thought-reply">
|
||||||
|
<textarea
|
||||||
|
bind:value={replyInputValue}
|
||||||
|
placeholder="Reply to Mossy…"
|
||||||
|
rows="1"
|
||||||
|
disabled={isApiLoading}
|
||||||
|
on:keydown={handleReplyKey}
|
||||||
|
></textarea>
|
||||||
|
<button class="reply-send" on:click={sendReply} disabled={isApiLoading || !replyInputValue.trim()}>
|
||||||
|
{isApiLoading ? '…' : '➤'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="hint">Drag the sloth to help it climb. Double-click to wake it up.</p>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
@import url("https://fonts.googleapis.com/css2?family=Nunito:wght@400;600;700;800&display=swap");
|
||||||
|
|
||||||
|
:global(body) {
|
||||||
|
margin: 0; padding: 0;
|
||||||
|
width: 100vw; height: 100vh;
|
||||||
|
background: transparent;
|
||||||
|
overflow: hidden;
|
||||||
|
font-family: 'Nunito', sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
canvas.scene {
|
||||||
|
position: fixed; inset: 0;
|
||||||
|
width: 100vw; height: 100vh;
|
||||||
|
display: block; z-index: 1;
|
||||||
|
touch-action: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
canvas.scene.embedded {
|
||||||
|
position: absolute;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
inset: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Top bar ─────────────────────────────────────────────────────── */
|
||||||
|
.top-bar {
|
||||||
|
position: fixed;
|
||||||
|
top: 16px; left: 16px;
|
||||||
|
display: flex; gap: 10px;
|
||||||
|
z-index: 30;
|
||||||
|
}
|
||||||
|
.bar-btn {
|
||||||
|
padding: 9px 16px;
|
||||||
|
border: none; border-radius: 20px;
|
||||||
|
background: rgba(255,255,255,0.92);
|
||||||
|
color: #2E3D22;
|
||||||
|
font-family: 'Nunito', sans-serif;
|
||||||
|
font-weight: 700; font-size: 0.85rem;
|
||||||
|
cursor: pointer;
|
||||||
|
box-shadow: 0 4px 18px rgba(40,55,30,.12);
|
||||||
|
transition: background .15s, transform .1s;
|
||||||
|
}
|
||||||
|
.bar-btn:hover { background: #fff; transform: translateY(-1px); }
|
||||||
|
.bar-btn:active { transform: scale(.96); }
|
||||||
|
|
||||||
|
/* ── History panel ───────────────────────────────────────────────── */
|
||||||
|
.history-panel {
|
||||||
|
position: fixed;
|
||||||
|
top: 62px; right: 16px;
|
||||||
|
width: min(360px, calc(100vw - 32px));
|
||||||
|
max-height: calc(100vh - 80px);
|
||||||
|
background: rgba(255,255,255,0.97);
|
||||||
|
border-radius: 24px;
|
||||||
|
box-shadow: 0 20px 60px rgba(40,55,30,.18);
|
||||||
|
z-index: 25;
|
||||||
|
display: flex; flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.history-header {
|
||||||
|
display: flex; justify-content: space-between; align-items: center;
|
||||||
|
padding: 18px 20px 12px;
|
||||||
|
border-bottom: 1px solid rgba(93,160,82,.16);
|
||||||
|
}
|
||||||
|
.history-header h2 { margin: 0; font-size: 1rem; color: #2E3D22; }
|
||||||
|
.close-btn {
|
||||||
|
border: none; background: #e6f1dd;
|
||||||
|
color: #3E6B2E; border-radius: 50%;
|
||||||
|
width: 30px; height: 30px;
|
||||||
|
cursor: pointer; font-size: 0.85rem; font-weight: 700;
|
||||||
|
}
|
||||||
|
.history-scroll {
|
||||||
|
overflow-y: auto; padding: 14px 16px;
|
||||||
|
display: flex; flex-direction: column; gap: 10px;
|
||||||
|
}
|
||||||
|
.history-msg {
|
||||||
|
border-radius: 16px; padding: 12px 14px;
|
||||||
|
font-size: 0.9rem; line-height: 1.55;
|
||||||
|
}
|
||||||
|
.history-msg p { margin: 4px 0 0; color: #243018; }
|
||||||
|
.history-msg.assistant { background: #eef6e8; }
|
||||||
|
.history-msg.user { background: #fbf7ec; }
|
||||||
|
.history-label { font-weight: 800; font-size: 0.78rem; color: #4E7D3A; }
|
||||||
|
|
||||||
|
/* ── Thought bubble (Anchored Right) ──────────────────────────────────────────────── */
|
||||||
|
.thought-wrap {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 32px;
|
||||||
|
right: 32px;
|
||||||
|
max-width: min(360px, 90vw);
|
||||||
|
display: flex; flex-direction: column; gap: 10px;
|
||||||
|
z-index: 15;
|
||||||
|
pointer-events: none;
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(8px) scale(.96);
|
||||||
|
transition: opacity .35s ease, transform .35s ease;
|
||||||
|
align-items: flex-end;
|
||||||
|
}
|
||||||
|
.thought-wrap.visible {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0) scale(1);
|
||||||
|
pointer-events: all;
|
||||||
|
}
|
||||||
|
.thought-bubble {
|
||||||
|
background: #fff;
|
||||||
|
border: 2px solid rgba(93,160,82,.30);
|
||||||
|
border-radius: 22px 22px 6px 22px;
|
||||||
|
padding: 14px 18px;
|
||||||
|
box-shadow: 0 8px 36px rgba(40,55,30,.14);
|
||||||
|
position: relative;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
.thought-dots { display: flex; gap: 4px; margin-bottom: 6px; }
|
||||||
|
.thought-dots span { width: 5px; height: 5px; background: #7BBE5E; border-radius: 50%; }
|
||||||
|
.thought-text {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.92rem;
|
||||||
|
color: #2A3A1C;
|
||||||
|
line-height: 1.6;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.thought-reply { display: flex; gap: 8px; align-items: flex-end; width: 100%; }
|
||||||
|
.thought-reply textarea {
|
||||||
|
flex: 1;
|
||||||
|
background: rgba(255,255,255,0.96);
|
||||||
|
border: 1.5px solid rgba(93,160,82,.40);
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
font-family: 'Nunito', sans-serif;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: #243018;
|
||||||
|
outline: none; resize: none;
|
||||||
|
height: 44px; min-height: 44px; max-height: 88px;
|
||||||
|
line-height: 1.5; box-sizing: border-box;
|
||||||
|
transition: border-color .15s, box-shadow .15s;
|
||||||
|
}
|
||||||
|
.thought-reply textarea::placeholder { color: #9bb487; }
|
||||||
|
.thought-reply textarea:focus {
|
||||||
|
border-color: #3E7D3A;
|
||||||
|
box-shadow: 0 0 0 3px rgba(62,125,58,.18);
|
||||||
|
}
|
||||||
|
.reply-send {
|
||||||
|
width: 44px; height: 44px;
|
||||||
|
border-radius: 50%; border: none;
|
||||||
|
background: #5DA052; color: #fff;
|
||||||
|
font-size: 1rem; cursor: pointer;
|
||||||
|
flex-shrink: 0;
|
||||||
|
transition: background .12s, transform .1s;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
}
|
||||||
|
.reply-send:hover:not(:disabled) { background: #3E7D3A; }
|
||||||
|
.reply-send:active:not(:disabled) { transform: scale(.94); }
|
||||||
|
.reply-send:disabled { opacity: .5; cursor: default; }
|
||||||
|
|
||||||
|
/* ── Hint ────────────────────────────────────────────────────────── */
|
||||||
|
.hint {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 22px; left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
font-size: 0.8rem; font-weight: 600;
|
||||||
|
color: rgba(40, 55, 30, 0.55);
|
||||||
|
pointer-events: none;
|
||||||
|
letter-spacing: .04em;
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
134
src/SlothBackground.svelte
Normal file
134
src/SlothBackground.svelte
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
<script>
|
||||||
|
//@ts-nocheck
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import Zdog from 'zdog';
|
||||||
|
|
||||||
|
let canvasRef;
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
const TAU = Zdog.TAU;
|
||||||
|
|
||||||
|
const color = {
|
||||||
|
// Sloth Tree Colors
|
||||||
|
branch: '#6B4A2A',
|
||||||
|
branchDk: '#4E3318',
|
||||||
|
leafGreen: '#5DA052',
|
||||||
|
leafDark: '#3A6A28',
|
||||||
|
vine: '#4E7D3A',
|
||||||
|
|
||||||
|
// Background Aesthetic Colors
|
||||||
|
skyTop: '#FFB7D5',
|
||||||
|
skyLow: '#FFE1F0',
|
||||||
|
darkGreen: '#3A5C18',
|
||||||
|
land: '#7ab535',
|
||||||
|
landLight: '#9fd95c',
|
||||||
|
cloudPink: '#FFE4F0',
|
||||||
|
cloudWhite: '#FFF7FB'
|
||||||
|
};
|
||||||
|
|
||||||
|
const scene = new Zdog.Illustration({
|
||||||
|
element: canvasRef,
|
||||||
|
dragRotate: false,
|
||||||
|
resize: 'window',
|
||||||
|
rotate: { x: -0.10, y: -0.08, z: 0 }
|
||||||
|
});
|
||||||
|
|
||||||
|
const envGroup = new Zdog.Anchor({ addTo: scene });
|
||||||
|
|
||||||
|
// --- PINK SKY ---
|
||||||
|
new Zdog.Shape({
|
||||||
|
addTo: envGroup,
|
||||||
|
path: [{ x: -2000, y: -1100 }, { x: 2000, y: -1100 }, { x: 2000, y: 0 }, { x: -2000, y: 0 }],
|
||||||
|
stroke: 0, fill: true, color: color.skyTop, translate: { z: -820 }
|
||||||
|
});
|
||||||
|
new Zdog.Shape({
|
||||||
|
addTo: envGroup,
|
||||||
|
path: [{ x: -2000, y: 0 }, { x: 2000, y: 0 }, { x: 2000, y: 700 }, { x: -2000, y: 700 }],
|
||||||
|
stroke: 0, fill: true, color: color.skyLow, translate: { z: -820 }
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- CLOUDS ---
|
||||||
|
[
|
||||||
|
{ x: -420, y: -480, z: -420, s: 1.2 },
|
||||||
|
{ x: -160, y: -510, z: -510, s: 0.9 },
|
||||||
|
{ x: 360, y: -420, z: -380, s: 1.4 },
|
||||||
|
{ x: 80, y: -490, z: -460, s: 1.0 },
|
||||||
|
{ x: 700, y: -460, z: -500, s: 1.1 }
|
||||||
|
].forEach(p => {
|
||||||
|
const c = new Zdog.Anchor({ addTo: envGroup, translate: { x: p.x, y: p.y, z: p.z }, scale: p.s });
|
||||||
|
new Zdog.Shape({ addTo: c, stroke: 95, color: color.cloudPink });
|
||||||
|
new Zdog.Shape({ addTo: c, stroke: 72, color: color.cloudWhite, translate: { x: -55, y: 10 } });
|
||||||
|
new Zdog.Shape({ addTo: c, stroke: 78, color: color.cloudPink, translate: { x: 55, y: 5 } });
|
||||||
|
new Zdog.Shape({ addTo: c, stroke: 62, color: color.cloudWhite, translate: { x: -95, y: 15 } });
|
||||||
|
new Zdog.Shape({ addTo: c, stroke: 58, color: color.cloudPink, translate: { x: 100, y: 12 } });
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- HILLS ---
|
||||||
|
function addHill(x, w, col, z, amp = 160) {
|
||||||
|
const pts = [{ x: x - w / 2, y: 300 }];
|
||||||
|
for (let i = 0; i <= 24; i++) {
|
||||||
|
const t = i / 24;
|
||||||
|
pts.push({ x: x - w / 2 + t * w, y: 300 - Math.sin(t * Math.PI) * amp });
|
||||||
|
}
|
||||||
|
pts.push({ x: x + w / 2, y: 300 });
|
||||||
|
new Zdog.Shape({ addTo: envGroup, path: pts, stroke: 0, fill: true, color: col, translate: { z } });
|
||||||
|
}
|
||||||
|
|
||||||
|
addHill(-600, 900, color.darkGreen, -700, 160);
|
||||||
|
addHill( 500, 800, '#4a7020', -700, 160);
|
||||||
|
addHill(-200, 700, '#5a8a28', -680, 150);
|
||||||
|
addHill( 900, 700, '#3d6a18', -720, 170);
|
||||||
|
|
||||||
|
// --- GROUND PLANES ---
|
||||||
|
new Zdog.Shape({
|
||||||
|
addTo: envGroup,
|
||||||
|
path: [{ x: -2000, y: 220 }, { x: 2000, y: 220 }, { x: 2000, y: 800 }, { x: -2000, y: 800 }],
|
||||||
|
stroke: 0, fill: true, color: color.land, translate: { z: -500 }
|
||||||
|
});
|
||||||
|
new Zdog.Shape({
|
||||||
|
addTo: envGroup,
|
||||||
|
path: [{ x: -2000, y: 220 }, { x: 2000, y: 220 }, { x: 2000, y: 260 }, { x: -2000, y: 260 }],
|
||||||
|
stroke: 0, fill: true, color: color.landLight, translate: { z: -490 }
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- ENLARGED SLOTH'S TREE ---
|
||||||
|
const treeScaleGroup = new Zdog.Anchor({ addTo: scene, scale: 1.5, translate: { y: 120 } });
|
||||||
|
const slothTreeGroup = new Zdog.Anchor({ addTo: treeScaleGroup, translate: { x: 0, y: 30, z: 80 } });
|
||||||
|
|
||||||
|
const getBranchY = (x) => -250 - 0.1 * x;
|
||||||
|
const trunkX = -440;
|
||||||
|
|
||||||
|
new Zdog.Shape({ addTo: slothTreeGroup, path: [{ x: trunkX, y: getBranchY(trunkX) }, { x: 200, y: getBranchY(200) }], stroke: 80, color: color.branch });
|
||||||
|
|
||||||
|
new Zdog.Shape({
|
||||||
|
addTo: slothTreeGroup,
|
||||||
|
path: [{ x: trunkX, y: -600 }, { x: trunkX, y: 700 }],
|
||||||
|
stroke: 240,
|
||||||
|
color: color.branch,
|
||||||
|
translate: { z: 40 }
|
||||||
|
});
|
||||||
|
new Zdog.Shape({
|
||||||
|
addTo: slothTreeGroup,
|
||||||
|
path: [{ x: trunkX + 60, y: -600 }, { x: trunkX + 60, y: 700 }],
|
||||||
|
stroke: 20,
|
||||||
|
color: color.branchDk,
|
||||||
|
translate: { z: 40 }
|
||||||
|
});
|
||||||
|
|
||||||
|
scene.updateRenderGraph();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<canvas bind:this={canvasRef} class="background-scene"></canvas>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
canvas.background-scene {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
width: 100vw;
|
||||||
|
height: 100vh;
|
||||||
|
z-index: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
296
src/app.css
Normal file
296
src/app.css
Normal file
@@ -0,0 +1,296 @@
|
|||||||
|
:root {
|
||||||
|
--text: #6b6375;
|
||||||
|
--text-h: #08060d;
|
||||||
|
--bg: #fff;
|
||||||
|
--border: #e5e4e7;
|
||||||
|
--code-bg: #f4f3ec;
|
||||||
|
--accent: #aa3bff;
|
||||||
|
--accent-bg: rgba(170, 59, 255, 0.1);
|
||||||
|
--accent-border: rgba(170, 59, 255, 0.5);
|
||||||
|
--social-bg: rgba(244, 243, 236, 0.5);
|
||||||
|
--shadow:
|
||||||
|
rgba(0, 0, 0, 0.1) 0 10px 15px -3px, rgba(0, 0, 0, 0.05) 0 4px 6px -2px;
|
||||||
|
|
||||||
|
--sans: system-ui, 'Segoe UI', Roboto, sans-serif;
|
||||||
|
--heading: system-ui, 'Segoe UI', Roboto, sans-serif;
|
||||||
|
--mono: ui-monospace, Consolas, monospace;
|
||||||
|
|
||||||
|
font: 18px/145% var(--sans);
|
||||||
|
letter-spacing: 0.18px;
|
||||||
|
color-scheme: light dark;
|
||||||
|
color: var(--text);
|
||||||
|
background: var(--bg);
|
||||||
|
font-synthesis: none;
|
||||||
|
text-rendering: optimizeLegibility;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
|
||||||
|
@media (max-width: 1024px) {
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
:root {
|
||||||
|
--text: #9ca3af;
|
||||||
|
--text-h: #f3f4f6;
|
||||||
|
--bg: #16171d;
|
||||||
|
--border: #2e303a;
|
||||||
|
--code-bg: #1f2028;
|
||||||
|
--accent: #c084fc;
|
||||||
|
--accent-bg: rgba(192, 132, 252, 0.15);
|
||||||
|
--accent-border: rgba(192, 132, 252, 0.5);
|
||||||
|
--social-bg: rgba(47, 48, 58, 0.5);
|
||||||
|
--shadow:
|
||||||
|
rgba(0, 0, 0, 0.4) 0 10px 15px -3px, rgba(0, 0, 0, 0.25) 0 4px 6px -2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#social .button-icon {
|
||||||
|
filter: invert(1) brightness(2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1,
|
||||||
|
h2 {
|
||||||
|
font-family: var(--heading);
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-h);
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: 56px;
|
||||||
|
letter-spacing: -1.68px;
|
||||||
|
margin: 32px 0;
|
||||||
|
@media (max-width: 1024px) {
|
||||||
|
font-size: 36px;
|
||||||
|
margin: 20px 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
h2 {
|
||||||
|
font-size: 24px;
|
||||||
|
line-height: 118%;
|
||||||
|
letter-spacing: -0.24px;
|
||||||
|
margin: 0 0 8px;
|
||||||
|
@media (max-width: 1024px) {
|
||||||
|
font-size: 20px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
p {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
code,
|
||||||
|
.counter {
|
||||||
|
font-family: var(--mono);
|
||||||
|
display: inline-flex;
|
||||||
|
border-radius: 4px;
|
||||||
|
color: var(--text-h);
|
||||||
|
}
|
||||||
|
|
||||||
|
code {
|
||||||
|
font-size: 15px;
|
||||||
|
line-height: 135%;
|
||||||
|
padding: 4px 8px;
|
||||||
|
background: var(--code-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.counter {
|
||||||
|
font-size: 16px;
|
||||||
|
padding: 5px 10px;
|
||||||
|
border-radius: 5px;
|
||||||
|
color: var(--accent);
|
||||||
|
background: var(--accent-bg);
|
||||||
|
border: 2px solid transparent;
|
||||||
|
transition: border-color 0.3s;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: var(--accent-border);
|
||||||
|
}
|
||||||
|
&:focus-visible {
|
||||||
|
outline: 2px solid var(--accent);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero {
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
.base,
|
||||||
|
.framework,
|
||||||
|
.vite {
|
||||||
|
inset-inline: 0;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.base {
|
||||||
|
width: 170px;
|
||||||
|
position: relative;
|
||||||
|
z-index: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.framework,
|
||||||
|
.vite {
|
||||||
|
position: absolute;
|
||||||
|
}
|
||||||
|
|
||||||
|
.framework {
|
||||||
|
z-index: 1;
|
||||||
|
top: 34px;
|
||||||
|
height: 28px;
|
||||||
|
transform: perspective(2000px) rotateZ(300deg) rotateX(44deg) rotateY(39deg)
|
||||||
|
scale(1.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.vite {
|
||||||
|
z-index: 0;
|
||||||
|
top: 107px;
|
||||||
|
height: 26px;
|
||||||
|
width: auto;
|
||||||
|
transform: perspective(2000px) rotateZ(300deg) rotateX(40deg) rotateY(39deg)
|
||||||
|
scale(0.8);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#app {
|
||||||
|
width: 1126px;
|
||||||
|
max-width: 100%;
|
||||||
|
margin: 0 auto;
|
||||||
|
text-align: center;
|
||||||
|
border-inline: 1px solid var(--border);
|
||||||
|
min-height: 100svh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
#center {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 25px;
|
||||||
|
place-content: center;
|
||||||
|
place-items: center;
|
||||||
|
flex-grow: 1;
|
||||||
|
|
||||||
|
@media (max-width: 1024px) {
|
||||||
|
padding: 32px 20px 24px;
|
||||||
|
gap: 18px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#next-steps {
|
||||||
|
display: flex;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
text-align: left;
|
||||||
|
|
||||||
|
& > div {
|
||||||
|
flex: 1 1 0;
|
||||||
|
padding: 32px;
|
||||||
|
@media (max-width: 1024px) {
|
||||||
|
padding: 24px 20px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1024px) {
|
||||||
|
flex-direction: column;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#docs {
|
||||||
|
border-right: 1px solid var(--border);
|
||||||
|
|
||||||
|
@media (max-width: 1024px) {
|
||||||
|
border-right: none;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#next-steps ul {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
margin: 32px 0 0;
|
||||||
|
|
||||||
|
.logo {
|
||||||
|
height: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: var(--text-h);
|
||||||
|
font-size: 16px;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--social-bg);
|
||||||
|
display: flex;
|
||||||
|
padding: 6px 12px;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
text-decoration: none;
|
||||||
|
transition: box-shadow 0.3s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
}
|
||||||
|
.button-icon {
|
||||||
|
height: 18px;
|
||||||
|
width: 18px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1024px) {
|
||||||
|
margin-top: 20px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
li {
|
||||||
|
flex: 1 1 calc(50% - 8px);
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
width: 100%;
|
||||||
|
justify-content: center;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#spacer {
|
||||||
|
height: 88px;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
@media (max-width: 1024px) {
|
||||||
|
height: 48px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.ticks {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
&::before,
|
||||||
|
&::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: -4.5px;
|
||||||
|
border: 5px solid transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
left: 0;
|
||||||
|
border-left-color: var(--border);
|
||||||
|
}
|
||||||
|
&::after {
|
||||||
|
right: 0;
|
||||||
|
border-right-color: var(--border);
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
src/assets/hero.png
Normal file
BIN
src/assets/hero.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
1
src/assets/svelte.svg
Normal file
1
src/assets/svelte.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="26.6" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 308"><path fill="#FF3E00" d="M239.682 40.707C211.113-.182 154.69-12.301 113.895 13.69L42.247 59.356a82.198 82.198 0 0 0-37.135 55.056a86.566 86.566 0 0 0 8.536 55.576a82.425 82.425 0 0 0-12.296 30.719a87.596 87.596 0 0 0 14.964 66.244c28.574 40.893 84.997 53.007 125.787 27.016l71.648-45.664a82.182 82.182 0 0 0 37.135-55.057a86.601 86.601 0 0 0-8.53-55.577a82.409 82.409 0 0 0 12.29-30.718a87.573 87.573 0 0 0-14.963-66.244"></path><path fill="#FFF" d="M106.889 270.841c-23.102 6.007-47.497-3.036-61.103-22.648a52.685 52.685 0 0 1-9.003-39.85a49.978 49.978 0 0 1 1.713-6.693l1.35-4.115l3.671 2.697a92.447 92.447 0 0 0 28.036 14.007l2.663.808l-.245 2.659a16.067 16.067 0 0 0 2.89 10.656a17.143 17.143 0 0 0 18.397 6.828a15.786 15.786 0 0 0 4.403-1.935l71.67-45.672a14.922 14.922 0 0 0 6.734-9.977a15.923 15.923 0 0 0-2.713-12.011a17.156 17.156 0 0 0-18.404-6.832a15.78 15.78 0 0 0-4.396 1.933l-27.35 17.434a52.298 52.298 0 0 1-14.553 6.391c-23.101 6.007-47.497-3.036-61.101-22.649a52.681 52.681 0 0 1-9.004-39.849a49.428 49.428 0 0 1 22.34-33.114l71.664-45.677a52.218 52.218 0 0 1 14.563-6.398c23.101-6.007 47.497 3.036 61.101 22.648a52.685 52.685 0 0 1 9.004 39.85a50.559 50.559 0 0 1-1.713 6.692l-1.35 4.116l-3.67-2.693a92.373 92.373 0 0 0-28.037-14.013l-2.664-.809l.246-2.658a16.099 16.099 0 0 0-2.89-10.656a17.143 17.143 0 0 0-18.398-6.828a15.786 15.786 0 0 0-4.402 1.935l-71.67 45.674a14.898 14.898 0 0 0-6.73 9.975a15.9 15.9 0 0 0 2.709 12.012a17.156 17.156 0 0 0 18.404 6.832a15.841 15.841 0 0 0 4.402-1.935l27.345-17.427a52.147 52.147 0 0 1 14.552-6.397c23.101-6.006 47.497 3.037 61.102 22.65a52.681 52.681 0 0 1 9.003 39.848a49.453 49.453 0 0 1-22.34 33.12l-71.664 45.673a52.218 52.218 0 0 1-14.563 6.398"></path></svg>
|
||||||
|
After Width: | Height: | Size: 1.9 KiB |
1
src/assets/vite.svg
Normal file
1
src/assets/vite.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 8.5 KiB |
9
src/main.js
Normal file
9
src/main.js
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { mount } from "svelte";
|
||||||
|
import "./app.css";
|
||||||
|
import App from "./App.svelte";
|
||||||
|
|
||||||
|
const app = mount(App, {
|
||||||
|
target: document.getElementById("app"),
|
||||||
|
});
|
||||||
|
|
||||||
|
export default app;
|
||||||
Reference in New Issue
Block a user