Feature: Digital pet
This commit is contained in:
@@ -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>
|
||||
Reference in New Issue
Block a user