den.js 1.0.0 — he exists
This commit is contained in:
15
.gitignore
vendored
Normal file
15
.gitignore
vendored
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
node_modules/
|
||||||
|
package-lock.json
|
||||||
|
|
||||||
|
# runtime data written by den.php (lives above docroot in deployment,
|
||||||
|
# but ignore it here in case anyone tests with it alongside)
|
||||||
|
*_presence.txt
|
||||||
|
*_guestbook.txt
|
||||||
|
*_poll.txt
|
||||||
|
*_pilgrims.txt
|
||||||
|
counter.txt
|
||||||
|
status.txt
|
||||||
|
|
||||||
|
.DS_Store
|
||||||
|
*.swp
|
||||||
|
*~
|
||||||
19
CHANGELOG.md
Normal file
19
CHANGELOG.md
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
# Changelog
|
||||||
|
|
||||||
|
## 1.0.0
|
||||||
|
|
||||||
|
First release. He exists.
|
||||||
|
|
||||||
|
- Autonomous critter: chase / rest / wander / errand / mischief states, turn-rate
|
||||||
|
limited movement, stride-based paw prints that rotate to face travel direction
|
||||||
|
- Burrow spawn animation, ten-minute idle sleep cycle with bed and circling ritual
|
||||||
|
- Emote bubbles and speech; `denSay` / `denYip` helpers
|
||||||
|
- `localStorage` memory: visits, lifetime pets, name. Pet milestones at 10/25/50/100,
|
||||||
|
behaviour changes with trust
|
||||||
|
- Portable shell tab with critter commands
|
||||||
|
- POI system via `data-den-poi`
|
||||||
|
- Mischief system via `data-den-steal` — steals below 25 pets, gifts at and above
|
||||||
|
- Event API: `celebrate`, `investigate`, `alarm`, `mischief`, `sleepy`, `zoomies`
|
||||||
|
- Optional PHP presence endpoint with HMAC'd fingerprints and per-IP caps
|
||||||
|
- Secret typed codes and the Konami code
|
||||||
|
- Respects `prefers-reduced-motion`
|
||||||
21
LICENSE
Normal file
21
LICENSE
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2026 owo.ing
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
232
README.md
Normal file
232
README.md
Normal file
@@ -0,0 +1,232 @@
|
|||||||
|
# den.js
|
||||||
|
|
||||||
|
An invisible kobold lives on your website.
|
||||||
|
|
||||||
|
You never see him. You see his paw prints, his little dialogue, and — after ten
|
||||||
|
minutes of quiet — the bed he drags out to sleep in. He walks to your cursor,
|
||||||
|
gets bored and wanders off to sniff at things, remembers you between visits, and
|
||||||
|
depending on how well he knows you he will either steal your buttons and run, or
|
||||||
|
bring them to you as gifts.
|
||||||
|
|
||||||
|
One script tag. No dependencies. No build step required.
|
||||||
|
|
||||||
|
```html
|
||||||
|
<script src="/den.js"></script>
|
||||||
|
```
|
||||||
|
|
||||||
|
That's it. That's the integration.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What he does
|
||||||
|
|
||||||
|
**Movement.** He has a position, a heading, and a turn rate, so he can't snap
|
||||||
|
around instantly — chase him with your cursor and the prints carve an arc as he
|
||||||
|
wheels to follow. He trots when you're close, sprints when you're far, and lays
|
||||||
|
prints by distance travelled rather than on a timer, so his stride stays
|
||||||
|
physically consistent at any framerate.
|
||||||
|
|
||||||
|
**Moods.** `chase` when your cursor moves, `rest` when he arrives, `wander` when
|
||||||
|
he gets bored, `errand` when he's been sent somewhere, `mischief` when he's
|
||||||
|
committing a crime. Emote bubbles float off him — `❗` when he notices you,
|
||||||
|
`👃` while sniffing, `💤` while dozing, hearts when he's fond of you.
|
||||||
|
|
||||||
|
**Sleep.** Ten minutes with no input and he yawns, walks the tight circle every
|
||||||
|
canid walks before lying down, and a bed fades in with a breathing lump under
|
||||||
|
the blanket. Any keypress, click, scroll, or mouse twitch wakes him — groggily.
|
||||||
|
|
||||||
|
**Memory.** Visit count, lifetime pets, and his name persist in `localStorage`.
|
||||||
|
Return after a week and he says so. Pet him ten times and he decides you're
|
||||||
|
friends; at twenty-five he starts lingering near your cursor instead of
|
||||||
|
wandering off, and his stealing turns into gift-giving.
|
||||||
|
|
||||||
|
**A shell.** A `>_ den` tab sits in the corner of every page. Open it and you can
|
||||||
|
`come`, `sit`, `pet`, `fetch`, `sleep`, `where`, `status`, or `name` him.
|
||||||
|
|
||||||
|
**Presence.** Optional. With `den.php` on the server he'll notice how many other
|
||||||
|
people are on the site and mention that he can smell them.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Install
|
||||||
|
|
||||||
|
Copy `src/den.js` (or a build from `build/`) to your web root and add the script
|
||||||
|
tag. Nothing else is required — he injects his own CSS and brings his own audio.
|
||||||
|
|
||||||
|
For the presence counter, also copy `src/den.php` to your web root, change the
|
||||||
|
secret at the top, and make sure PHP can write one file next to it:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
touch ../owo_presence.txt
|
||||||
|
chown www-data:www-data ../owo_presence.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
The storage file lives one level *above* the document root so it can never be
|
||||||
|
fetched over HTTP.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Page integration
|
||||||
|
|
||||||
|
Everything beyond the script tag is declarative markup.
|
||||||
|
|
||||||
|
### Points of interest
|
||||||
|
|
||||||
|
Mark anything you want him to visit and comment on. Pipe-separate alternate
|
||||||
|
lines and he'll pick one at random:
|
||||||
|
|
||||||
|
```html
|
||||||
|
<div class="panel" data-den-poi="yip (sign it)|yip yip (write something)">
|
||||||
|
```
|
||||||
|
|
||||||
|
When he decides to wander, there's a 45% chance he heads for a POI that's
|
||||||
|
currently on screen instead of a random point. He skips whichever one he visited
|
||||||
|
last, so he explores rather than fixating.
|
||||||
|
|
||||||
|
### Stealable things
|
||||||
|
|
||||||
|
Nothing is stealable unless you say so:
|
||||||
|
|
||||||
|
```html
|
||||||
|
<span class="badge" data-den-steal>free gifs</span>
|
||||||
|
```
|
||||||
|
|
||||||
|
He self-limits to elements between 8–220px wide and 8–90px tall, and only ones
|
||||||
|
currently visible. The original element is hidden and a fixed-position clone
|
||||||
|
follows him, so the page layout doesn't reflow while he's got it. **Everything
|
||||||
|
always comes back** — there are timeouts on every phase, and clicking him
|
||||||
|
returns the item instantly.
|
||||||
|
|
||||||
|
### Showing the presence count
|
||||||
|
|
||||||
|
```html
|
||||||
|
<span data-den-others></span>
|
||||||
|
```
|
||||||
|
|
||||||
|
Filled in on load and refreshed every 60 seconds.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Events
|
||||||
|
|
||||||
|
Pages tell him what happened; he decides how to feel about it.
|
||||||
|
|
||||||
|
```js
|
||||||
|
denEvent('celebrate', { text: 'YIP!! (a new signature!!)' });
|
||||||
|
denEvent('investigate', { el: '#guestbook', text: 'yip? (what is this)' });
|
||||||
|
denEvent('investigate', { x: 400, y: 300, run: true });
|
||||||
|
denEvent('alarm', { text: 'YIP?! (INTRUDER)' });
|
||||||
|
denEvent('mischief');
|
||||||
|
denEvent('sleepy');
|
||||||
|
denEvent('zoomies');
|
||||||
|
```
|
||||||
|
|
||||||
|
`celebrate` is a happy spin with a heart burst. `investigate` sends him on an
|
||||||
|
errand — while on one he ignores your cursor, because he's busy. `alarm` startles
|
||||||
|
him into a panicked sprint.
|
||||||
|
|
||||||
|
Lower-level helpers, if you want to drive him directly:
|
||||||
|
|
||||||
|
```js
|
||||||
|
denSay('yip!!'); // speech bubble at his current position
|
||||||
|
denYip(); // the synthesized squeak
|
||||||
|
denSetName('crumb'); // returns the sanitized name, or null
|
||||||
|
denGetName();
|
||||||
|
denOthers; // number, current presence count
|
||||||
|
```
|
||||||
|
|
||||||
|
### A worked example
|
||||||
|
|
||||||
|
Redirect after a successful form post with a marker, then:
|
||||||
|
|
||||||
|
```js
|
||||||
|
const q = new URLSearchParams(location.search);
|
||||||
|
if (q.get('signed') === '1') {
|
||||||
|
history.replaceState(null, '', location.pathname);
|
||||||
|
setTimeout(() => denEvent('celebrate', { text: 'YIP!! (for the hoard!!)' }), 3400);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The delay lets him finish climbing out of his burrow first.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Secret codes
|
||||||
|
|
||||||
|
Typed anywhere outside a text field:
|
||||||
|
|
||||||
|
| type | he says |
|
||||||
|
| --- | --- |
|
||||||
|
| `yip` | `yip!!` |
|
||||||
|
| `owo` | `*notices u*` |
|
||||||
|
| `uwu` | `yip (no)` |
|
||||||
|
| `kobold` | `yip!! (that is me)` |
|
||||||
|
| `shrine` | `YIP?! (how do u know about that)` |
|
||||||
|
| `zoomies` | ten seconds of chaos |
|
||||||
|
| `sleep` | bedtime |
|
||||||
|
| ↑↑↓↓←→←→BA | same as `zoomies` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Building
|
||||||
|
|
||||||
|
Optional — `src/den.js` runs fine as-is. Builds are for size and, if you want it,
|
||||||
|
hiding the surprises above from anyone who opens view-source.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm install -g terser javascript-obfuscator
|
||||||
|
./build.fish
|
||||||
|
```
|
||||||
|
|
||||||
|
| file | size | notes |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `src/den.js` | 33 KB | readable source, the one you edit |
|
||||||
|
| `build/den.min.js` | 20 KB | minified; recommended |
|
||||||
|
| `build/den.obf.js` | 88 KB | string-encoded and control-flow flattened |
|
||||||
|
|
||||||
|
The obfuscated build hides all his dialogue and secrets from casual reading, at
|
||||||
|
4x the file size. Nothing stops a determined reader — this is spoiler protection,
|
||||||
|
not security.
|
||||||
|
|
||||||
|
`build.fish` also bumps the `?v=N` cache-buster in any `.php` file that
|
||||||
|
references a build, because forgetting that will cost you an afternoon
|
||||||
|
convincing yourself the code is broken when the browser is simply serving you
|
||||||
|
last week's kobold.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
`test/sim.js` runs him headless in jsdom with a mocked clock, so you can step
|
||||||
|
through behaviour that would otherwise take ten minutes of sitting still:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm install jsdom
|
||||||
|
node test/sim.js # readable source
|
||||||
|
node test/sim.js build/den.obf.js # verify a build still works
|
||||||
|
```
|
||||||
|
|
||||||
|
It drives a full mischief cycle and asserts the stolen element is restored.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- Respects `prefers-reduced-motion` — under it he doesn't spawn at all, and the
|
||||||
|
API becomes no-ops so your page code never errors.
|
||||||
|
- Everything is `position: fixed` with `pointer-events: none`, so he can't
|
||||||
|
intercept clicks or affect layout.
|
||||||
|
- Petting ignores clicks on links, buttons, and form fields.
|
||||||
|
- If `den.php` is missing, presence silently no-ops.
|
||||||
|
- He injects `text-align: left` on his own UI, because inheritable properties
|
||||||
|
leak in from host pages. If something of his looks off on a new page, check
|
||||||
|
what the host's `body` is setting.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT — see [LICENSE](LICENSE).
|
||||||
|
|
||||||
|
Built for [owo.ing](https://owo.ing). If you put him on your site, he's yours
|
||||||
|
now. Name him something good.
|
||||||
70
build.fish
Executable file
70
build.fish
Executable file
@@ -0,0 +1,70 @@
|
|||||||
|
#!/usr/bin/env fish
|
||||||
|
# den.js build: minify, obfuscate, syntax-check, bump cache version.
|
||||||
|
# needs: npm install -g terser javascript-obfuscator
|
||||||
|
|
||||||
|
set -l src src/den.js
|
||||||
|
set -l min build/den.min.js
|
||||||
|
set -l obf build/den.obf.js
|
||||||
|
set -l banner "/* den.js - an invisible kobold. (c) 2026 owo.ing. hands off the hoard. */"
|
||||||
|
|
||||||
|
if not test -f $src
|
||||||
|
echo "no $src here. run this from the repo root."
|
||||||
|
exit 1
|
||||||
|
end
|
||||||
|
|
||||||
|
mkdir -p build
|
||||||
|
|
||||||
|
echo ":: minifying"
|
||||||
|
terser $src --compress passes=2 --mangle --format ascii_only=true -o $min
|
||||||
|
or begin
|
||||||
|
echo "terser failed"
|
||||||
|
exit 1
|
||||||
|
end
|
||||||
|
|
||||||
|
echo ":: obfuscating"
|
||||||
|
javascript-obfuscator $src --output $obf \
|
||||||
|
--compact true \
|
||||||
|
--self-defending true \
|
||||||
|
--string-array true --string-array-encoding base64 --string-array-threshold 0.9 \
|
||||||
|
--string-array-rotate true --string-array-shuffle true \
|
||||||
|
--control-flow-flattening true --control-flow-flattening-threshold 0.4 \
|
||||||
|
--dead-code-injection true --dead-code-injection-threshold 0.2 \
|
||||||
|
--unicode-escape-sequence false
|
||||||
|
or begin
|
||||||
|
echo "obfuscator failed"
|
||||||
|
exit 1
|
||||||
|
end
|
||||||
|
|
||||||
|
echo ":: syntax check"
|
||||||
|
node --check $min; and node --check $obf
|
||||||
|
or begin
|
||||||
|
echo "SYNTAX CHECK FAILED - not touching your pages"
|
||||||
|
exit 1
|
||||||
|
end
|
||||||
|
|
||||||
|
# banner goes on after the tools run, or they'd strip it
|
||||||
|
for f in $min $obf
|
||||||
|
set -l tmp (mktemp)
|
||||||
|
echo $banner > $tmp
|
||||||
|
cat $f >> $tmp
|
||||||
|
mv $tmp $f
|
||||||
|
end
|
||||||
|
|
||||||
|
# bump ?v=N wherever a build is referenced, so browsers actually refetch
|
||||||
|
set -l bumped 0
|
||||||
|
for f in *.php example/*.html example/*.php
|
||||||
|
test -f $f; or continue
|
||||||
|
set -l cur (grep -oP 'den\.(min|obf)\.js\?v=\K\d+' $f | head -1)
|
||||||
|
if test -n "$cur"
|
||||||
|
set -l new (math $cur + 1)
|
||||||
|
sed -i "s/\(den\.\(min\|obf\)\.js?v=\)$cur/\1$new/g" $f
|
||||||
|
echo " $f: v$cur -> v$new"
|
||||||
|
set bumped (math $bumped + 1)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if test $bumped -eq 0
|
||||||
|
echo " (no pages with ?v= found - remember to bust cache yourself)"
|
||||||
|
end
|
||||||
|
|
||||||
|
echo ":: done"
|
||||||
|
ls -la $src $min $obf | awk '{printf " %8s %s\n", $5, $9}'
|
||||||
2
build/den.min.js
vendored
Normal file
2
build/den.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
2
build/den.obf.js
Normal file
2
build/den.obf.js
Normal file
File diff suppressed because one or more lines are too long
126
example/index.html
Normal file
126
example/index.html
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>den.js — there is a kobold on this page</title>
|
||||||
|
<style>
|
||||||
|
:root{
|
||||||
|
--bg1:#2a1e3d; --bg2:#3d2a55; --panel:#241a36; --panel2:#2f2145;
|
||||||
|
--pink:#ffb3de; --mint:#a8ffe0; --lav:#c9b8ff; --hot:#ff6ec7;
|
||||||
|
--ink:#f2e9ff; --dim:#9d8bc4;
|
||||||
|
}
|
||||||
|
*{box-sizing:border-box;}
|
||||||
|
body{
|
||||||
|
margin:0; padding:24px 16px 80px;
|
||||||
|
color:var(--ink);
|
||||||
|
font-family:"Comic Sans MS","Comic Sans","Segoe UI",sans-serif;
|
||||||
|
background:
|
||||||
|
radial-gradient(1.5px 1.5px at 30px 40px,#ffffffcc,transparent),
|
||||||
|
radial-gradient(1px 1px at 120px 90px,#ffb3decc,transparent),
|
||||||
|
radial-gradient(2px 2px at 200px 30px,#a8ffe0aa,transparent),
|
||||||
|
linear-gradient(160deg,var(--bg1),var(--bg2) 60%,#1e1530);
|
||||||
|
background-size:260px 200px,260px 200px,260px 200px,cover;
|
||||||
|
background-attachment:fixed;
|
||||||
|
}
|
||||||
|
.wrap{max-width:720px;margin:0 auto;}
|
||||||
|
h1{
|
||||||
|
font-size:clamp(34px,8vw,58px); margin:0; text-align:center;
|
||||||
|
background:linear-gradient(90deg,var(--pink),var(--mint),var(--lav),var(--pink));
|
||||||
|
-webkit-background-clip:text; background-clip:text; color:transparent;
|
||||||
|
filter:drop-shadow(3px 3px 0 #00000088);
|
||||||
|
}
|
||||||
|
.sub{text-align:center;color:var(--dim);font-size:13px;margin:6px 0 20px;}
|
||||||
|
.panel{
|
||||||
|
border:3px ridge var(--lav); border-radius:12px;
|
||||||
|
background:var(--panel); padding:14px 16px; margin-bottom:12px;
|
||||||
|
}
|
||||||
|
.panel.alt{background:var(--panel2);}
|
||||||
|
h2{font-size:16px;margin:0 0 8px;color:var(--mint);border-bottom:2px dashed var(--pink);padding-bottom:4px;}
|
||||||
|
h2::before{content:"🐾 ";}
|
||||||
|
p{font-size:14px;line-height:1.5;margin:6px 0;}
|
||||||
|
code{background:#1b1229;color:var(--mint);padding:1px 5px;border-radius:4px;font-size:12px;}
|
||||||
|
pre{background:#0d0818;border:2px inset #6b5a8a;border-radius:8px;padding:10px;overflow-x:auto;}
|
||||||
|
pre code{background:none;padding:0;}
|
||||||
|
.loot{display:flex;flex-wrap:wrap;gap:6px;justify-content:center;margin-top:8px;}
|
||||||
|
.badge{
|
||||||
|
width:88px;height:31px;display:flex;align-items:center;justify-content:center;
|
||||||
|
font-size:9px;font-weight:bold;text-align:center;font-family:Verdana,Arial,sans-serif;
|
||||||
|
border:1px solid #fff;line-height:1.1;
|
||||||
|
}
|
||||||
|
.b1{background:#000;color:#0f0;} .b2{background:linear-gradient(#37276b,#1e1443);color:var(--mint);}
|
||||||
|
.b3{background:#fff3a8;color:#1e1530;} .b4{background:linear-gradient(90deg,var(--hot),var(--mint));color:#1e1530;}
|
||||||
|
button{
|
||||||
|
font-family:inherit;font-size:13px;font-weight:bold;color:#1e1530;
|
||||||
|
background:linear-gradient(180deg,var(--pink),var(--hot));
|
||||||
|
border:3px outset #ffd6ef;border-radius:10px;padding:5px 12px;cursor:pointer;margin:3px;
|
||||||
|
}
|
||||||
|
button:active{border-style:inset;}
|
||||||
|
kbd{background:#1b1229;border:1px solid #6b5a8a;border-radius:4px;padding:1px 5px;font-size:11px;}
|
||||||
|
.hint{font-size:12px;color:var(--dim);}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="wrap">
|
||||||
|
|
||||||
|
<h1>den.js</h1>
|
||||||
|
<p class="sub">there is a kobold on this page. you cannot see him. move your mouse.</p>
|
||||||
|
|
||||||
|
<div class="panel" data-den-poi="yip (this is the part about me)|yip!!">
|
||||||
|
<h2>hello</h2>
|
||||||
|
<p>He is invisible. What you can see is his paw prints, his little comments,
|
||||||
|
and — if you leave him alone long enough — the bed he drags out.</p>
|
||||||
|
<p class="hint">Give him a moment to climb out of his burrow.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="panel alt" data-den-poi="yip? (are they talking about me)">
|
||||||
|
<h2>try this</h2>
|
||||||
|
<p><b>Move your cursor</b> — he comes to it. Stop, and he waits, then gets bored and wanders off.</p>
|
||||||
|
<p><b>Click him</b> — where the prints are, not where your cursor is. He yips and spins. Do it ten times and he decides you're friends.</p>
|
||||||
|
<p><b>Type <code>yip</code></b> — anywhere on the page. He answers.</p>
|
||||||
|
<p><b>Open the <code>>_ den</code> tab</b> in the corner, then try <code>come</code>, <code>fetch</code>, <code>status</code>, or <code>name crumb</code>.</p>
|
||||||
|
<p><b>Press</b> <kbd>↑</kbd><kbd>↑</kbd><kbd>↓</kbd><kbd>↓</kbd><kbd>←</kbd><kbd>→</kbd><kbd>←</kbd><kbd>→</kbd><kbd>B</kbd><kbd>A</kbd> — or just type <code>zoomies</code>.</p>
|
||||||
|
<p><b>Wait ten minutes</b> without touching anything. He goes to bed.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="panel" data-den-poi="yip (mine)|yip!! (do not touch)">
|
||||||
|
<h2>things he can steal</h2>
|
||||||
|
<p>These are marked <code>data-den-steal</code>. Nothing is stealable unless
|
||||||
|
you say so. Hit <code>fetch</code> in the shell to make him take one now —
|
||||||
|
he always brings it back.</p>
|
||||||
|
<div class="loot">
|
||||||
|
<span class="badge b1" data-den-steal>NOTEPAD.EXE</span>
|
||||||
|
<span class="badge b2" data-den-steal>apache 4 life</span>
|
||||||
|
<span class="badge b3" data-den-steal>Y2K COMPLIANT</span>
|
||||||
|
<span class="badge b4" data-den-steal>free gifs</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="panel alt" data-den-poi="yip? (what does this one do)">
|
||||||
|
<h2>events</h2>
|
||||||
|
<p>Pages tell him what happened; he decides how to feel about it.</p>
|
||||||
|
<p style="text-align:center;">
|
||||||
|
<button onclick="denEvent('celebrate',{text:'YIP!! (something good happened)'})">celebrate</button>
|
||||||
|
<button onclick="denEvent('investigate',{el:'#thing',text:'yip? (what is this)'})">investigate</button>
|
||||||
|
<button onclick="denEvent('alarm',{text:'YIP?! (INTRUDER)'})">alarm</button>
|
||||||
|
<button onclick="denEvent('mischief')">mischief</button>
|
||||||
|
<button onclick="denEvent('sleepy')">sleepy</button>
|
||||||
|
</p>
|
||||||
|
<p id="thing" class="hint" style="text-align:center;">↑ "investigate" sends him to this line, specifically.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="panel">
|
||||||
|
<h2>the whole integration</h2>
|
||||||
|
<pre><code><script src="/den.js"></script></code></pre>
|
||||||
|
<p>Optional markup:</p>
|
||||||
|
<pre><code><div data-den-poi="yip (sign it)"> <!-- he visits and comments -->
|
||||||
|
<span data-den-steal> <!-- he may take this -->
|
||||||
|
<span data-den-others></span> <!-- how many others are here --></code></pre>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="sub">MIT licensed. if you put him on your site, he's yours now.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="../src/den.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
17
package.json
Normal file
17
package.json
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"name": "den.js",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "An invisible kobold lives on your website.",
|
||||||
|
"main": "src/den.js",
|
||||||
|
"files": ["src/", "build/", "README.md", "LICENSE"],
|
||||||
|
"scripts": {
|
||||||
|
"test": "node test/sim.js",
|
||||||
|
"test:min": "node test/sim.js build/den.min.js",
|
||||||
|
"test:obf": "node test/sim.js build/den.obf.js"
|
||||||
|
},
|
||||||
|
"keywords": ["cursor", "mascot", "kobold", "indieweb", "neocities", "vanilla-js"],
|
||||||
|
"license": "MIT",
|
||||||
|
"devDependencies": {
|
||||||
|
"jsdom": "^24.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
873
src/den.js
Normal file
873
src/den.js
Normal file
@@ -0,0 +1,873 @@
|
|||||||
|
(function(){
|
||||||
|
if (window.__denCritter) return;
|
||||||
|
window.__denCritter = true;
|
||||||
|
|
||||||
|
var st = document.createElement('style');
|
||||||
|
st.textContent =
|
||||||
|
'.paw-trail{position:fixed;pointer-events:none;z-index:9999;animation:pawfade 1s linear forwards;filter:drop-shadow(0 0 3px currentColor);}' +
|
||||||
|
'@keyframes pawfade{0%{opacity:.9;transform:scale(1);}100%{opacity:0;transform:scale(.5) translateY(10px);}}' +
|
||||||
|
'.emote{position:fixed;pointer-events:none;z-index:9999;font-size:15px;animation:emoterise 1.2s ease-out forwards;}' +
|
||||||
|
'.emote.say{font-size:12px;font-weight:bold;color:#c9b8ff;text-shadow:0 0 6px #8f7bff,1px 1px 0 #120a20;white-space:nowrap;font-family:"Comic Sans MS","Comic Sans",cursive;}' +
|
||||||
|
'@keyframes emoterise{0%{opacity:0;transform:translateY(0) scale(.6);}15%{opacity:1;}100%{opacity:0;transform:translateY(-34px) scale(1.15);}}' +
|
||||||
|
'.denbed{position:fixed;z-index:9998;pointer-events:none;animation:bedin .6s ease-out;transition:opacity .45s ease;}' +
|
||||||
|
'@keyframes bedin{from{opacity:0;transform:translateY(10px);}to{opacity:1;transform:translateY(0);}}' +
|
||||||
|
'.denbed .lump{animation:breathe 2.8s ease-in-out infinite;transform-origin:38px 24px;}' +
|
||||||
|
'@keyframes breathe{0%,100%{transform:scaleY(1);}50%{transform:scaleY(1.17);}}' +
|
||||||
|
'body.denzoom *,body.denzoom *::before,body.denzoom *::after{animation-duration:.22s !important;}' +
|
||||||
|
'.denhole{position:fixed;z-index:9997;pointer-events:none;transition:opacity .55s ease;animation:holein .5s ease-out;}' +
|
||||||
|
'@keyframes holein{from{opacity:0;transform:scale(.6);}to{opacity:1;transform:scale(1);}}' +
|
||||||
|
'.dentab{position:fixed;right:14px;bottom:0;text-align:left;z-index:10000;background:#1b1229;color:#8f7bff;border:2px ridge #6b5a8a;border-bottom:none;border-radius:8px 8px 0 0;font-family:"Courier New",monospace;font-size:11px;padding:3px 10px;cursor:pointer;opacity:.55;transition:opacity .2s;user-select:none;}' +
|
||||||
|
'.dentab:hover{opacity:1;}' +
|
||||||
|
'.denterm{position:fixed;right:8px;bottom:0;z-index:10000;width:300px;max-width:86vw;background:#0d0818;border:3px ridge #6b5a8a;border-bottom:none;border-radius:10px 10px 0 0;font-family:"Courier New",monospace;font-size:12px;padding:8px;display:none;text-align:left;}' +
|
||||||
|
'.denterm.open{display:block;}' +
|
||||||
|
'.denterm .dout{height:170px;overflow-y:auto;white-space:pre-wrap;word-break:break-word;color:#c9b8ff;}' +
|
||||||
|
'.denterm .dline{display:flex;gap:6px;margin-top:5px;align-items:center;}' +
|
||||||
|
'.denterm .dprompt{color:#ff6ec7;white-space:nowrap;}' +
|
||||||
|
'.denterm input{flex:1;background:transparent;border:none;outline:none;color:#a8ffe0;font-family:inherit;font-size:12px;}';
|
||||||
|
document.head.appendChild(st);
|
||||||
|
|
||||||
|
var actx = null;
|
||||||
|
function yipSound(){
|
||||||
|
try {
|
||||||
|
if (!actx) actx = new (window.AudioContext || window.webkitAudioContext)();
|
||||||
|
if (actx.state === 'suspended') actx.resume();
|
||||||
|
var o = actx.createOscillator(), g = actx.createGain();
|
||||||
|
var t = actx.currentTime;
|
||||||
|
o.type = 'sine';
|
||||||
|
o.frequency.setValueAtTime(700 + Math.random() * 200, t);
|
||||||
|
o.frequency.exponentialRampToValueAtTime(1400 + Math.random() * 300, t + 0.07);
|
||||||
|
o.frequency.exponentialRampToValueAtTime(600, t + 0.14);
|
||||||
|
g.gain.setValueAtTime(0.12, t);
|
||||||
|
g.gain.exponentialRampToValueAtTime(0.001, t + 0.16);
|
||||||
|
o.connect(g); g.connect(actx.destination);
|
||||||
|
o.start(t); o.stop(t + 0.18);
|
||||||
|
} catch(e){}
|
||||||
|
}
|
||||||
|
window.denYip = yipSound;
|
||||||
|
|
||||||
|
if (window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
|
||||||
|
window.denSay = function(){};
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var PAW = '<svg width="14" height="15" viewBox="0 0 20 21" xmlns="http://www.w3.org/2000/svg">' +
|
||||||
|
'<ellipse cx="10" cy="14.5" rx="5.2" ry="4.6"/>' +
|
||||||
|
'<ellipse cx="3.2" cy="8.6" rx="2.1" ry="2.7"/>' +
|
||||||
|
'<ellipse cx="7.6" cy="5.4" rx="2.1" ry="2.8"/>' +
|
||||||
|
'<ellipse cx="12.4" cy="5.4" rx="2.1" ry="2.8"/>' +
|
||||||
|
'<ellipse cx="16.8" cy="8.6" rx="2.1" ry="2.7"/>' +
|
||||||
|
'</svg>';
|
||||||
|
var colors = ['#8f7bff', '#4fc3ff'];
|
||||||
|
var spawnEdge = Math.floor(Math.random() * 4);
|
||||||
|
var sx, sy;
|
||||||
|
if (spawnEdge === 0) { sx = 70; sy = 80 + Math.random() * Math.max(window.innerHeight - 160, 1); }
|
||||||
|
else if (spawnEdge === 1) { sx = window.innerWidth - 70; sy = 80 + Math.random() * Math.max(window.innerHeight - 160, 1); }
|
||||||
|
else if (spawnEdge === 2) { sx = 80 + Math.random() * Math.max(window.innerWidth - 160, 1); sy = 70; }
|
||||||
|
else { sx = 80 + Math.random() * Math.max(window.innerWidth - 160, 1); sy = window.innerHeight - 70; }
|
||||||
|
var pos = { x: sx, y: sy };
|
||||||
|
var heading = Math.random() * Math.PI * 2;
|
||||||
|
var target = { x: pos.x, y: pos.y };
|
||||||
|
var mode = 'rest';
|
||||||
|
var restUntil = performance.now() + 800;
|
||||||
|
var petUntil = 0;
|
||||||
|
var nextZzz = performance.now() + 99999;
|
||||||
|
var lastEmote = 0;
|
||||||
|
var side = 1, distSince = 0;
|
||||||
|
var lastT = performance.now();
|
||||||
|
var WALK = 70, TROT = 150, RUN = 340;
|
||||||
|
var STRIDE_WALK = 24, STRIDE_RUN = 38;
|
||||||
|
var TURN = 4.0;
|
||||||
|
var SLEEPY_AFTER = 600000;
|
||||||
|
var lastActivity = performance.now();
|
||||||
|
var sleepPrepUntil = 0, wakeUntil = 0, suppressBumpUntil = 0;
|
||||||
|
var asleep = false, bedEl = null, nextSleepZ = 0;
|
||||||
|
var speedMult = 1;
|
||||||
|
var poiSay = null, lastPoi = null;
|
||||||
|
var errandTarget = null, errandSay = null, errandRun = false, errandDeadline = 0;
|
||||||
|
var mis = null, lastMischief = 0;
|
||||||
|
var dout = null, dinp = null, dpr = null;
|
||||||
|
|
||||||
|
var mem = { v: 0, pets: 0, name: null, last: 0, id: null };
|
||||||
|
try {
|
||||||
|
var m0 = JSON.parse(localStorage.getItem('denmem'));
|
||||||
|
if (m0 && typeof m0 === 'object') {
|
||||||
|
mem.v = m0.v | 0;
|
||||||
|
mem.pets = m0.pets | 0;
|
||||||
|
mem.name = (typeof m0.name === 'string' && m0.name) ? m0.name.slice(0, 16) : null;
|
||||||
|
mem.last = m0.last || 0;
|
||||||
|
if (typeof m0.id === 'string' && /^[a-f0-9]{16}$/.test(m0.id)) mem.id = m0.id;
|
||||||
|
}
|
||||||
|
} catch(e){}
|
||||||
|
var prevLast = mem.last;
|
||||||
|
mem.v++;
|
||||||
|
mem.last = Date.now();
|
||||||
|
if (!mem.id) {
|
||||||
|
var idb = '';
|
||||||
|
try {
|
||||||
|
var ida = new Uint8Array(8);
|
||||||
|
crypto.getRandomValues(ida);
|
||||||
|
for (var ii = 0; ii < 8; ii++) idb += ('0' + ida[ii].toString(16)).slice(-2);
|
||||||
|
} catch(e) {
|
||||||
|
for (var ij = 0; ij < 16; ij++) idb += Math.floor(Math.random() * 16).toString(16);
|
||||||
|
}
|
||||||
|
mem.id = idb;
|
||||||
|
}
|
||||||
|
function saveMem(){
|
||||||
|
try { localStorage.setItem('denmem', JSON.stringify(mem)); } catch(e){}
|
||||||
|
}
|
||||||
|
saveMem();
|
||||||
|
|
||||||
|
function greeting(){
|
||||||
|
var away = prevLast ? (Date.now() - prevLast) : 0;
|
||||||
|
if (mem.v <= 1) return '*yip* (hello)';
|
||||||
|
if (away > 604800000) return 'YIP!! (where were u)';
|
||||||
|
if (mem.name) return '*yip* (' + mem.name + ' missed u)';
|
||||||
|
if (mem.v >= 8) return 'yip (welcome home)';
|
||||||
|
return 'yip!! (u came back)';
|
||||||
|
}
|
||||||
|
|
||||||
|
window.denSetName = function(n){
|
||||||
|
n = String(n || '').replace(/[^\w \-]/g, '').trim().slice(0, 16);
|
||||||
|
if (!n) return null;
|
||||||
|
mem.name = n;
|
||||||
|
saveMem();
|
||||||
|
sayText('yip!! (i am ' + n + ')');
|
||||||
|
emote('\uD83D\uDC96', true);
|
||||||
|
if (dpr) dpr.textContent = promptTxt();
|
||||||
|
return n;
|
||||||
|
};
|
||||||
|
window.denGetName = function(){ return mem.name; };
|
||||||
|
|
||||||
|
function promptTxt(){ return (mem.name || 'guest') + '@den ~>'; }
|
||||||
|
|
||||||
|
function denPhrase(n){
|
||||||
|
if (n <= 0) return 'just u + the kobold rn';
|
||||||
|
if (n === 1) return '1 other is here rn';
|
||||||
|
return n + ' others are here rn';
|
||||||
|
}
|
||||||
|
function checkPresence(){
|
||||||
|
if (!window.fetch) return;
|
||||||
|
fetch('/den.php?id=' + mem.id, { cache: 'no-store' }).then(function(r){
|
||||||
|
if (!r.ok) return null;
|
||||||
|
return r.json();
|
||||||
|
}).then(function(j){
|
||||||
|
if (!j) return;
|
||||||
|
var n = j.others | 0;
|
||||||
|
window.denOthers = n;
|
||||||
|
var els = document.querySelectorAll('[data-den-others]');
|
||||||
|
for (var i = 0; i < els.length; i++) els[i].textContent = denPhrase(n);
|
||||||
|
}).catch(function(){});
|
||||||
|
}
|
||||||
|
|
||||||
|
function pois(){
|
||||||
|
var out = [];
|
||||||
|
var els = document.querySelectorAll('[data-den-poi]');
|
||||||
|
for (var i = 0; i < els.length; i++) {
|
||||||
|
var r = els[i].getBoundingClientRect();
|
||||||
|
if (r.width < 10 || r.height < 10) continue;
|
||||||
|
if (r.bottom < 40 || r.top > window.innerHeight - 40) continue;
|
||||||
|
if (r.right < 40 || r.left > window.innerWidth - 40) continue;
|
||||||
|
out.push({ el: els[i], r: r });
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function emote(ch, force){
|
||||||
|
var now = performance.now();
|
||||||
|
if (!force && now - lastEmote < 500) return;
|
||||||
|
lastEmote = now;
|
||||||
|
var el = document.createElement('span');
|
||||||
|
el.className = 'emote';
|
||||||
|
el.textContent = ch;
|
||||||
|
el.style.left = (pos.x + (Math.random() * 16 - 8)) + 'px';
|
||||||
|
el.style.top = (pos.y - 18) + 'px';
|
||||||
|
document.body.appendChild(el);
|
||||||
|
setTimeout(function(){ el.remove(); }, 1250);
|
||||||
|
}
|
||||||
|
|
||||||
|
function pick(arr){ return arr[Math.floor(Math.random() * arr.length)]; }
|
||||||
|
|
||||||
|
function sayText(t){
|
||||||
|
var el = document.createElement('span');
|
||||||
|
el.className = 'emote say';
|
||||||
|
el.textContent = t;
|
||||||
|
el.style.left = (pos.x - 10) + 'px';
|
||||||
|
el.style.top = (pos.y - 22) + 'px';
|
||||||
|
document.body.appendChild(el);
|
||||||
|
setTimeout(function(){ el.remove(); }, 1250);
|
||||||
|
}
|
||||||
|
window.denSay = sayText;
|
||||||
|
|
||||||
|
function makeBed(){
|
||||||
|
bedEl = document.createElement('div');
|
||||||
|
bedEl.className = 'denbed';
|
||||||
|
bedEl.innerHTML = '<svg width="64" height="42" viewBox="0 0 64 42" xmlns="http://www.w3.org/2000/svg">' +
|
||||||
|
'<rect x="2" y="24" width="60" height="11" rx="5" fill="#3d2a55" stroke="#8f7bff" stroke-width="2"/>' +
|
||||||
|
'<rect x="3" y="33" width="4" height="8" rx="1" fill="#8f7bff"/>' +
|
||||||
|
'<rect x="57" y="33" width="4" height="8" rx="1" fill="#8f7bff"/>' +
|
||||||
|
'<ellipse cx="13" cy="22" rx="9" ry="5" fill="#c9b8ff"/>' +
|
||||||
|
'<path class="lump" d="M23 26 Q33 10 53 23 L53 26 Z" fill="#4fc3ff" stroke="#8f7bff" stroke-width="1.5"/>' +
|
||||||
|
'</svg>';
|
||||||
|
bedEl.style.left = (pos.x - 32) + 'px';
|
||||||
|
bedEl.style.top = (pos.y - 26) + 'px';
|
||||||
|
document.body.appendChild(bedEl);
|
||||||
|
}
|
||||||
|
|
||||||
|
function bump(){
|
||||||
|
var now = performance.now();
|
||||||
|
if (now < suppressBumpUntil) return;
|
||||||
|
lastActivity = now;
|
||||||
|
if (sleepPrepUntil) sleepPrepUntil = 0;
|
||||||
|
if (asleep) {
|
||||||
|
asleep = false;
|
||||||
|
if (bedEl) {
|
||||||
|
var b = bedEl; bedEl = null;
|
||||||
|
b.style.opacity = '0';
|
||||||
|
setTimeout(function(){ b.remove(); }, 500);
|
||||||
|
}
|
||||||
|
emote('\u2728', true);
|
||||||
|
sayText('...yip?');
|
||||||
|
wakeUntil = now + 900;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
document.addEventListener('keydown', bump);
|
||||||
|
document.addEventListener('scroll', bump, { passive: true });
|
||||||
|
document.addEventListener('touchstart', bump, { passive: true });
|
||||||
|
|
||||||
|
document.addEventListener('mousemove', function(e){
|
||||||
|
target.x = e.clientX; target.y = e.clientY;
|
||||||
|
bump();
|
||||||
|
if (mode === 'errand' || mode === 'mischief') return;
|
||||||
|
var now = performance.now();
|
||||||
|
if (now < petUntil || now < wakeUntil) return;
|
||||||
|
if (mode !== 'chase') emote('\u2757');
|
||||||
|
mode = 'chase';
|
||||||
|
});
|
||||||
|
|
||||||
|
function petHim(){
|
||||||
|
var now = performance.now();
|
||||||
|
if (mode === 'mischief') returnItem();
|
||||||
|
petUntil = now + 800;
|
||||||
|
mode = 'rest';
|
||||||
|
restUntil = now + 2000;
|
||||||
|
nextZzz = now + 99999;
|
||||||
|
emote(pick(['\uD83D\uDC95','\uD83D\uDC96','\uD83D\uDC97']), true);
|
||||||
|
setTimeout(function(){ emote('\uD83D\uDC95', true); }, 240);
|
||||||
|
yipSound();
|
||||||
|
mem.pets++;
|
||||||
|
saveMem();
|
||||||
|
if (mem.pets === 10) setTimeout(function(){ sayText('yip!! (we are friends now)'); }, 420);
|
||||||
|
else if (mem.pets === 25) setTimeout(function(){ sayText('yip (best friend)'); }, 420);
|
||||||
|
else if (mem.pets === 50) setTimeout(function(){ sayText('yip!! (bonded 4 life)'); }, 420);
|
||||||
|
else if (mem.pets === 100) setTimeout(function(){ sayText('yip (soulmates)'); }, 420);
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('click', function(e){
|
||||||
|
bump();
|
||||||
|
if (e.target.closest && e.target.closest('a,button,input,textarea,label,select')) return;
|
||||||
|
if (Math.hypot(e.clientX - pos.x, e.clientY - pos.y) > 48) return;
|
||||||
|
petHim();
|
||||||
|
});
|
||||||
|
|
||||||
|
function stealables(){
|
||||||
|
var out = [];
|
||||||
|
var els = document.querySelectorAll('[data-den-steal]');
|
||||||
|
for (var i = 0; i < els.length; i++) {
|
||||||
|
var r = els[i].getBoundingClientRect();
|
||||||
|
if (r.width < 8 || r.width > 220 || r.height < 8 || r.height > 90) continue;
|
||||||
|
if (r.bottom < 40 || r.top > window.innerHeight - 60) continue;
|
||||||
|
if (r.right < 40 || r.left > window.innerWidth - 60) continue;
|
||||||
|
if (els[i].style.visibility === 'hidden') continue;
|
||||||
|
out.push(els[i]);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function returnItem(){
|
||||||
|
if (mis && mis.clone) {
|
||||||
|
mis.el.style.visibility = '';
|
||||||
|
mis.clone.remove();
|
||||||
|
}
|
||||||
|
if (mis) {
|
||||||
|
mis = null;
|
||||||
|
mode = 'wander';
|
||||||
|
target = pickWander();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function startMischief(force){
|
||||||
|
var now = performance.now();
|
||||||
|
if (mis) return false;
|
||||||
|
if (!force && now - lastMischief < 150000) return false;
|
||||||
|
var cands = stealables();
|
||||||
|
if (!cands.length) return false;
|
||||||
|
if (!force && Math.random() > 0.07) return false;
|
||||||
|
var el = cands[Math.floor(Math.random() * cands.length)];
|
||||||
|
var r = el.getBoundingClientRect();
|
||||||
|
mis = {
|
||||||
|
el: el,
|
||||||
|
phase: 'togo',
|
||||||
|
pt: clampPt(r.left + r.width / 2, r.top + r.height / 2),
|
||||||
|
deadline: now + 18000,
|
||||||
|
gift: mem.pets >= 25
|
||||||
|
};
|
||||||
|
lastMischief = now;
|
||||||
|
mode = 'mischief';
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function grabItem(now){
|
||||||
|
var r = mis.el.getBoundingClientRect();
|
||||||
|
mis.clone = document.createElement('div');
|
||||||
|
mis.clone.style.cssText = 'position:fixed;z-index:9998;pointer-events:none;margin:0;left:' + r.left + 'px;top:' + r.top + 'px;width:' + r.width + 'px;height:' + r.height + 'px;';
|
||||||
|
var inner = mis.el.cloneNode(true);
|
||||||
|
inner.style.margin = '0';
|
||||||
|
mis.clone.appendChild(inner);
|
||||||
|
document.body.appendChild(mis.clone);
|
||||||
|
mis.el.style.visibility = 'hidden';
|
||||||
|
mis.cw = r.width / 2;
|
||||||
|
mis.ch = r.height;
|
||||||
|
if (mis.gift) {
|
||||||
|
sayText('yip!');
|
||||||
|
} else {
|
||||||
|
var aa = Math.random() * Math.PI * 2;
|
||||||
|
mis.dropPt = clampPt(pos.x + Math.cos(aa) * 300, pos.y + Math.sin(aa) * 300);
|
||||||
|
sayText(pick(['yip yip yip (mine now)', '(catch me)', 'yip!! (tax)']));
|
||||||
|
}
|
||||||
|
mis.phase = 'carry';
|
||||||
|
}
|
||||||
|
|
||||||
|
function clampPt(x, y){
|
||||||
|
return {
|
||||||
|
x: Math.min(Math.max(x, 20), window.innerWidth - 20),
|
||||||
|
y: Math.min(Math.max(y, 20), window.innerHeight - 20)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolvePoint(opts){
|
||||||
|
var el = opts.el;
|
||||||
|
if (typeof el === 'string') el = document.querySelector(el);
|
||||||
|
if (el && el.getBoundingClientRect) {
|
||||||
|
var r = el.getBoundingClientRect();
|
||||||
|
if (r.width > 0) return clampPt(r.left + r.width * 0.5, r.top + Math.min(r.height * 0.4, 50));
|
||||||
|
}
|
||||||
|
if (typeof opts.x === 'number' && typeof opts.y === 'number') return clampPt(opts.x, opts.y);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
window.denEvent = function(type, opts){
|
||||||
|
opts = opts || {};
|
||||||
|
var now = performance.now();
|
||||||
|
lastActivity = now;
|
||||||
|
if (asleep) bump();
|
||||||
|
if (mode === 'mischief' && type !== 'mischief') returnItem();
|
||||||
|
if (type === 'mischief') {
|
||||||
|
startMischief(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (type === 'celebrate') {
|
||||||
|
petUntil = now + 1100;
|
||||||
|
mode = 'rest';
|
||||||
|
restUntil = now + 2800;
|
||||||
|
nextZzz = now + 99999;
|
||||||
|
emote('\u2728', true);
|
||||||
|
setTimeout(function(){ emote('\uD83D\uDC95', true); }, 300);
|
||||||
|
setTimeout(function(){ emote('\u2728', true); }, 650);
|
||||||
|
setTimeout(function(){ sayText(opts.text || 'YIP!!'); }, 180);
|
||||||
|
yipSound();
|
||||||
|
} else if (type === 'investigate') {
|
||||||
|
var pt = resolvePoint(opts);
|
||||||
|
if (!pt) return;
|
||||||
|
errandTarget = pt;
|
||||||
|
errandSay = opts.text || null;
|
||||||
|
errandRun = !!opts.run;
|
||||||
|
errandDeadline = now + 9000;
|
||||||
|
mode = 'errand';
|
||||||
|
} else if (type === 'alarm') {
|
||||||
|
emote('\u26A0\uFE0F', true);
|
||||||
|
sayText(opts.text || 'YIP?!');
|
||||||
|
var aa = Math.random() * Math.PI * 2;
|
||||||
|
errandTarget = clampPt(pos.x + Math.cos(aa) * 260, pos.y + Math.sin(aa) * 260);
|
||||||
|
errandSay = null;
|
||||||
|
errandRun = true;
|
||||||
|
errandDeadline = now + 4000;
|
||||||
|
mode = 'errand';
|
||||||
|
} else if (type === 'sleepy') {
|
||||||
|
suppressBumpUntil = now + 800;
|
||||||
|
sleepPrepUntil = now + 1400;
|
||||||
|
sayText('*yawn*');
|
||||||
|
} else if (type === 'zoomies') {
|
||||||
|
zoomies();
|
||||||
|
} else if (opts.text) {
|
||||||
|
sayText(opts.text);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
function zoomies(){
|
||||||
|
document.body.classList.add('denzoom');
|
||||||
|
speedMult = 7.5;
|
||||||
|
var old = document.title;
|
||||||
|
document.title = 'YIP YIP YIP YIP YIP';
|
||||||
|
sayText('YIP YIP YIP YIP');
|
||||||
|
setTimeout(function(){
|
||||||
|
document.body.classList.remove('denzoom');
|
||||||
|
speedMult = 1;
|
||||||
|
document.title = old;
|
||||||
|
}, 10000);
|
||||||
|
}
|
||||||
|
|
||||||
|
var kseq = ['ArrowUp','ArrowUp','ArrowDown','ArrowDown','ArrowLeft','ArrowRight','ArrowLeft','ArrowRight','b','a'];
|
||||||
|
var kpos = 0;
|
||||||
|
document.addEventListener('keydown', function(e){
|
||||||
|
var k = e.key.length === 1 ? e.key.toLowerCase() : e.key;
|
||||||
|
kpos = (k === kseq[kpos]) ? kpos + 1 : (k === kseq[0] ? 1 : 0);
|
||||||
|
if (kpos !== kseq.length) return;
|
||||||
|
kpos = 0;
|
||||||
|
zoomies();
|
||||||
|
});
|
||||||
|
|
||||||
|
var buf = '';
|
||||||
|
document.addEventListener('keydown', function(e){
|
||||||
|
var t = e.target;
|
||||||
|
if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.isContentEditable)) return;
|
||||||
|
if (e.key.length !== 1) return;
|
||||||
|
buf = (buf + e.key.toLowerCase()).slice(-12);
|
||||||
|
if (buf.slice(-3) === 'yip') { sayText('yip!!'); buf = ''; }
|
||||||
|
else if (buf.slice(-3) === 'owo') { sayText('*notices u*'); buf = ''; }
|
||||||
|
else if (buf.slice(-3) === 'uwu') { sayText('yip (no)'); buf = ''; }
|
||||||
|
else if (buf.slice(-6) === 'kobold') { emote('\uD83D\uDC95', true); sayText('yip!! (that is me)'); buf = ''; }
|
||||||
|
else if (buf.slice(-6) === 'shrine') { sayText('YIP?! (how do u know about that)'); buf = ''; }
|
||||||
|
else if (buf.slice(-7) === 'zoomies') { zoomies(); buf = ''; }
|
||||||
|
else if (buf.slice(-5) === 'sleep') {
|
||||||
|
suppressBumpUntil = performance.now() + 800;
|
||||||
|
sleepPrepUntil = performance.now() + 1400;
|
||||||
|
sayText('*yawn*');
|
||||||
|
buf = '';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function angDiff(a, b){
|
||||||
|
var d = (b - a) % (Math.PI * 2);
|
||||||
|
if (d > Math.PI) d -= Math.PI * 2;
|
||||||
|
if (d < -Math.PI) d += Math.PI * 2;
|
||||||
|
return d;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickWander(){
|
||||||
|
poiSay = null;
|
||||||
|
if (Math.random() < 0.45) {
|
||||||
|
var ps = pois().filter(function(p){ return p.el !== lastPoi; });
|
||||||
|
if (ps.length) {
|
||||||
|
var p = ps[Math.floor(Math.random() * ps.length)];
|
||||||
|
lastPoi = p.el;
|
||||||
|
var txts = (p.el.getAttribute('data-den-poi') || '').split('|').filter(Boolean);
|
||||||
|
if (txts.length) poiSay = txts[Math.floor(Math.random() * txts.length)];
|
||||||
|
return {
|
||||||
|
x: Math.min(Math.max(p.r.left + p.r.width * (0.25 + Math.random() * 0.5), 20), window.innerWidth - 20),
|
||||||
|
y: Math.min(Math.max(p.r.top + Math.min(p.r.height * 0.4, 60) + Math.random() * 20, 20), window.innerHeight - 20)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var r = 60 + Math.random() * 120;
|
||||||
|
var a = heading + (Math.random() - 0.5) * 2.6;
|
||||||
|
return {
|
||||||
|
x: Math.min(Math.max(pos.x + Math.cos(a) * r, 20), window.innerWidth - 20),
|
||||||
|
y: Math.min(Math.max(pos.y + Math.sin(a) * r, 20), window.innerHeight - 20)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function drop(){
|
||||||
|
side = -side;
|
||||||
|
var p = document.createElement('span');
|
||||||
|
p.className = 'paw-trail';
|
||||||
|
p.innerHTML = PAW;
|
||||||
|
var c = colors[side > 0 ? 0 : 1];
|
||||||
|
p.querySelector('svg').style.fill = c;
|
||||||
|
p.style.color = c;
|
||||||
|
var deg = heading * 180 / Math.PI + 90;
|
||||||
|
var ox = -Math.sin(heading) * 7 * side;
|
||||||
|
var oy = Math.cos(heading) * 7 * side;
|
||||||
|
p.style.left = (pos.x + ox - 7) + 'px';
|
||||||
|
p.style.top = (pos.y + oy - 7) + 'px';
|
||||||
|
p.querySelector('svg').style.transform = 'rotate(' + deg + 'deg) scaleX(' + side + ')';
|
||||||
|
p.querySelector('svg').style.display = 'block';
|
||||||
|
document.body.appendChild(p);
|
||||||
|
setTimeout(function(){ p.remove(); }, 1300);
|
||||||
|
}
|
||||||
|
|
||||||
|
function tick(now){
|
||||||
|
var dt = Math.min((now - lastT) / 1000, 0.05);
|
||||||
|
lastT = now;
|
||||||
|
var zoom = speedMult;
|
||||||
|
|
||||||
|
if (asleep) {
|
||||||
|
if (now > nextSleepZ) { emote('\uD83D\uDCA4', true); nextSleepZ = now + 2600; }
|
||||||
|
requestAnimationFrame(tick);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (sleepPrepUntil) {
|
||||||
|
if (now < sleepPrepUntil) {
|
||||||
|
heading += 7 * dt;
|
||||||
|
var bs = WALK * 0.7 * dt;
|
||||||
|
pos.x += Math.cos(heading) * bs;
|
||||||
|
pos.y += Math.sin(heading) * bs;
|
||||||
|
distSince += bs;
|
||||||
|
if (distSince >= STRIDE_WALK * 0.8) { distSince = 0; drop(); }
|
||||||
|
} else {
|
||||||
|
sleepPrepUntil = 0;
|
||||||
|
asleep = true;
|
||||||
|
pos.x = Math.min(Math.max(pos.x, 44), window.innerWidth - 44);
|
||||||
|
pos.y = Math.min(Math.max(pos.y, 40), window.innerHeight - 30);
|
||||||
|
makeBed();
|
||||||
|
sayText('...zzz');
|
||||||
|
nextSleepZ = now + 1800;
|
||||||
|
}
|
||||||
|
requestAnimationFrame(tick);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (now - lastActivity > SLEEPY_AFTER && now > petUntil) {
|
||||||
|
sleepPrepUntil = now + 1400;
|
||||||
|
sayText('*yawn*');
|
||||||
|
requestAnimationFrame(tick);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (now < wakeUntil) {
|
||||||
|
requestAnimationFrame(tick);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (now < petUntil) {
|
||||||
|
heading += 9 * dt;
|
||||||
|
var st2 = WALK * 0.9 * zoom * dt;
|
||||||
|
pos.x += Math.cos(heading) * st2;
|
||||||
|
pos.y += Math.sin(heading) * st2;
|
||||||
|
distSince += st2;
|
||||||
|
if (distSince >= STRIDE_WALK * 0.8) { distSince = 0; drop(); }
|
||||||
|
requestAnimationFrame(tick);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mode === 'mischief') {
|
||||||
|
if (!mis) {
|
||||||
|
mode = 'wander';
|
||||||
|
target = pickWander();
|
||||||
|
requestAnimationFrame(tick);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (now > mis.deadline) {
|
||||||
|
if (mis.phase === 'carry') {
|
||||||
|
mis.phase = 'dropped';
|
||||||
|
mis.dropTime = now;
|
||||||
|
mis.deadline = now + 12000;
|
||||||
|
sayText(mis.gift ? '*wheeze* (u are fast. here)' : '*wheeze* (fine)');
|
||||||
|
} else {
|
||||||
|
returnItem();
|
||||||
|
requestAnimationFrame(tick);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (mis.phase === 'togo' || mis.phase === 'carry') {
|
||||||
|
var mdest;
|
||||||
|
if (mis.phase === 'togo') mdest = mis.pt;
|
||||||
|
else mdest = mis.gift ? clampPt(target.x, target.y) : mis.dropPt;
|
||||||
|
var mx = mdest.x - pos.x, my = mdest.y - pos.y;
|
||||||
|
var md = Math.hypot(mx, my);
|
||||||
|
if (md < 16) {
|
||||||
|
if (mis.phase === 'togo') {
|
||||||
|
grabItem(now);
|
||||||
|
} else {
|
||||||
|
mis.phase = 'dropped';
|
||||||
|
mis.dropTime = now;
|
||||||
|
if (mis.gift) {
|
||||||
|
sayText(pick(['uwu (for u)', 'yip (i brought u this)', '(i love u)']));
|
||||||
|
emote('\uD83D\uDC95', true);
|
||||||
|
setTimeout(function(){ emote('\uD83D\uDC96', true); }, 300);
|
||||||
|
} else {
|
||||||
|
sayText(pick(['yip (u can have it back)', '(hehe)', 'yip yip (a good crime)']));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
var mw = Math.atan2(my, mx);
|
||||||
|
var mmx = TURN * dt * 2.2;
|
||||||
|
heading += Math.max(-mmx, Math.min(mmx, angDiff(heading, mw)));
|
||||||
|
var msp = (mis.phase === 'carry' && !mis.gift ? RUN : TROT) * zoom;
|
||||||
|
var mst = (mis.phase === 'carry' && !mis.gift ? STRIDE_RUN : STRIDE_WALK);
|
||||||
|
var mstep = Math.min(msp * dt, md);
|
||||||
|
pos.x += Math.cos(heading) * mstep;
|
||||||
|
pos.y += Math.sin(heading) * mstep;
|
||||||
|
distSince += mstep;
|
||||||
|
if (distSince >= mst) { distSince = 0; drop(); }
|
||||||
|
}
|
||||||
|
if (mis && mis.clone && mis.phase === 'carry') {
|
||||||
|
mis.clone.style.left = (pos.x - mis.cw) + 'px';
|
||||||
|
mis.clone.style.top = (pos.y - mis.ch - 14) + 'px';
|
||||||
|
}
|
||||||
|
} else if (mis.phase === 'dropped') {
|
||||||
|
if (now > mis.dropTime + (mis.gift ? 4200 : 5200)) {
|
||||||
|
var hr = mis.el.getBoundingClientRect();
|
||||||
|
mis.clone.style.transition = 'left .6s ease, top .6s ease, opacity .6s ease';
|
||||||
|
mis.clone.style.left = hr.left + 'px';
|
||||||
|
mis.clone.style.top = hr.top + 'px';
|
||||||
|
mis.phase = 'returning';
|
||||||
|
mis.retTime = now;
|
||||||
|
if (!mis.gift) sayText('yip (fine. take it back)');
|
||||||
|
}
|
||||||
|
} else if (mis.phase === 'returning') {
|
||||||
|
if (now > mis.retTime + 700) {
|
||||||
|
emote('\u2728', true);
|
||||||
|
returnItem();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
requestAnimationFrame(tick);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mode === 'errand') {
|
||||||
|
if (!errandTarget || now > errandDeadline) {
|
||||||
|
errandTarget = null;
|
||||||
|
mode = 'wander';
|
||||||
|
target = pickWander();
|
||||||
|
} else {
|
||||||
|
var ex = errandTarget.x - pos.x, ey = errandTarget.y - pos.y;
|
||||||
|
var ed = Math.hypot(ex, ey);
|
||||||
|
if (ed < 16) {
|
||||||
|
if (errandSay) sayText(errandSay);
|
||||||
|
else emote(pick(['\uD83D\uDC43','\u2753']));
|
||||||
|
errandTarget = null;
|
||||||
|
errandSay = null;
|
||||||
|
mode = 'rest';
|
||||||
|
restUntil = now + 1500;
|
||||||
|
nextZzz = now + 99999;
|
||||||
|
} else {
|
||||||
|
var ew = Math.atan2(ey, ex);
|
||||||
|
var emx = TURN * dt * 2.2;
|
||||||
|
heading += Math.max(-emx, Math.min(emx, angDiff(heading, ew)));
|
||||||
|
var esp = (errandRun ? RUN : TROT) * zoom;
|
||||||
|
var est = (errandRun ? STRIDE_RUN : STRIDE_WALK);
|
||||||
|
var estep = Math.min(esp * dt, ed);
|
||||||
|
pos.x += Math.cos(heading) * estep;
|
||||||
|
pos.y += Math.sin(heading) * estep;
|
||||||
|
distSince += estep;
|
||||||
|
if (distSince >= est) { distSince = 0; drop(); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
requestAnimationFrame(tick);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var dx = target.x - pos.x, dy = target.y - pos.y;
|
||||||
|
var dist = Math.hypot(dx, dy);
|
||||||
|
|
||||||
|
if (mode === 'chase') {
|
||||||
|
if (dist < 26) {
|
||||||
|
mode = 'rest';
|
||||||
|
restUntil = now + (mem.pets >= 25 ? 4200 : 2500);
|
||||||
|
nextZzz = now + 1700;
|
||||||
|
if (Math.random() < 0.5) emote(mem.pets >= 10 ? '\uD83D\uDC95' : '\u2728');
|
||||||
|
}
|
||||||
|
} else if (mode === 'rest') {
|
||||||
|
if (now > nextZzz) { emote('\uD83D\uDCA4'); nextZzz = now + 1600; }
|
||||||
|
if (now > restUntil) {
|
||||||
|
if (!startMischief(false)) { target = pickWander(); mode = 'wander'; }
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (dist < 14) {
|
||||||
|
mode = 'rest'; nextZzz = now + 99999;
|
||||||
|
if (poiSay) {
|
||||||
|
restUntil = now + 1200 + Math.random() * 1600;
|
||||||
|
sayText(poiSay);
|
||||||
|
poiSay = null;
|
||||||
|
} else {
|
||||||
|
restUntil = now + 600 + Math.random() * 1600;
|
||||||
|
emote(pick(['\uD83D\uDC43','\u2753','\uD83C\uDF38','\u2728','\uD83D\uDC3E']));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mode !== 'rest' && dist > 1) {
|
||||||
|
var want = Math.atan2(dy, dx);
|
||||||
|
var maxTurn = TURN * dt * (mode === 'chase' ? 2.2 : 1);
|
||||||
|
heading += Math.max(-maxTurn, Math.min(maxTurn, angDiff(heading, want)));
|
||||||
|
var running = (mode === 'chase' && dist > 160);
|
||||||
|
var speed = (mode === 'chase' ? (running ? RUN : TROT) : WALK) * zoom;
|
||||||
|
var stride = running ? STRIDE_RUN : STRIDE_WALK;
|
||||||
|
var step = Math.min(speed * dt, dist);
|
||||||
|
pos.x += Math.cos(heading) * step;
|
||||||
|
pos.y += Math.sin(heading) * step;
|
||||||
|
distSince += step;
|
||||||
|
if (distSince >= stride) { distSince = 0; drop(); }
|
||||||
|
}
|
||||||
|
requestAnimationFrame(tick);
|
||||||
|
}
|
||||||
|
var tab = document.createElement('div');
|
||||||
|
tab.className = 'dentab';
|
||||||
|
tab.textContent = '>_ den';
|
||||||
|
var term = document.createElement('div');
|
||||||
|
term.className = 'denterm';
|
||||||
|
term.innerHTML = '<div class="dout">den shell v2 (now portable)\ntype "help"\n</div>' +
|
||||||
|
'<div class="dline"><span class="dprompt"></span><input autocomplete="off" spellcheck="false" aria-label="den shell input"></div>';
|
||||||
|
|
||||||
|
function dprint(t){ dout.textContent += t + '\n'; dout.scrollTop = dout.scrollHeight; }
|
||||||
|
function openTerm(){ term.classList.add('open'); tab.style.display = 'none'; dinp.focus(); }
|
||||||
|
function closeTerm(){ term.classList.remove('open'); tab.style.display = ''; }
|
||||||
|
|
||||||
|
var petSeen = 0;
|
||||||
|
function runCmd(raw){
|
||||||
|
var lc = raw.toLowerCase();
|
||||||
|
if (lc.indexOf('name ') === 0) {
|
||||||
|
var nn = window.denSetName(raw.slice(5));
|
||||||
|
dprint(nn ? 'the kobold accepts the name "' + nn + '". this is legally binding' : 'that name did not survive processing. try letters');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (lc === 'name') {
|
||||||
|
dprint(mem.name ? 'the kobold is named "' + mem.name + '"' : 'the kobold has no name yet. try: name <something>');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (lc.indexOf('sudo') === 0) { dprint('no.'); return; }
|
||||||
|
if (lc.indexOf('rm ') === 0 || lc === 'rm') { dprint('rm: the hoard is load-bearing. request denied'); return; }
|
||||||
|
switch (lc) {
|
||||||
|
case 'help':
|
||||||
|
dprint('commands: pet, come, sit, fetch, sleep, wake, zoomies, where, status, name <x>, ls, whoami, pwd, neofetch, yip, clear, exit');
|
||||||
|
break;
|
||||||
|
case 'pet':
|
||||||
|
case 'pet kobold':
|
||||||
|
petSeen++;
|
||||||
|
petHim();
|
||||||
|
dprint(petSeen >= 5 ? '*yip yip yip* (maximum happiness. further pets stored for later)' : '*pets dispensed remotely* yip!');
|
||||||
|
break;
|
||||||
|
case 'fetch': {
|
||||||
|
if (startMischief(true)) {
|
||||||
|
dprint(mem.pets >= 25 ? 'he goes to find u something' : 'oh no');
|
||||||
|
} else {
|
||||||
|
dprint(mis ? 'his paws are full' : 'nothing here worth taking. sad');
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'come': {
|
||||||
|
var r = term.getBoundingClientRect();
|
||||||
|
target = {
|
||||||
|
x: Math.min(Math.max(r.left + 40, 20), window.innerWidth - 20),
|
||||||
|
y: Math.min(Math.max(r.top - 40, 20), window.innerHeight - 20)
|
||||||
|
};
|
||||||
|
if (asleep) bump();
|
||||||
|
mode = 'chase';
|
||||||
|
dprint('he is on his way');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'sit':
|
||||||
|
mode = 'rest';
|
||||||
|
restUntil = performance.now() + 6000;
|
||||||
|
nextZzz = performance.now() + 99999;
|
||||||
|
sayText('yip (sitting)');
|
||||||
|
dprint('he sits. a good boy');
|
||||||
|
break;
|
||||||
|
case 'sleep':
|
||||||
|
suppressBumpUntil = performance.now() + 800;
|
||||||
|
sleepPrepUntil = performance.now() + 1400;
|
||||||
|
sayText('*yawn*');
|
||||||
|
dprint('bedtime initiated');
|
||||||
|
break;
|
||||||
|
case 'wake':
|
||||||
|
if (asleep) { bump(); dprint('he stirs'); }
|
||||||
|
else dprint('he is already awake and probably watching u');
|
||||||
|
break;
|
||||||
|
case 'zoomies':
|
||||||
|
zoomies();
|
||||||
|
dprint('oh no');
|
||||||
|
break;
|
||||||
|
case 'where':
|
||||||
|
sayText('yip!! (here)');
|
||||||
|
emote('\u2728', true);
|
||||||
|
dprint('listen for the yip');
|
||||||
|
break;
|
||||||
|
case 'status':
|
||||||
|
dprint('mode: ' + (asleep ? 'asleep' : mode) + ' | pets: ' + mem.pets + ' | name: ' + (mem.name || '(unnamed)') + ' | visits: ' + mem.v);
|
||||||
|
break;
|
||||||
|
case 'ls':
|
||||||
|
dprint('den/ hoard/ guestbook.txt do_not_open/ retopo_FINAL_v2/');
|
||||||
|
break;
|
||||||
|
case 'whoami':
|
||||||
|
dprint('a guest in the den');
|
||||||
|
break;
|
||||||
|
case 'pwd':
|
||||||
|
dprint('/home/den/u');
|
||||||
|
break;
|
||||||
|
case 'yip':
|
||||||
|
sayText('yip!!');
|
||||||
|
dprint('yip!!');
|
||||||
|
break;
|
||||||
|
case 'neofetch':
|
||||||
|
dprint(' /\\_/\\ guest@den\n ( o.o ) ----------\n > ^ < OS: DenOS (yes it is just apache)\n kobold btw Shell: den shell 2.0\n Uptime: emotionally? unclear');
|
||||||
|
break;
|
||||||
|
case 'clear':
|
||||||
|
dout.textContent = '';
|
||||||
|
break;
|
||||||
|
case 'exit':
|
||||||
|
dprint('u can check out any time u like...');
|
||||||
|
setTimeout(closeTerm, 600);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
dprint("fish: Unknown command: '" + raw.split(' ')[0].replace(/[^\w.-]/g, '') + "'");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function mountShell(){
|
||||||
|
document.body.appendChild(tab);
|
||||||
|
document.body.appendChild(term);
|
||||||
|
dout = term.querySelector('.dout');
|
||||||
|
dinp = term.querySelector('input');
|
||||||
|
dpr = term.querySelector('.dprompt');
|
||||||
|
dpr.textContent = promptTxt();
|
||||||
|
tab.addEventListener('click', openTerm);
|
||||||
|
dinp.addEventListener('keydown', function(e){
|
||||||
|
if (e.key === 'Escape') { closeTerm(); return; }
|
||||||
|
if (e.key !== 'Enter') return;
|
||||||
|
var raw = dinp.value.trim();
|
||||||
|
dinp.value = '';
|
||||||
|
if (!raw) return;
|
||||||
|
dprint(promptTxt() + ' ' + raw);
|
||||||
|
runCmd(raw);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
var hole = document.createElement('div');
|
||||||
|
hole.className = 'denhole';
|
||||||
|
hole.innerHTML = '<svg width="56" height="34" viewBox="0 0 56 34" xmlns="http://www.w3.org/2000/svg">' +
|
||||||
|
'<ellipse cx="28" cy="17" rx="26" ry="14" fill="#0d0818" stroke="#8f7bff" stroke-width="2.5"/>' +
|
||||||
|
'<ellipse cx="28" cy="19" rx="18" ry="8" fill="#000"/>' +
|
||||||
|
'<ellipse cx="20" cy="10" rx="4" ry="1.6" fill="#3d2a55"/>' +
|
||||||
|
'</svg>';
|
||||||
|
hole.style.left = (pos.x - 28) + 'px';
|
||||||
|
hole.style.top = (pos.y - 17) + 'px';
|
||||||
|
function beginLife(){
|
||||||
|
mountShell();
|
||||||
|
checkPresence();
|
||||||
|
setInterval(checkPresence, 60000);
|
||||||
|
document.body.appendChild(hole);
|
||||||
|
setTimeout(function(){
|
||||||
|
emote('\u2728', true);
|
||||||
|
sayText(greeting());
|
||||||
|
var cx = window.innerWidth / 2, cy = window.innerHeight / 2;
|
||||||
|
var ang = Math.atan2(cy - pos.y, cx - pos.x);
|
||||||
|
heading = ang;
|
||||||
|
var r2 = 160 + Math.random() * 120;
|
||||||
|
target = {
|
||||||
|
x: Math.min(Math.max(pos.x + Math.cos(ang) * r2, 20), window.innerWidth - 20),
|
||||||
|
y: Math.min(Math.max(pos.y + Math.sin(ang) * r2, 20), window.innerHeight - 20)
|
||||||
|
};
|
||||||
|
poiSay = null;
|
||||||
|
mode = 'wander';
|
||||||
|
}, 700);
|
||||||
|
setTimeout(function(){
|
||||||
|
hole.style.opacity = '0';
|
||||||
|
setTimeout(function(){ hole.remove(); }, 600);
|
||||||
|
}, 2600);
|
||||||
|
setTimeout(function(){
|
||||||
|
var n = window.denOthers | 0;
|
||||||
|
if (n > 0) sayText('yip (' + n + (n === 1 ? ' other is' : ' others are') + ' here... i can smell them)');
|
||||||
|
}, 9000);
|
||||||
|
}
|
||||||
|
if (document.body) beginLife();
|
||||||
|
else document.addEventListener('DOMContentLoaded', beginLife);
|
||||||
|
requestAnimationFrame(tick);
|
||||||
|
})();
|
||||||
52
src/den.php
Normal file
52
src/den.php
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
<?php
|
||||||
|
// presence endpoint for den.js
|
||||||
|
// counts client ids (js-running visitors), capped per ip to prevent inflation
|
||||||
|
// stores hmac'd fingerprints only, never raw ips or ids. entries expire after 5 min
|
||||||
|
// one line per active visitor: latest_timestamp|fingerprint|iphash
|
||||||
|
// change this secret, keep it private
|
||||||
|
define('DEN_SECRET', 'NowmIO4VXYYF6cmg6hJRw3P4ZEd4PIpylnmpKhj8QeW9fe7wfJ94lwTGw8PfwRgRB2MlZEqaIBftH9L3twsccucwq6gFKnmqLpXsTS0PqN1Glwi6tRYtH3hPKm0VD3U');
|
||||||
|
|
||||||
|
$f = __DIR__ . '/../owo_presence.txt';
|
||||||
|
$now = time();
|
||||||
|
$iph = substr(hash_hmac('sha1', $_SERVER['REMOTE_ADDR'], DEN_SECRET), 0, 12);
|
||||||
|
|
||||||
|
$id = isset($_GET['id']) ? $_GET['id'] : '';
|
||||||
|
if (preg_match('/^[a-f0-9]{16}$/', $id)) {
|
||||||
|
$me = substr(hash_hmac('sha1', 'id:' . $id, DEN_SECRET), 0, 12);
|
||||||
|
} else {
|
||||||
|
$me = $iph;
|
||||||
|
}
|
||||||
|
|
||||||
|
$roster = [];
|
||||||
|
if (file_exists($f)) {
|
||||||
|
foreach (file($f, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) as $l) {
|
||||||
|
$p = explode('|', $l, 3);
|
||||||
|
if (count($p) < 2) continue;
|
||||||
|
$ts = (int)$p[0];
|
||||||
|
if ($ts < $now - 300) continue;
|
||||||
|
$fp = $p[1];
|
||||||
|
$rih = isset($p[2]) ? $p[2] : '';
|
||||||
|
if (!isset($roster[$fp]) || $ts > $roster[$fp][0]) {
|
||||||
|
$roster[$fp] = [$ts, $rih];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$from_my_ip = 0;
|
||||||
|
foreach ($roster as $fp => $e) {
|
||||||
|
if ($e[1] === $iph && $fp !== $me) $from_my_ip++;
|
||||||
|
}
|
||||||
|
if (isset($roster[$me]) || $from_my_ip < 3) {
|
||||||
|
$roster[$me] = [$now, $iph];
|
||||||
|
}
|
||||||
|
$others = count($roster) - (isset($roster[$me]) ? 1 : 0);
|
||||||
|
|
||||||
|
$out = '';
|
||||||
|
foreach ($roster as $fp => $e) {
|
||||||
|
$out .= $e[0] . '|' . $fp . '|' . $e[1] . "\n";
|
||||||
|
}
|
||||||
|
@file_put_contents($f, $out, LOCK_EX);
|
||||||
|
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
header('Cache-Control: no-store');
|
||||||
|
echo json_encode(['others' => $others]);
|
||||||
82
test/sim.js
Normal file
82
test/sim.js
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
const { JSDOM } = require('jsdom');
|
||||||
|
const fs = require('fs');
|
||||||
|
|
||||||
|
const dom = new JSDOM(`<!DOCTYPE html><html><head></head><body>
|
||||||
|
<span id="loot">LOOT</span>
|
||||||
|
</body></html>`, { runScripts: 'outside-only', pretendToBeVisual: false });
|
||||||
|
const w = dom.window;
|
||||||
|
const doc = w.document;
|
||||||
|
|
||||||
|
// viewport
|
||||||
|
Object.defineProperty(w, 'innerWidth', { value: 1200 });
|
||||||
|
Object.defineProperty(w, 'innerHeight', { value: 800 });
|
||||||
|
|
||||||
|
// clock + rAF pump
|
||||||
|
let NOW = 0;
|
||||||
|
let rafQ = [];
|
||||||
|
w.performance.now = () => NOW;
|
||||||
|
w.requestAnimationFrame = (cb) => { rafQ.push(cb); return rafQ.length; };
|
||||||
|
function pump(ms, step = 16) {
|
||||||
|
const end = NOW + ms;
|
||||||
|
while (NOW < end) {
|
||||||
|
NOW += step;
|
||||||
|
// run any pending timeouts jsdom scheduled? jsdom timers use real time.
|
||||||
|
const q = rafQ; rafQ = [];
|
||||||
|
q.forEach(cb => cb(NOW));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// matchMedia
|
||||||
|
w.matchMedia = () => ({ matches: false });
|
||||||
|
|
||||||
|
// loot element rect
|
||||||
|
const loot = doc.getElementById('loot');
|
||||||
|
loot.setAttribute('data-den-steal', '');
|
||||||
|
loot.getBoundingClientRect = () => ({ left: 300, top: 300, right: 400, bottom: 330, width: 100, height: 30 });
|
||||||
|
|
||||||
|
// generic rects so pois()/terminal don't explode
|
||||||
|
const origGBCR = w.Element.prototype.getBoundingClientRect;
|
||||||
|
w.Element.prototype.getBoundingClientRect = function(){
|
||||||
|
if (this === loot) return loot.getBoundingClientRect();
|
||||||
|
return { left: 0, top: 0, right: 0, bottom: 0, width: 0, height: 0 };
|
||||||
|
};
|
||||||
|
|
||||||
|
// run den.js
|
||||||
|
const src = fs.readFileSync(process.argv[2] || 'src/den.js', 'utf8');
|
||||||
|
w.eval(src);
|
||||||
|
|
||||||
|
// jsdom setTimeout is real-time; the spawn sequence uses setTimeout(700/2600).
|
||||||
|
// We can't fast-forward those, but they don't gate mischief. Wait them out for real.
|
||||||
|
function log(label){
|
||||||
|
const clone = [...doc.querySelectorAll('div')].find(d => (d.getAttribute('style')||'').includes('pointer-events:none') && d.textContent.includes('LOOT'));
|
||||||
|
const says = [...doc.querySelectorAll('.emote.say')].map(e => e.textContent);
|
||||||
|
console.log(`[t=${NOW}ms] ${label} | loot.visibility='${loot.style.visibility}' | clone=${clone ? 'YES@'+clone.style.left+','+clone.style.top : 'no'} | says=${JSON.stringify(says)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
pump(4000); // past spawn
|
||||||
|
log('before mischief');
|
||||||
|
w.denEvent('mischief');
|
||||||
|
log('event fired');
|
||||||
|
pump(100); log('after 100ms');
|
||||||
|
pump(1000); log('after ~1.1s (should be walking to loot)');
|
||||||
|
pump(2000); log('after ~3.1s (grabbed? carrying?)');
|
||||||
|
|
||||||
|
// simulate the user moving the mouse mid-carry
|
||||||
|
const ev = new w.MouseEvent('mousemove', { clientX: 900, clientY: 200 });
|
||||||
|
doc.dispatchEvent(ev);
|
||||||
|
log('mouse moved');
|
||||||
|
pump(3000); log('after ~6.1s');
|
||||||
|
pump(6000); log('after ~12.1s (dropped by now?)');
|
||||||
|
pump(8000); log('after ~20.1s (returned by now?)');
|
||||||
|
pump(10000); log('after ~30.1s (MUST be returned)');
|
||||||
|
|
||||||
|
const restored = loot.style.visibility !== 'hidden';
|
||||||
|
const noOrphan = ![...doc.querySelectorAll('div')]
|
||||||
|
.some(d => (d.getAttribute('style') || '').includes('pointer-events:none')
|
||||||
|
&& d.textContent.includes('LOOT'));
|
||||||
|
console.log('');
|
||||||
|
console.log(restored ? 'PASS: stolen element restored' : 'FAIL: element still hidden');
|
||||||
|
console.log(noOrphan ? 'PASS: no orphaned clone left behind' : 'FAIL: clone leaked');
|
||||||
|
process.exit(restored && noOrphan ? 0 : 1);
|
||||||
|
}, 3500);
|
||||||
Reference in New Issue
Block a user