Feature: Digital pet

This commit is contained in:
Sami
2026-06-14 15:18:56 -04:00
parent dac4b88b94
commit d5db0d0e8e
19 changed files with 1612 additions and 968 deletions
+414
View File
@@ -0,0 +1,414 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Signal - Digital Pet Companion</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
background: linear-gradient(135deg, #0a0e27 0%, #0f1a35 50%, #0a0e27 100%);
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
color: #e9feff;
padding: 20px;
}
.container {
text-align: center;
max-width: 600px;
}
h1 {
font-size: 28px;
margin-bottom: 10px;
color: #7df9ff;
}
.subtitle {
font-size: 14px;
opacity: 0.8;
margin-bottom: 40px;
color: #9ee8f3;
}
.pet-container {
position: relative;
display: flex;
justify-content: center;
align-items: flex-end;
height: 300px;
margin-bottom: 40px;
}
.digital-pet-avatar {
width: 120px;
height: 120px;
border-radius: 28px 28px 34px 34px;
border: 2px solid rgba(135, 206, 250, 0.45);
background: radial-gradient(circle at 40% 25%, rgba(125, 249, 255, 0.24), rgba(6, 16, 28, 0.92));
display: grid;
grid-template-rows: 20px 1fr 24px;
place-items: center;
cursor: pointer;
box-shadow: 0 0 24px rgba(74, 222, 255, 0.2), inset 0 0 18px rgba(74, 222, 255, 0.08);
transition: transform 180ms ease, opacity 180ms ease, filter 180ms ease;
user-select: none;
}
.digital-pet-avatar:hover {
outline: 2px solid rgba(125, 249, 255, 0.65);
outline-offset: 3px;
transform: translateY(-3px) scale(1.04);
}
.digital-pet-avatar[data-mode="idle"] {
animation: breathe 3.6s ease-in-out infinite;
}
.digital-pet-avatar[data-mode="sleeping"] {
opacity: 0.58;
filter: saturate(0.55);
animation: none;
}
.antenna {
color: #7df9ff;
font-size: 24px;
line-height: 1;
}
.face {
font-size: 28px;
letter-spacing: 2px;
display: flex;
gap: 8px;
align-items: center;
justify-content: center;
position: relative;
width: 70px;
height: 32px;
}
.eye {
position: relative;
width: 24px;
height: 24px;
background: #e9feff;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
box-shadow: inset 0 2px 4px rgba(0, 0, 0, 0.2);
overflow: hidden;
}
.pupil {
position: absolute;
width: 10px;
height: 10px;
background: #0a0e27;
border-radius: 50%;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
transition: transform 30ms ease-out;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.3);
}
.pupil::after {
content: '';
position: absolute;
width: 4px;
height: 4px;
background: rgba(255, 255, 255, 0.4);
border-radius: 50%;
top: 2px;
left: 2px;
}
.sleeping-eye {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
opacity: 0;
font-size: 16px;
pointer-events: none;
}
.digital-pet-avatar[data-mode="sleeping"] .eye {
background: transparent;
}
.digital-pet-avatar[data-mode="sleeping"] .pupil {
opacity: 0;
}
.digital-pet-avatar[data-mode="sleeping"] .sleeping-eye {
opacity: 1;
}
.digital-pet-avatar[data-mode="hidden"] .eye {
background: transparent;
}
.digital-pet-avatar[data-mode="hidden"] .pupil {
opacity: 0;
}
.name {
font-size: 12px;
opacity: 0.72;
}
.message-bubble {
position: absolute;
bottom: 140px;
left: 50%;
transform: translateX(-50%);
max-width: 280px;
padding: 10px 14px;
border: 1px solid rgba(135, 206, 250, 0.35);
border-radius: 12px;
background: rgba(3, 10, 20, 0.92);
color: #e9feff;
font-size: 13px;
line-height: 1.4;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.32);
opacity: 1;
transition: opacity 300ms ease;
}
.message-bubble.hidden {
opacity: 0;
pointer-events: none;
}
.controls {
display: flex;
gap: 15px;
justify-content: center;
flex-wrap: wrap;
}
button {
padding: 10px 20px;
border: 1px solid rgba(135, 206, 250, 0.45);
background: rgba(125, 249, 255, 0.08);
color: #7df9ff;
border-radius: 8px;
cursor: pointer;
font-family: inherit;
font-size: 12px;
transition: all 200ms ease;
}
button:hover {
background: rgba(125, 249, 255, 0.15);
border-color: rgba(125, 249, 255, 0.65);
transform: scale(1.05);
}
button:active {
transform: scale(0.98);
}
.info {
margin-top: 40px;
padding: 20px;
border: 1px solid rgba(135, 206, 250, 0.25);
border-radius: 12px;
background: rgba(125, 249, 255, 0.04);
font-size: 12px;
line-height: 1.6;
}
.info p {
margin-bottom: 8px;
}
@keyframes breathe {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-3px); }
}
@media (max-width: 640px) {
h1 { font-size: 24px; }
.digital-pet-avatar { width: 90px; height: 90px; }
.controls { gap: 10px; }
button { padding: 8px 16px; font-size: 11px; }
}
</style>
</head>
<body>
<div class="container">
<h1>Signal</h1>
<p class="subtitle">A quiet station companion</p>
<div class="pet-container">
<div class="message-bubble hidden" id="messageBubble"></div>
<button class="digital-pet-avatar" id="petAvatar" data-mode="idle" title="Click Signal">
<span class="antenna"></span>
<div class="face" id="faceContainer">
<div class="eye" id="eyeLeft">
<div class="pupil" id="pupilLeft"></div>
<span class="sleeping-eye"></span>
</div>
<div class="eye" id="eyeRight">
<div class="pupil" id="pupilRight"></div>
<span class="sleeping-eye"></span>
</div>
</div>
<span class="name">Signal</span>
</button>
</div>
<div class="controls">
<button onclick="petClick()">Click Signal</button>
<button onclick="toggleMode()">Toggle Sleep</button>
<button onclick="getRandomMessage()">Show Message</button>
</div>
<div class="info">
<p><strong>About Signal:</strong> A friendly, calm companion focused on keeping watch without demanding attention.</p>
<p><strong>Interactions:</strong> Click to react, hover to sleep, or get random messages to see different moods.</p>
<p><strong>Moods:</strong> calm (•_•), pleased (•‿•), curious (•?•), or tired (-_-)</p>
</div>
</div>
<script>
const moodData = {
calm: { messages: ["All quiet.", "Good signal.", "Watching the station.", "Systems feel steady."] },
pleased: { messages: ["Good signal today.", "Good. Now we know.", "The station holds.", "That helped."] },
curious: { messages: ["Interesting.", "Small systems matter.", "Antenna aligned."] },
tired: { messages: ["Still with you.", "Powering down softly."] }
};
let currentState = {
mode: "idle",
mood: "calm",
message: null,
messageTimeout: null,
mouseX: 0,
mouseY: 0
};
const petAvatar = document.getElementById("petAvatar");
const messageBubble = document.getElementById("messageBubble");
const pupilLeft = document.getElementById("pupilLeft");
const pupilRight = document.getElementById("pupilRight");
// Track mouse movement
document.addEventListener("mousemove", (e) => {
currentState.mouseX = e.clientX;
currentState.mouseY = e.clientY;
updateEyeDirection();
});
function updateEyeDirection() {
// Only update eyes when awake and visible
if (currentState.mode === "sleeping" || currentState.mode === "hidden") {
return;
}
const avatarRect = petAvatar.getBoundingClientRect();
const avatarCenterX = avatarRect.left + avatarRect.width / 2;
const avatarCenterY = avatarRect.top + avatarRect.height / 2;
const deltaX = currentState.mouseX - avatarCenterX;
const deltaY = currentState.mouseY - avatarCenterY;
const angle = Math.atan2(deltaY, deltaX);
// Calculate pupil offset within the eye (max 7px from center of eye)
const maxOffset = 7;
const offsetX = Math.cos(angle) * maxOffset;
const offsetY = Math.sin(angle) * maxOffset;
// Apply offset to both pupils
pupilLeft.style.transform = `translate(calc(-50% + ${offsetX}px), calc(-50% + ${offsetY}px))`;
pupilRight.style.transform = `translate(calc(-50% + ${offsetX}px), calc(-50% + ${offsetY}px))`;
}
function updateFace() {
petAvatar.setAttribute("data-mode", currentState.mode);
// Pupils visibility is handled by CSS based on data-mode
updateEyeDirection();
}
function showMessage(text) {
messageBubble.textContent = text;
messageBubble.classList.remove("hidden");
currentState.message = text;
if (currentState.messageTimeout) clearTimeout(currentState.messageTimeout);
currentState.messageTimeout = setTimeout(() => {
messageBubble.classList.add("hidden");
currentState.message = null;
}, 3000);
}
function petClick() {
if (currentState.mode === "sleeping") {
currentState.mode = "idle";
showMessage("Back online.");
} else {
currentState.mode = "reacting";
const moods = Object.keys(moodData);
currentState.mood = moods[Math.floor(Math.random() * moods.length)];
const messages = moodData[currentState.mood].messages;
showMessage(messages[Math.floor(Math.random() * messages.length)]);
setTimeout(() => {
currentState.mode = "idle";
updateFace();
}, 1500);
}
updateFace();
}
function toggleMode() {
if (currentState.mode === "sleeping") {
currentState.mode = "idle";
showMessage("Quiet watch resumed.");
} else {
currentState.mode = "sleeping";
showMessage("Going quiet.");
}
updateFace();
}
function getRandomMessage() {
const moods = Object.keys(moodData);
const randomMood = moods[Math.floor(Math.random() * moods.length)];
const messages = moodData[randomMood].messages;
const randomMessage = messages[Math.floor(Math.random() * messages.length)];
currentState.mood = randomMood;
if (currentState.mode !== "sleeping") currentState.mode = "reacting";
updateFace();
showMessage(randomMessage);
if (currentState.mode === "reacting") {
setTimeout(() => {
currentState.mode = "idle";
updateFace();
}, 1500);
}
}
// Initial update
updateFace();
</script>
</body>
</html>