1.1.0 — weather, treats, hunger, drift

This commit is contained in:
2026-08-18 13:45:25 -04:00
parent c0a634cab5
commit 820b30468d
7 changed files with 296 additions and 25 deletions

View File

@@ -1,5 +1,20 @@
# Changelog
## 1.1.0
He gets a life.
- **Weather**: `denWeather('rain'|'snow'|'clear')`. Rain sends him to shelter under
the nearest POI, where he sulks until it passes. Snow delights him and makes his
prints linger 3x longer.
- **Treats**: `denTreat(x, y)` or the `treat` shell command. He sprints for it, eats
it, and it counts toward trust. Hunger builds over a session and he'll say so.
- **Scroll reaction**: he's carried by the page when you scroll, scrambles back to
your cursor, and arrives winded.
- **Personality drift**: idle mutterings change based on hunger, and on whether a
returning visitor has ever actually touched him.
- Trust is now pets + treats throughout (gift threshold, resting behaviour, emotes).
## 1.0.0
First release. He exists.

View File

@@ -35,13 +35,28 @@ committing a crime. Emote bubbles float off him — `❗` when he notices you,
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.
**Memory.** Visit count, lifetime pets, treats, and his name persist in
`localStorage`. Return after a week and he says so. Reach ten points of trust
(pets + treats) and he decides you're friends; at twenty-five he lingers near
your cursor instead of wandering off, and his stealing turns into gift-giving.
**Drift.** What he mutters while wandering depends on how you've treated him. Feed
him and he's content. Visit repeatedly without ever touching him and he starts
asking whether anyone's there.
**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.
`come`, `sit`, `pet`, `treat`, `fetch`, `rain`, `snow`, `sleep`, `where`,
`status`, or `name` him.
**Weather.** `denWeather('rain')` and he bolts for the nearest POI to wait it out,
sulking under cover until it stops. `denWeather('snow')` and he loses his mind —
a burst of speed, and his prints linger three times as long in it.
**Treats.** Drop one and he sprints for it. Treats build trust the same way pets
do, and he gets visibly hungry if he hasn't been fed in a while.
**Scrolling.** He's standing on the page, not floating above it — scroll and he
gets carried along, has to scramble back, and arrives out of breath.
**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.
@@ -119,6 +134,8 @@ denEvent('alarm', { text: 'YIP?! (INTRUDER)' });
denEvent('mischief');
denEvent('sleepy');
denEvent('zoomies');
denEvent('rain'); // also 'snow', 'clear'
denEvent('treat', { x: 400, y: 300 }); // or { el: '#bowl' }
```
`celebrate` is a happy spin with a heart burst. `investigate` sends him on an
@@ -132,6 +149,8 @@ denSay('yip!!'); // speech bubble at his current position
denYip(); // the synthesized squeak
denSetName('crumb'); // returns the sanitized name, or null
denGetName();
denWeather('snow'); // 'rain' | 'snow' | 'clear'
denTreat(x, y); // drop a treat at a point
denOthers; // number, current presence count
```
@@ -180,9 +199,9 @@ npm install -g terser javascript-obfuscator
| 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 |
| `src/den.js` | 42 KB | readable source, the one you edit |
| `build/den.min.js` | 25 KB | minified; recommended |
| `build/den.obf.js` | 112 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,

3
build/den.min.js vendored

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -80,6 +80,7 @@
<p><b>Type <code>yip</code></b> — anywhere on the page. He answers.</p>
<p><b>Open the <code>&gt;_ 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>Scroll fast</b> — he gets carried off with the page and has to sprint back.</p>
<p><b>Wait ten minutes</b> without touching anything. He goes to bed.</p>
</div>
@@ -106,6 +107,13 @@
<button onclick="denEvent('mischief')">mischief</button>
<button onclick="denEvent('sleepy')">sleepy</button>
</p>
<p style="text-align:center;">
<button onclick="denEvent('treat')">drop a treat</button>
<button onclick="denWeather('rain')">rain</button>
<button onclick="denWeather('snow')">snow</button>
<button onclick="denWeather('clear')">clear skies</button>
</p>
<p class="hint" style="text-align:center;">Rain sends him under the nearest panel to sulk. Snow does the opposite.</p>
<p id="thing" class="hint" style="text-align:center;">↑ "investigate" sends him to this line, specifically.</p>
</div>

