<!DOCTYPE html>
<html lang="zh">
<head>
<meta charset="UTF-8" />
<title>给小熊投喂甜甜圈</title>
<style>
* {
box-sizing: border-box;
user-select: none;
}
body {
padding: 0;
margin: 0;
font-family: sans-serif;
background-color: #48c1bb;
overscroll-behavior: contain;
--brown: #57280f;
}
.wrapper {
position: absolute;
height: 100dvh;
width: 100%;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
}
.wrapper.show-message::after {
position: absolute;
content: "拖动甜甜圈喂给小熊";
top: 80px;
font-style: italic;
color: #fff;
font-size: 1.2em;
}
.object {
position: absolute;
width: var(--w);
height: var(--h);
image-rendering: pixelated;
}
.bear {
position: relative;
--bg: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAwAAAAMCAYAAABWdVznAAAAAXNSR0IArs4c6QAAAEFJREFUKFNjZMACvp+a/h8kzGmWyYgujSIAU4iuCFkjXAMuxTDNME1gDYQUI2tiJFYxTNPI1EBysMKCi1DwwiIOAN7kLBdgm7MJAAAAAElFTkSuQmCC);
border-image: var(--bg) 5 fill / 12px / 0 stretch;
}
.round {
background-image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAwAAAAMCAYAAABWdVznAAAAAXNSR0IArs4c6QAAAEBJREFUKFNjZEAD309N/48sxGmWyYjMh3PQFaIbBNMI1kBIMUwzSBMjsYphmkamBpKDFRZchIIXJeKQY5VQ0gAAHM4sF8jjrCwAAAAASUVORK5CYII=);
image-rendering: pixelated;
background-size: cover;
}
.cheek {
animation: cheek-eat forwards 3.5s;
animation-play-state: paused;
}
.mouth {
--bg: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAoAAAAJCAYAAAALpr0TAAAAAXNSR0IArs4c6QAAADxJREFUKFNjZICCh6UT/st15cO4YPpR2UQG+e4CRhAbTIAUwVTAFIMUwQBIMSOyIhTj0DhDQiHRviY2HAHIiSqDjfLRxAAAAABJRU5ErkJggg==);
border-image: var(--bg) 4 fill / 10px 9px / 0 stretch;
}
.mouth-wrapper {
width: 16px;
height: 4px;
margin-top: -5px;
z-index: 1;
}
.eating .mouth-wrapper {
background-image: none;
width: 0;
height: 0;
}
.eating .mouth {
animation: eat infinite 0.4s;
}
.eating .cheek {
animation-play-state: running;
}
@keyframes eat {
0%,
100% {
width: var(--w);
height: var(--h);
}
50% {
width: var(--max-w);
height: var(--max-h);
}
}
@keyframes cheek-eat {
0% {
width: var(--w);
height: var(--h);
}
100% {
width: var(--max-w);
height: var(--max-h);
}
}
.inner-face {
width: 100%;
max-width: 140px;
display: flex;
justify-content: space-evenly;
align-items: center;
margin: 0 auto;
}
.face {
position: absolute;
--top: 24px;
--move-top: 22px;
top: var(--top);
width: 100%;
}
.eating .ears,
.eating .face {
animation: face-eat infinite 0.4s;
}
@keyframes face-eat {
0%,
100% {
top: var(--move-top);
}
50% {
top: var(--top);
}
}
.ears {
position: absolute;
width: 120%;
left: -10%;
--top: -16px;
--move-top: -14px;
top: var(--top);
}
.inner-ears {
display: flex;
justify-content: space-evenly;
width: 100%;
max-width: 200px;
margin: 0 auto;
}
.ear {
width: 18px;
height: 18px;
}
.cheeks {
position: absolute;
bottom: -10px;
width: 100%;
display: flex;
justify-content: space-between;
height: 0;
}
.eye {
background-color: var(--brown);
width: 4px;
height: 8px;
z-index: 1;
}
.nose {
background-image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAICAYAAADwdn+XAAAAAXNSR0IArs4c6QAAAEZJREFUKFNjZICC/////4exQbSyRxYyl+HujmkofEZGRkaQAJhA10yMAWDNIIBNM4pVBDjDwQBcgUhMOIADEVc0EjIAphcAZB4l+bnOcdYAAAAASUVORK5CYII=);
image-rendering: pixelated;
background-size: cover;
width: 32px;
height: 16px;
margin-bottom: -16px;
}
.flex-center {
display: flex;
justify-content: center;
align-items: center;
}
.hand {
background-image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAUAAAAGCAYAAAAL+1RLAAAAAXNSR0IArs4c6QAAAClJREFUGFdjDNfg/88ABStvfGQEMcEEDIAUgCRQBEGSIAnCgnDt2CwCAGzKEyHNFrF4AAAAAElFTkSuQmCC);
width: 10px;
height: 12px;
image-rendering: pixelated;
background-size: cover;
}
.flip {
transform: scale(-1, 1);
}
.limbs {
position: absolute;
width: 100%;
height: 40px;
bottom: -10px;
display: flex;
flex-direction: column;
justify-content: space-between;
}
.eating .limbs {
animation: limbs-eat infinite 0.4s;
}
@keyframes limbs-eat {
0%,
100% {
height: 40px;
}
50% {
height: 42px;
}
}
.limbs > div {
display: flex;
justify-content: space-around;
}
.foot {
width: 18px;
height: 18px;
}
.food {
cursor: grab;
}
.food:active {
cursor: grabbing;
}
.donut {
background-image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAASCAYAAABB7B6eAAAAAXNSR0IArs4c6QAAAVJJREFUOE9jZCAAwjX4/+NTsvLGR0Z88lglkQ0tjQ1nMLazxWlGRGoOXA6bZSgWwAwGGUosQLYcZhmyRXALQIaTYjA2B8AsA1kEswRsATUMR7YQZBHMErAFZ1rT8EbkrdtXsIaYmqoOzpAEWcJoG8vIiM/1uAxGNxWbRTBf4LSge/FKBn8LbbhZOk5WKOZe2XcMzt944ioDtvgDmYHTApgLQKZUV6ViDYrWttlg8RWzpzCcPXQYQw1eC0CqkVMFNhtABoMANsNB4nALQEFBKMKwWYDLYJBaUPyBgg4cRMhhjc8iYjIfcsIAWwDS9PfFrv8rKxrg+sm1BNnw8I4GBmYJN0Z4Rlt2YDUDsiUw2whZhi0pgwyPcggF52aUogJkCQhgs4iY4AEZDAIww0FsrIUdzCJiLIMZCjMYrAepCCdYXCNbhs0XINfCAMHiGpsBlFY4AGg4nGHeibMpAAAAAElFTkSuQmCC);
image-rendering: pixelated;
background-size: cover;
}
.donut-eaten-1 {
background-image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAASCAYAAABB7B6eAAAAAXNSR0IArs4c6QAAAWBJREFUOE+dlbFKA0EQhm/xAWx9ASVgOhsJJEgKW6twIqRMZxsQ06SJCLZ2KQXJYWVrIaIgNnYRRF/ANg8QIv/CP8yds3trtko2O983O7s7cVniyBubK2tp8blwMUT0RwYCPptem5zjwanMW7JkwbCfm4K9TlvmKdOiWgGyD8GrRsogoiQq+A9cyyCixAt4gHprGv71PTfLs7PdDJ4vJK7dd06Drm4KH8CShMBVqiXiLkoCHQjZ0f6uTDW7rRJ3/vgq3+/fPiSpKiMoYAYIGJ0PzFJMLqZ+Hlf4/fnlzxokGRRgtb4VloFvw4JjvQhQiroDswQhMNbi/FA6vwNd65gopavoi+EFCFr+PKyKs7HEryvR8PxynG1sHTp5B7dPd5mW0FYns64y4CcHPf+a5SWjVJBgWKKU8gCMQTg+l1oFXzRFKTJCCfYxqoWbvUj3fi2zdoFsOdZq16E/mhhUJ/ILNpijYV616soAAAAASUVORK5CYII=);
}
.donut-eaten-2 {
background-image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAASCAYAAABB7B6eAAAAAXNSR0IArs4c6QAAAVtJREFUOE9jZKAxYKSG+eEa/P9B5qy88RHDPIotABm+YvYUsDsjUnPANLJFZFkAczHIsNLYcHggGNvZwi2CWUKSBTCDkQ3FFsQgi0C+AVlCtAUgw/0ttFHMU1PVwRmFIEsYbWOJswCb4cgmY7MI5guifLAsweq/jpMVimuv7DsG5288cRUlLmAS3YtXMoAtwJbMkCOyuioVa1C0ts0Gi4NS0dlDhzHUgC0AGQSLNJAADIDEkFMFNhtgyROb4SD1cAtAkUcowrBZgMtgkNpbt68wgIIO7APk1IHPImJyPchgGABbAOL8fbHr/8qKBrgEuZYgGx7e0cDALOEGSaYgXyw7sJoB2RKYbYQsQzYUpgdkeJRDKGpGg1kCLkuQfENMsCAbDGLDDAexUfIBLGmCfAMDhCwDuRYGQAaDHYhUqmLNaMh5ANkybL6BGYpuMEwtwZyMbBk2C7DVAcjqADiOkGEeg4/IAAAAAElFTkSuQmCC);
}
.donut-eaten-3 {
background-image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAASCAYAAABB7B6eAAAAAXNSR0IArs4c6QAAARFJREFUOE9jZKAxYKSG+eEa/P9B5qy88RHDPIotABm+YvYUsDsjUnPANLJFZFsAc3VpbDg8EIztbOEWwSwhywKQ4cgGowczyCKQb0CWkGXBsgQrcJirqergjEKQJYy2saRbADMc2WRsFsF8QbIPLi0qAbv+yr5jcDs2nrjKgC3IuhevZCDbApDprW2zwZaAUtHZQ4cxgossC2CpB2YwiMZmOEicZAuQ0zw+g0Fyt25fYQAFHdFBRChpwsIHZDAMUN0CZMPDOxoYmCXciE+muHyAbCjM5SDDoxxCic9oIMP9LbQJlosgg0EAZjiITVQc4LMAZijMYJILO5Dhyw6sxul6kGthgOziGjnto9uEzVBkNQCDSHph6/HU2wAAAABJRU5ErkJggg==);
}
.donut-eaten-4 {
background-image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAASCAYAAABB7B6eAAAAAXNSR0IArs4c6QAAAO1JREFUOE9jZKAxYKSx+QxUsyBcg/8/zLErb3yEm0uxBTCDS2PDGYztbMF2RKTmMMAsocgCkOEgg9EByCKYJRRZcKY1DR4s2CxhtI1lJNuCZQlWcMPVVHVw+oIqFmw8cZUBW1B1L15Jfiq6tKjkf2vbbLDLV8yewnD20GEMX1BkASiCQQaDADbDQeJkWwAzHJfBIMNv3b7CAAo6suIAV/KEGQwLK6pbAHI1DIR3NDAwS7iRn0xhvkA2FNnwKIdQcG4mK4jgBmnw//e30EZxNYgDMxzEptiCZQdWwy0AGQwCNCns0A2G2UqRD4ipSwCm6mlhhOrUsgAAAABJRU5ErkJggg==);
}
.donut-eaten-5 {
background-image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAASCAYAAABB7B6eAAAAAXNSR0IArs4c6QAAAIpJREFUOE9jZKAxYKSx+QyjFhAMYaoH0ZnWtP/di1eCLV554yMjVS0AGQ7zkrGdLUNEag71IhnZcJglIJ9QxQf/Dy/+f/bQYYz4oJoF4Rr8/0tjw+lrwa3bVxg2nrhKnSACOR0WByCDYYCqFoCCyd9CG8VwqidTkCUwG0CGg9hUSUX4svOoBQQLOwA+szZhNsKHIwAAAABJRU5ErkJggg==);
}
.donut-crumbs {
position: absolute;
--bg: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAQAAAAECAYAAACp8Z5+AAAAAXNSR0IArs4c6QAAABxJREFUGFdjZICCM61p/0FMRsICIKUm1bMYYVoAj+EO3fwl+Y4AAAAASUVORK5CYII=);
border-image: var(--bg) 2 fill / 4px / 0 stretch;
animation: 1s forwards spread;
}
.donut::after {
position: absolute;
content: "";
width: 40px;
height: 40px;
--bg: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAoAAAAKCAYAAACNMs+9AAAAAXNSR0IArs4c6QAAAEdJREFUKFONUFsKACAIa/c/9EJokqaVPyLupRirSBIANOfuiyfQAAcbQCaWijtZcVrrVtHsbzmDorJW14d3fCl2/3OXF0D7CVHNLAu21ivDAAAAAElFTkSuQmCC);
border-image: var(--bg) 5 fill / 20px / 0 stretch;
image-rendering: pixelated;
animation: 0.5s forwards sparkle;
}
@keyframes sparkle {
0% {
width: 40px;
height: 40px;
transform: translate(16px, 4px);
}
95% {
width: 80px;
height: 80px;
transform: translate(-2px, -10px);
opacity: 1;
}
100% {
opacity: 0;
}
}
@keyframes spread {
0% {
width: var(--w);
height: var(--h);
}
100% {
width: var(--max-w);
height: var(--max-h);
}
}
.cheek-shrink .cheek {
animation: 0.4s forwards shrink;
animation-play-state: running;
}
.grow {
animation: forwards spread 0.5s;
animation-delay: 1s;
}
@keyframes shrink {
0% {
width: var(--max-w);
height: var(--max-h);
}
100% {
width: 20;
height: 20;
}
}
.sign {
position: fixed;
font-family: Arial, Helvetica, sans-serif;
color: var(--brown);
bottom: 10px;
right: 10px;
font-size: 10px;
text-transform: none;
}
a {
color: var(--brown);
text-decoration: none;
text-transform: none;
}
a:hover {
text-decoration: underline;
}
</style>
</head>
<body>
<div class="wrapper show-message"></div>
<script>
const isNum = (x) => typeof x === "number";
const px = (n) => `${n}px`;
const getPagePos = (e, param) =>
e.targetTouches ? e.touches[0][`page${param}`] : e[`page${param}`];
const randomN = (n) => {
return Math.round(-n - 0.5 + Math.random() * (2 * n + 1));
};
const wrapper = document.querySelector(".wrapper");
const addEvents = (target, event, action, array) => {
array.forEach((a) => target[`${event}EventListener`](a, action));
};
const mouse = {
up: (t, e, a) => addEvents(t, e, a, ["mouseup", "touchend"]),
move: (t, e, a) => addEvents(t, e, a, ["mousemove", "touchmove"]),
down: (t, e, a) => addEvents(t, e, a, ["mousedown", "touchstart"]),
enter: (t, e, a) => addEvents(t, e, a, ["mouseenter", "touchstart"]),
leave: (t, e, a) => addEvents(t, e, a, ["mouseleave", "touchmove"]),
};
class Vector {
constructor(props) {
Object.assign(this, {
x: 0,
y: 0,
...props,
});
}
get magnitude() {
return Math.sqrt(this.x * this.x + this.y * this.y);
}
setLength(length) {
const angle = Math.atan2(this.y, this.x);
this.x = Math.cos(angle) * length;
this.y = Math.sin(angle) * length;
}
setXy(xy) {
this.x = xy.x;
this.y = xy.y;
}
addXy(xy) {
this.x += xy.x;
this.y += xy.y;
}
subtractXy(xy) {
this.x -= xy.x;
this.y -= xy.y;
}
multiplyXy(n) {
this.x *= n;
this.y *= n;
}
}
class WorldObject {
constructor(props) {
Object.assign(this, {
zOffset: 90,
moveWithTransform: true,
x: 0,
y: 0,
offset: { x: null, y: null },
pos: new Vector({ x: props.x, y: props.y }),
size: { w: 0, h: 0 },
maxSize: { w: 0, h: 0 },
...props,
});
this.addToWorld();
}
get rect() {
const { left, top } = this.el.getBoundingClientRect();
return {
left,
top,
};
}
get distPos() {
const {
size: { w, h },
} = this;
return {
x: this.rect.left + w / 2,
y: this.rect.top + h / 2,
};
}
setOffset() {
const {
offset: { x, y },
} = this;
if (isNum(this.offset.x) && isNum(this.offset.y)) {
this.el.style.setProperty("--offset-x", px(x));
this.el.style.setProperty("--offset-y", px(y));
}
}
setSize(target = this) {
const {
size: { w, h },
maxSize: { w: mW, h: mH },
} = target;
this.el.style.setProperty("--w", px(w));
this.el.style.setProperty("--h", px(h));
this.el.style.setProperty("--max-w", px(mW));
this.el.style.setProperty("--max-h", px(mH));
}
setStyles() {
const {
pos: { x, y },
z,
} = this;
Object.assign(this.el.style, {
left: px(x || 0),
top: px(y || 0),
});
this.el.style.zIndex = z || 0;
this.el.style.transformOrigin =
isNum(this.offset.x) & isNum(this.offset.y)
? `${this.offset.x}px ${this.offset.y}px`
: `center`;
}
distanceBetween(target) {
return Math.round(
Math.sqrt(
Math.pow(target.distPos.x - this.distPos.x, 2) +
Math.pow(target.distPos.y - this.distPos.y, 2)
)
);
}
addToWorld() {
this.setSize();
this.setOffset();
if (!this.noPos) this.setStyles();
this.container.appendChild(this.el);
}
}
class Crumbs extends WorldObject {
constructor(props) {
super({
el: Object.assign(document.createElement("div"), {
className: `${props.type}-crumbs object`,
}),
x: 0,
y: 0,
container: wrapper,
...props,
});
setTimeout(() => {
this.el.remove();
this.food.crumbs = null;
}, 1000);
}
}
class Food extends WorldObject {
constructor(props) {
super({
el: Object.assign(document.createElement("div"), {
className: `food ${props.type} object`,
}),
x: 0,
y: 0,
grabPos: { a: { x: 0, y: 0 }, b: { x: 0, y: 0 } },
container: wrapper,
eatCount: 0,
eatInterval: null,
...props,
});
this.addDragEvent();
this.setPos();
}
touchPos(e) {
return {
x: getPagePos(e, "X"),
y: getPagePos(e, "Y"),
};
}
addDragEvent() {
mouse.down(this.el, "add", this.onGrab);
}
drag = (e, x, y) => {
if (e.type[0] === "m") e.preventDefault();
this.grabPos.a.x = this.grabPos.b.x - x;
this.grabPos.a.y = this.grabPos.b.y - y;
this.pos.subtractXy(this.grabPos.a);
this.setStyles();
};
onGrab = (e) => {
this.grabPos.b = this.touchPos(e);
mouse.up(document, "add", this.onLetGo);
mouse.move(document, "add", this.onDrag);
};
onDrag = (e) => {
const { x, y } = this.touchPos(e);
if (this.canMove) this.drag(e, x, y);
this.grabPos.b.x = x;
this.grabPos.b.y = y;
};
onLetGo = () => {
mouse.up(document, "remove", this.onLetGo);
mouse.move(document, "remove", this.onDrag);
};
eat() {
if (!this.eatInterval) {
this.eatInterval = setInterval(() => {
if (this.eatCount < 5) {
this.crumbs = new Crumbs({
type: this.type,
size: { w: 0, h: 0 },
maxSize: { w: 40, h: 40 },
x: this.distPos.x + randomN(10),
y: this.pos.y + randomN(10),
food: this,
});
this.eatCount++;
this.el.className = `food ${this.type} ${this.type}-eaten-${this.eatCount} object`;
} else {
this.el.remove();
this.bear.food = null;
clearInterval(this.eatInterval);
this.eatInterval = null;
this.bear.el.className = "bear object grow";
this.bear.grow();
}
}, 500);
}
}
setPos() {
const { width, height } = wrapper.getBoundingClientRect();
this.pos.setXy({
x: width / 2 - 36,
y: height - (height > 400 ? 200 : 100),
});
this.setStyles();
}
}
class Bear extends WorldObject {
constructor(props) {
super({
...props,
canMove: true,
type: "bear",
el: Object.assign(document.createElement("div"), {
className: "bear object",
innerHTML: `
<div class="ears">
<div class="inner-ears">
<div class="ear round"></div>
<div class="ear round"></div>
</div>
</div>
<div class="face">
<div class="inner-face">
<div class="eye"></div>
<div class="nose"></div>
<div class="eye"></div>
</div>
<div class="cheeks">
<div class="cheek-wrapper flex-center"></div>
<div class="mouth-wrapper flex-center"></div>
<div class="cheek-wrapper flex-center"></div>
</div>
</div>
<div class="limbs">
<div class="hands">
<div class="hand"></div>
<div class="hand flip"></div>
</div>
<div class="feet">
<div class="foot round"></div>
<div class="foot round"></div>
</div>
</div>
`,
}),
});
const cheekWrappers = this.el.querySelectorAll(".cheek-wrapper");
const mouthWrapper = this.el.querySelector(".mouth-wrapper");
this.cheeks = [0, 1].map(
(i) =>
new WorldObject({
el: Object.assign(document.createElement("div"), {
className: "cheek round object",
}),
container: cheekWrappers[i],
body: this,
size: { w: 0, h: 0 },
maxSize: { w: 40, h: 40 },
noPos: true,
})
);
this.mouth = new WorldObject({
el: Object.assign(document.createElement("div"), {
className: "mouth object flex-center",
}),
container: mouthWrapper,
body: this,
size: { w: 20, h: 0 },
maxSize: { w: 40, h: 30 },
noPos: true,
});
mouse.move(document, "add", () => {
if (this.food) {
if (this.mouth.distanceBetween(this.food) < 50) {
this.el.classList.add("eating");
wrapper.classList.remove("show-message");
this.food.eat();
} else {
this.el.classList.remove("eating");
clearInterval(this.food.eatInterval);
this.food.eatInterval = null;
}
}
});
this.createFood();
window.addEventListener("resize", () => {
if (this.food) this.food.setPos();
});
}
grow() {
this.el.className = "bear object grow";
setTimeout(() => {
this.el.classList.add("cheek-shrink");
setTimeout(() => {
this.el.classList.remove("cheek-shrink");
this.createFood();
}, 1000);
}, 1000);
setTimeout(() => {
this.size = { ...this.maxSize };
this.maxSize = {
w: this.size.w + 20,
h: this.size.h + 10,
};
this.el.classList.remove("grow");
this.setSize();
}, 1500);
}
createFood() {
this.food = new Food({
type: "donut",
size: { w: 72, h: 54 },
canMove: true,
bear: this,
});
}
}
new Bear({
container: wrapper,
size: { w: 70, h: 90 },
maxSize: { w: 90, h: 100 },
offset: { x: null, y: null },
});
</script>
</body>
</html>