View File

@@ -1,6 +1,6 @@
{
"name": "den.js",
"version": "1.0.0",
"version": "1.1.0",
"description": "An invisible kobold lives on your website.",
"main": "src/den.js",
"files": ["src/", "build/", "README.md", "LICENSE"],

View File

@@ -23,7 +23,14 @@
'.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;}';
'.denterm input{flex:1;background:transparent;border:none;outline:none;color:#a8ffe0;font-family:inherit;font-size:12px;}' +
'.denwx{position:fixed;top:0;left:0;right:0;bottom:0;pointer-events:none;z-index:9996;overflow:hidden;}' +
'.denwx i{position:absolute;top:-24px;display:block;animation-name:denfall;animation-timing-function:linear;animation-fill-mode:forwards;}' +
'.denwx i.r{width:2px;height:15px;background:linear-gradient(180deg,rgba(79,195,255,0),rgba(79,195,255,.85));border-radius:1px;}' +
'.denwx i.s{color:#fff;font-size:12px;line-height:1;}' +
'@keyframes denfall{from{transform:translateY(0);}to{transform:translateY(104vh);}}' +
'.dentreat{position:fixed;z-index:9997;pointer-events:none;font-size:19px;line-height:1;animation:denbob 1.3s ease-in-out infinite;filter:drop-shadow(0 0 4px #ffb3de);}' +
'@keyframes denbob{0%,100%{transform:translateY(0);}50%{transform:translateY(-5px);}}';
document.head.appendChild(st);
var actx = null;
@@ -82,17 +89,25 @@
var sleepPrepUntil = 0, wakeUntil = 0, suppressBumpUntil = 0;
var asleep = false, bedEl = null, nextSleepZ = 0;
var speedMult = 1;
var PAW_LIFE = 1300;
var weather = 'clear', wxTimer = null, wxLayer = null;
var shelterPt = null, nextSulk = 0;
var treatEl = null, treatPt = null;
var fedAt = performance.now();
var lastScrollY = window.scrollY || window.pageYOffset || 0;
var windedUntil = 0;
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 };
var mem = { v: 0, pets: 0, treats: 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.treats = m0.treats | 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;
@@ -117,6 +132,9 @@
}
saveMem();
function trust(){ return mem.pets + mem.treats; }
function hungry(){ return performance.now() - fedAt > 240000; }
function greeting(){
var away = prevLast ? (Date.now() - prevLast) : 0;
if (mem.v <= 1) return '*yip* (hello)';
@@ -230,6 +248,27 @@
wakeUntil = now + 900;
}
}
window.addEventListener('scroll', function(){
var y = window.scrollY || window.pageYOffset || 0;
var delta = y - lastScrollY;
lastScrollY = y;
if (asleep || sleepPrepUntil || !delta) return;
pos.y -= delta;
var pushed = false;
if (pos.y < 18) { pos.y = 18; pushed = true; }
else if (pos.y > window.innerHeight - 18) { pos.y = window.innerHeight - 18; pushed = true; }
if (pushed && Math.abs(delta) > 45) windedUntil = performance.now() + 2500;
if (treatEl && treatPt) {
treatPt.y -= delta;
treatEl.style.top = (treatPt.y - 10) + 'px';
if (treatPt.y < 0 || treatPt.y > window.innerHeight) {
treatEl.remove(); treatEl = null; treatPt = null;
if (mode === 'treat') { mode = 'wander'; target = pickWander(); }
}
}
if (shelterPt) shelterPt.y -= delta;
}, { passive: true });
document.addEventListener('keydown', bump);
document.addEventListener('scroll', bump, { passive: true });
document.addEventListener('touchstart', bump, { passive: true });
@@ -237,7 +276,8 @@
document.addEventListener('mousemove', function(e){
target.x = e.clientX; target.y = e.clientY;
bump();
if (mode === 'errand' || mode === 'mischief') return;
if (mode === 'errand' || mode === 'mischief' || mode === 'treat') return;
if (mode === 'shelter' && weather === 'rain') return;
var now = performance.now();
if (now < petUntil || now < wakeUntil) return;
if (mode !== 'chase') emote('\u2757');
@@ -309,7 +349,7 @@
phase: 'togo',
pt: clampPt(r.left + r.width / 2, r.top + r.height / 2),
deadline: now + 18000,
gift: mem.pets >= 25
gift: trust() >= 25
};
lastMischief = now;
mode = 'mischief';
@@ -337,6 +377,84 @@
mis.phase = 'carry';
}
function spawnWx(){
if (!wxLayer) return;
var rain = weather === 'rain';
var p = document.createElement('i');
p.className = rain ? 'r' : 's';
if (!rain) p.textContent = '\u2744';
p.style.left = (Math.random() * 100) + 'vw';
var dur = rain ? (0.55 + Math.random() * 0.35) : (3.2 + Math.random() * 3.4);
p.style.animationDuration = dur + 's';
p.style.opacity = rain ? (0.3 + Math.random() * 0.45) : (0.5 + Math.random() * 0.5);
wxLayer.appendChild(p);
setTimeout(function(){ p.remove(); }, dur * 1000 + 300);
}
function setWeather(kind){
if (kind !== 'rain' && kind !== 'snow') kind = 'clear';
if (kind === weather) return;
weather = kind;
if (wxTimer) { clearInterval(wxTimer); wxTimer = null; }
if (wxLayer) { var old = wxLayer; wxLayer = null; old.remove(); }
PAW_LIFE = (kind === 'snow') ? 4200 : 1300;
if (kind === 'clear') {
shelterPt = null;
if (mode === 'shelter') { mode = 'wander'; target = pickWander(); sayText('yip!! (it stopped)'); }
return;
}
wxLayer = document.createElement('div');
wxLayer.className = 'denwx';
document.body.appendChild(wxLayer);
wxTimer = setInterval(spawnWx, kind === 'rain' ? 85 : 240);
if (asleep) bump();
if (kind === 'rain') {
sayText('yip?! (wet)');
emote('\u2614', true);
seekShelter();
} else {
sayText('YIP!! (snow!!)');
emote('\u2744', true);
speedMult = 1.6;
setTimeout(function(){ if (speedMult === 1.6) speedMult = 1; }, 6000);
}
}
window.denWeather = setWeather;
function seekShelter(){
var ps = pois();
if (!ps.length) { shelterPt = null; return false; }
var best = null, bd = Infinity;
for (var i = 0; i < ps.length; i++) {
var r = ps[i].r;
var cx = r.left + r.width * 0.5;
var cy = r.top + Math.min(r.height * 0.5, 45);
var dd = Math.hypot(cx - pos.x, cy - pos.y);
if (dd < bd) { bd = dd; best = { x: cx, y: cy }; }
}
if (!best) return false;
shelterPt = clampPt(best.x, best.y);
nextSulk = performance.now() + 2200;
mode = 'shelter';
return true;
}
function dropTreat(x, y){
if (treatEl) { treatEl.remove(); treatEl = null; }
treatPt = clampPt(x, y);
treatEl = document.createElement('div');
treatEl.className = 'dentreat';
treatEl.textContent = '\uD83C\uDF56';
treatEl.style.left = (treatPt.x - 10) + 'px';
treatEl.style.top = (treatPt.y - 10) + 'px';
document.body.appendChild(treatEl);
if (asleep) bump();
if (mis) returnItem();
emote('\u2757', true);
mode = 'treat';
}
window.denTreat = dropTreat;
function clampPt(x, y){
return {
x: Math.min(Math.max(x, 20), window.innerWidth - 20),
@@ -365,6 +483,16 @@
startMischief(true);
return;
}
if (type === 'rain' || type === 'snow' || type === 'clear') {
setWeather(type);
return;
}
if (type === 'treat') {
var tp = resolvePoint(opts);
if (!tp) tp = clampPt(pos.x + (Math.random() * 220 - 110), pos.y + (Math.random() * 160 - 80));
dropTreat(tp.x, tp.y);
return;
}
if (type === 'celebrate') {
petUntil = now + 1100;
mode = 'rest';
@@ -453,6 +581,13 @@
return d;
}
function driftLine(){
if (hungry() && Math.random() < 0.55) return pick(['yip (hungry)', '(food?)', 'yip? (snacks)']);
if (mem.v >= 3 && trust() === 0 && Math.random() < 0.4) return pick(['yip...', '(is anyone there)', 'yip? (hello?)']);
if (trust() >= 25 && Math.random() < 0.4) return pick(['yip (comfy)', 'uwu', 'yip yip (good day)']);
return null;
}
function pickWander(){
poiSay = null;
if (Math.random() < 0.45) {
@@ -492,7 +627,7 @@
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);
setTimeout(function(){ p.remove(); }, PAW_LIFE);
}
function tick(now){
@@ -622,6 +757,73 @@
return;
}
if (mode === 'shelter') {
if (weather !== 'rain') {
shelterPt = null;
mode = 'wander';
target = pickWander();
} else if (!shelterPt) {
if (!seekShelter()) { mode = 'wander'; target = pickWander(); }
} else {
var sx = shelterPt.x - pos.x, sy = shelterPt.y - pos.y;
var sd = Math.hypot(sx, sy);
if (sd < 14) {
if (now > nextSulk) {
nextSulk = now + 3200 + Math.random() * 2600;
if (Math.random() < 0.5) emote(pick(['\u2614', '\uD83D\uDCA7']));
else sayText(pick(['yip... (waiting it out)', '(damp)', 'yip (i do not like this)']));
}
} else {
var sw = Math.atan2(sy, sx);
var smx = TURN * dt * 2.2;
heading += Math.max(-smx, Math.min(smx, angDiff(heading, sw)));
var sspeed = (sd > 150 ? RUN : TROT) * speedMult;
var sstep = Math.min(sspeed * dt, sd);
pos.x += Math.cos(heading) * sstep;
pos.y += Math.sin(heading) * sstep;
distSince += sstep;
if (distSince >= (sd > 150 ? STRIDE_RUN : STRIDE_WALK)) { distSince = 0; drop(); }
}
}
requestAnimationFrame(tick);
return;
}
if (mode === 'treat') {
if (!treatPt || !treatEl) {
mode = 'wander';
target = pickWander();
} else {
var tx = treatPt.x - pos.x, ty = treatPt.y - pos.y;
var td = Math.hypot(tx, ty);
if (td < 15) {
treatEl.remove();
treatEl = null;
treatPt = null;
fedAt = now;
mem.treats++;
saveMem();
emote('\uD83D\uDC96', true);
sayText(pick(['*nom nom* yip!!', 'yip!! (thank u)', '(best day)']));
yipSound();
mode = 'rest';
restUntil = now + 2600;
nextZzz = now + 99999;
} else {
var tw = Math.atan2(ty, tx);
var tmx = TURN * dt * 2.4;
heading += Math.max(-tmx, Math.min(tmx, angDiff(heading, tw)));
var tstep = Math.min(RUN * speedMult * dt, td);
pos.x += Math.cos(heading) * tstep;
pos.y += Math.sin(heading) * tstep;
distSince += tstep;
if (distSince >= STRIDE_RUN) { distSince = 0; drop(); }
}
}
requestAnimationFrame(tick);
return;
}
if (mode === 'errand') {
if (!errandTarget || now > errandDeadline) {
errandTarget = null;
@@ -661,14 +863,17 @@
if (mode === 'chase') {
if (dist < 26) {
mode = 'rest';
restUntil = now + (mem.pets >= 25 ? 4200 : 2500);
restUntil = now + (trust() >= 25 ? 4200 : 2500);
nextZzz = now + 1700;
if (Math.random() < 0.5) emote(mem.pets >= 10 ? '\uD83D\uDC95' : '\u2728');
if (now < windedUntil) { windedUntil = 0; sayText('*wheeze* (u went so far)'); }
else if (Math.random() < 0.5) emote(trust() >= 10 ? '\uD83D\uDC95' : '\u2728');
}
} else if (mode === 'rest') {
if (now > nextZzz) { emote('\uD83D\uDCA4'); nextZzz = now + 1600; }
if (hungry() && now > nextSulk) { emote('\uD83C\uDF56'); nextSulk = now + 9000; }
if (now > restUntil) {
if (!startMischief(false)) { target = pickWander(); mode = 'wander'; }
if (weather === 'rain' && seekShelter()) { /* back under cover */ }
else if (!startMischief(false)) { target = pickWander(); mode = 'wander'; }
}
} else {
if (dist < 14) {
@@ -679,7 +884,9 @@
poiSay = null;
} else {
restUntil = now + 600 + Math.random() * 1600;
emote(pick(['\uD83D\uDC43','\u2753','\uD83C\uDF38','\u2728','\uD83D\uDC3E']));
var dl = driftLine();
if (dl) sayText(dl);
else emote(pick(['\uD83D\uDC43','\u2753','\uD83C\uDF38','\u2728','\uD83D\uDC3E']));
}
}
}
@@ -727,7 +934,7 @@
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');
dprint('commands: pet, treat, come, sit, fetch, rain, snow, sun, sleep, wake, zoomies, where, status, name <x>, ls, whoami, pwd, neofetch, yip, clear, exit');
break;
case 'pet':
case 'pet kobold':
@@ -737,12 +944,34 @@
break;
case 'fetch': {
if (startMischief(true)) {
dprint(mem.pets >= 25 ? 'he goes to find u something' : 'oh no');
dprint(trust() >= 25 ? 'he goes to find u something' : 'oh no');
} else {
dprint(mis ? 'his paws are full' : 'nothing here worth taking. sad');
}
break;
}
case 'treat': {
var tr = term.getBoundingClientRect();
dropTreat(
Math.max(tr.left - 60, 40),
Math.max(tr.top - 50, 40)
);
dprint(hungry() ? 'he was waiting for this' : 'a treat appears. he has noticed');
break;
}
case 'rain':
setWeather('rain');
dprint('it rains. he does not care for it');
break;
case 'snow':
setWeather('snow');
dprint('it snows. he cares for it a great deal');
break;
case 'sun':
case 'clear':
setWeather('clear');
dprint('the sky clears');
break;
case 'come': {
var r = term.getBoundingClientRect();
target = {
@@ -781,7 +1010,9 @@
dprint('listen for the yip');
break;
case 'status':
dprint('mode: ' + (asleep ? 'asleep' : mode) + ' | pets: ' + mem.pets + ' | name: ' + (mem.name || '(unnamed)') + ' | visits: ' + mem.v);
dprint('mode: ' + (asleep ? 'asleep' : mode) + ' | pets: ' + mem.pets + ' | treats: ' + mem.treats +
' | name: ' + (mem.name || '(unnamed)') + ' | visits: ' + mem.v +
' | weather: ' + weather + (hungry() ? ' | hungry' : ''));
break;
case 'ls':
dprint('den/ hoard/ guestbook.txt do_not_open/ retopo_FINAL_v2/');