oneko 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/dist/chunks/{vanilla-CUrawvWx.js → vanilla-BNtPCdx5.js} +1079 -72
  2. package/dist/index.js +29 -7
  3. package/dist/solid.js +8 -3
  4. package/dist/svelte/Oneko.svelte +14 -4
  5. package/dist/svelte/Oneko.svelte.d.ts +2 -2
  6. package/dist/types/components/oneko.d.ts +12 -2
  7. package/dist/types/lib/oneko/affection.d.ts +16 -0
  8. package/dist/types/lib/oneko/animation/deps.d.ts +2 -0
  9. package/dist/types/lib/oneko/animation/errand-point.d.ts +7 -0
  10. package/dist/types/lib/oneko/animation/frame.d.ts +2 -1
  11. package/dist/types/lib/oneko/animation/play-types.d.ts +56 -0
  12. package/dist/types/lib/oneko/animation/play.d.ts +12 -0
  13. package/dist/types/lib/oneko/constants.d.ts +1 -0
  14. package/dist/types/lib/oneko/controls.d.ts +28 -0
  15. package/dist/types/lib/oneko/defaults.d.ts +5 -0
  16. package/dist/types/lib/oneko/initial-state.d.ts +4 -0
  17. package/dist/types/lib/oneko/mood.d.ts +7 -0
  18. package/dist/types/lib/oneko/pack.d.ts +4 -0
  19. package/dist/types/lib/oneko/pixel-art.d.ts +5 -0
  20. package/dist/types/lib/oneko/play-messages.d.ts +8 -0
  21. package/dist/types/lib/oneko/runtime-config.d.ts +2 -2
  22. package/dist/types/lib/oneko/seasons.d.ts +27 -0
  23. package/dist/types/lib/oneko/treats.d.ts +15 -0
  24. package/dist/types/lib/oneko/types.d.ts +61 -0
  25. package/dist/types/lib/oneko/vanilla.d.ts +3 -1
  26. package/dist/types/packages/oneko/src/solid.d.ts +5 -2
  27. package/dist/types/packages/oneko/src/vanilla.d.ts +2 -1
  28. package/dist/types/packages/oneko/src/vue.d.ts +71 -4
  29. package/dist/vanilla.js +4 -4
  30. package/dist/vue.js +38 -8
  31. package/package.json +1 -1
  32. package/dist/cat-sounds/Cat_royal_ambient5.ogg +0 -1
  33. package/dist/cat-sounds/Cat_royal_ambient6.ogg +0 -1
  34. package/dist/cat-sounds/Cat_royal_death.ogg +0 -1
  35. package/dist/cat-sounds/Cat_royal_hurt1.ogg +0 -1
  36. package/dist/cat-sounds/Cat_royal_hurt2.ogg +0 -1
  37. package/dist/cat-sounds/Cat_royal_hurt3.ogg +0 -1
  38. package/dist/cat-sounds/Stray_cat_idle1.ogg +0 -1
  39. package/dist/cat-sounds/Stray_cat_idle2.ogg +0 -1
  40. package/dist/cat-sounds/Stray_cat_idle3.ogg +0 -1
  41. package/dist/cat-sounds/Stray_cat_idle4.ogg +0 -1
@@ -388,15 +388,13 @@ const SOUND_POOLS = {
388
388
  "/cat-sounds/Cat_royal_ambient1.ogg",
389
389
  "/cat-sounds/Cat_royal_ambient2.ogg",
390
390
  "/cat-sounds/Cat_royal_ambient3.ogg",
391
- "/cat-sounds/Cat_royal_ambient4.ogg",
392
- "/cat-sounds/Cat_royal_ambient5.ogg",
393
- "/cat-sounds/Cat_royal_ambient6.ogg"
391
+ "/cat-sounds/Cat_royal_ambient4.ogg"
394
392
  ],
395
393
  alert: [
396
- "/cat-sounds/Stray_cat_idle1.ogg",
397
- "/cat-sounds/Stray_cat_idle2.ogg",
398
- "/cat-sounds/Stray_cat_idle3.ogg",
399
- "/cat-sounds/Stray_cat_idle4.ogg"
394
+ "/cat-sounds/Cat_idle1.ogg",
395
+ "/cat-sounds/Cat_idle2.ogg",
396
+ "/cat-sounds/Cat_idle3.ogg",
397
+ "/cat-sounds/Cat_idle4.ogg"
400
398
  ]
401
399
  };
402
400
  const KONAMI_SEQUENCE = [
@@ -557,7 +555,12 @@ const ONEKO_DEFAULTS = {
557
555
  napWhileTyping: false,
558
556
  followFocus: false,
559
557
  perchOnHover: false,
560
- pettable: false
558
+ pettable: false,
559
+ seasonal: true,
560
+ grabbable: false,
561
+ treats: false,
562
+ timeOfDay: false,
563
+ affection: false
561
564
  };
562
565
  function isValidRect(rect) {
563
566
  return Object.values(rect).every(Number.isFinite) && rect.right > rect.left && rect.bottom > rect.top;
@@ -673,6 +676,55 @@ function constrainZoneMovement(from, to, blocked) {
673
676
  }
674
677
  return { x: from.x + dx * limit, y: from.y + dy * limit };
675
678
  }
679
+ const clamp = (value, min, max) => Math.max(min, Math.min(Math.max(min, max), value));
680
+ function visibleRect(el) {
681
+ if (!el.isConnected) return null;
682
+ const rect = el.getBoundingClientRect();
683
+ if (rect.width === 0 || rect.height === 0) return null;
684
+ if (rect.bottom < 0 || rect.right < 0 || rect.top > window.innerHeight) return null;
685
+ if (rect.left > window.innerWidth) return null;
686
+ return rect;
687
+ }
688
+ function errandPoint(s, errand) {
689
+ const half = TILE * s.scale / 2;
690
+ const maxX = window.innerWidth - 16;
691
+ const maxY = window.innerHeight - 16;
692
+ switch (errand.kind) {
693
+ case "sit":
694
+ return { x: s.nekoPosX, y: s.nekoPosY };
695
+ case "treat":
696
+ return errand.treat.eaten ? null : { x: errand.treat.x, y: errand.treat.y };
697
+ case "goTo": {
698
+ const { target } = errand;
699
+ if (!(target instanceof Element))
700
+ return { x: clamp(target.x, 16, maxX), y: clamp(target.y, 16, maxY) };
701
+ const rect = visibleRect(target);
702
+ if (!rect) return null;
703
+ const below = rect.bottom + half + 4;
704
+ return {
705
+ x: clamp(rect.left + rect.width / 2, 16, maxX),
706
+ y: below < maxY ? below : clamp(rect.top - half - 4, 16, maxY)
707
+ };
708
+ }
709
+ case "box": {
710
+ const rect = visibleRect(errand.el);
711
+ if (!rect) return null;
712
+ return {
713
+ x: clamp(rect.left + rect.width / 2, 16, maxX),
714
+ y: clamp(Math.max(rect.top + half, rect.bottom - half - 2), 16, maxY)
715
+ };
716
+ }
717
+ case "knock": {
718
+ const rect = visibleRect(errand.el);
719
+ if (!rect) return null;
720
+ const x = errand.direction === 1 ? rect.left - half * 0.75 : rect.right + half * 0.75;
721
+ return {
722
+ x: clamp(x, 16, maxX),
723
+ y: clamp(Math.max(rect.top + half / 2, rect.bottom - half), 16, maxY)
724
+ };
725
+ }
726
+ }
727
+ }
676
728
  const EMPTY_ZONES = [];
677
729
  function createZoneState() {
678
730
  return {
@@ -760,7 +812,9 @@ function updateZoneTarget(s, width, height, random = Math.random) {
760
812
  }
761
813
  }
762
814
  const favorite = favorites.find((zone) => zone.id === zones.targetId);
763
- const desired = !s.followCursorCfg || s.typingNap || s.held ? { x: s.nekoPosX, y: s.nekoPosY } : s.perchTarget ?? (favorite ? {
815
+ const errand = s.errand && errandPoint(s, s.errand);
816
+ const requested = s.errand?.kind === "goTo" || s.errand?.kind === "sit";
817
+ const desired = errand && requested ? errand : !s.followCursorCfg || s.typingNap || s.held ? { x: s.nekoPosX, y: s.nekoPosY } : errand ?? s.perchTarget ?? (favorite ? {
764
818
  x: (favorite.rect.left + favorite.rect.right) / 2,
765
819
  y: (favorite.rect.top + favorite.rect.bottom) / 2
766
820
  } : s.leaderTarget ?? { x: s.mousePosX, y: s.mousePosY });
@@ -770,6 +824,161 @@ function updateZoneTarget(s, width, height, random = Math.random) {
770
824
  } : desired;
771
825
  if (oldId !== zones.targetId) s.lastPathRecalcFrame = -PATH_RECALC_INTERVAL;
772
826
  }
827
+ const OUTLINE = "#1b1b1b";
828
+ function pixelArtSVG(rows, colors) {
829
+ const width = rows[0].length + 2;
830
+ const height = rows.length + 2;
831
+ const filled = (x, y) => Boolean(colors[rows[y]?.[x] ?? "."]);
832
+ const rects = [];
833
+ for (let y = -1; y <= rows.length; y++) {
834
+ for (let x = -1; x <= rows[0].length; x++) {
835
+ const fill = filled(x, y) ? colors[rows[y][x]] : filled(x - 1, y) || filled(x + 1, y) || filled(x, y - 1) || filled(x, y + 1) ? OUTLINE : null;
836
+ if (fill) rects.push(`<rect x="${x + 1}" y="${y + 1}" width="1" height="1" fill="${fill}"/>`);
837
+ }
838
+ }
839
+ return `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}" shape-rendering="crispEdges" style="display:block;width:100%;height:100%">${rects.join("")}</svg>`;
840
+ }
841
+ const ONEKO_SEASONS = ["halloween", "winter", "newYear"];
842
+ function isOnekoSeason(value) {
843
+ return ONEKO_SEASONS.includes(value);
844
+ }
845
+ function seasonForDate(date) {
846
+ const month = date.getMonth() + 1;
847
+ const day = date.getDate();
848
+ if (month === 10 && day >= 20) return "halloween";
849
+ if (month === 12 && day >= 18 && day <= 29) return "winter";
850
+ if (month === 12 && day >= 30 || month === 1 && day <= 2) return "newYear";
851
+ return null;
852
+ }
853
+ function resolveSeason(setting, date = /* @__PURE__ */ new Date()) {
854
+ if (setting === false) return null;
855
+ if (setting === true) return seasonForDate(date);
856
+ return isOnekoSeason(setting) ? setting : null;
857
+ }
858
+ const SEASON_MESSAGES = {
859
+ halloween: [
860
+ "trick or treat. mostly treat",
861
+ "I am the spooky one in this house",
862
+ "black cat energy, any coat",
863
+ "boo. did I scare you",
864
+ "this pumpkin smells like nap",
865
+ "I dressed up as a cat"
866
+ ],
867
+ winter: [
868
+ "is the tree for climbing",
869
+ "I helped wrap the presents",
870
+ "the box is the present",
871
+ "warm lap wanted, apply within",
872
+ "snow is just cold sand"
873
+ ],
874
+ newYear: [
875
+ "new year, same nap spot",
876
+ "resolution: more treats",
877
+ "the fireworks can stop now",
878
+ "I stayed up past midnight. barely",
879
+ "counting down to my next nap"
880
+ ]
881
+ };
882
+ const HATS = {
883
+ halloween: {
884
+ rows: [
885
+ ".......PP..",
886
+ "......PPP..",
887
+ ".....PPPP..",
888
+ ".....PPPP..",
889
+ "....PPPPPP.",
890
+ "....PPPPPP.",
891
+ "...POOOOOP.",
892
+ "PPPPPPPPPPP"
893
+ ],
894
+ colors: { P: "#4a2470", O: "#f28c28" }
895
+ },
896
+ winter: {
897
+ rows: [
898
+ "........WW.",
899
+ ".......RWW.",
900
+ "......RR...",
901
+ ".....RRRR..",
902
+ "....RRRRRR.",
903
+ "...RRRRRRR.",
904
+ "WWWWWWWWWWW",
905
+ "WWWWWWWWWWW"
906
+ ],
907
+ colors: { R: "#d62839", W: "#f4f1ea" }
908
+ },
909
+ newYear: {
910
+ rows: [
911
+ ".....YY....",
912
+ ".....GG....",
913
+ "....GBBG...",
914
+ "....BGGB...",
915
+ "...GBBBBG..",
916
+ "...BGGGGB..",
917
+ "..GBBBBBBG.",
918
+ "..BGGGGGGB."
919
+ ],
920
+ colors: { Y: "#ffd23f", G: "#ffb627", B: "#2e86de" }
921
+ }
922
+ };
923
+ const HAT_WIDTH = 13;
924
+ const HAT_HEIGHT = 10;
925
+ function hatSVG(season) {
926
+ return pixelArtSVG(HATS[season].rows, HATS[season].colors);
927
+ }
928
+ function hatOffset(spriteX, spriteY) {
929
+ const anchor = HAT_ANCHORS[`${spriteX},${spriteY}`];
930
+ if (!anchor) return null;
931
+ return { left: anchor[0] - Math.floor(HAT_WIDTH / 2), top: anchor[1] - (HAT_HEIGHT - 1) };
932
+ }
933
+ const HAT_ANCHORS = {
934
+ // idle, tired, alert: sitting, facing the viewer
935
+ "-3,-3": [16, 6],
936
+ "-3,-2": [16, 6],
937
+ "-7,-3": [16, 5],
938
+ // scratchSelf
939
+ "-5,0": [11, 7],
940
+ "-6,0": [11, 7],
941
+ "-7,0": [14, 5],
942
+ // scratchWallN: seen from behind
943
+ "0,0": [15, 4],
944
+ "0,-1": [15, 4],
945
+ // scratchWallS
946
+ "-7,-1": [16, 13],
947
+ "-6,-2": [16, 13],
948
+ // scratchWallE
949
+ "-2,-2": [25, 4],
950
+ "-2,-3": [25, 4],
951
+ // scratchWallW
952
+ "-4,0": [7, 4],
953
+ "-4,-1": [7, 4],
954
+ // sleeping
955
+ "-2,0": [18, 13],
956
+ "-2,-1": [19, 16],
957
+ // N
958
+ "-1,-2": [16, 4],
959
+ "-1,-3": [16, 4],
960
+ // NE
961
+ "0,-2": [22, 5],
962
+ "0,-3": [22, 4],
963
+ // E
964
+ "-3,0": [25, 7],
965
+ "-3,-1": [25, 7],
966
+ // SE
967
+ "-5,-1": [26, 12],
968
+ "-5,-2": [23, 10],
969
+ // S
970
+ "-6,-3": [16, 4],
971
+ "-7,-2": [16, 4],
972
+ // SW
973
+ "-5,-3": [7, 12],
974
+ "-6,-1": [7, 10],
975
+ // W
976
+ "-4,-2": [7, 7],
977
+ "-4,-3": [7, 7],
978
+ // NW
979
+ "-1,0": [10, 5],
980
+ "-1,-1": [10, 5]
981
+ };
773
982
  function pickFromPool(deps, pool) {
774
983
  const s = deps.stateRef.current;
775
984
  let idx = Math.floor(Math.random() * pool.length);
@@ -810,6 +1019,9 @@ function messageForActivity(deps) {
810
1019
  if (idleAnimationMessage) {
811
1020
  return idleAnimationMessage;
812
1021
  }
1022
+ if (s.season && Math.random() < 0.35) {
1023
+ return pickFromPool(deps, SEASON_MESSAGES[s.season]);
1024
+ }
813
1025
  if (s.idleTime > 0) {
814
1026
  return pickFromPool(deps, IDLE_MESSAGES);
815
1027
  }
@@ -831,6 +1043,7 @@ function showBubble(deps) {
831
1043
  function hideBubble(deps) {
832
1044
  const s = deps.stateRef.current;
833
1045
  s.bubbleVisible = false;
1046
+ s.bubbleForced = false;
834
1047
  deps.bubbleEl.style.opacity = "0";
835
1048
  s.bubbleCooldown = s.bubbleCooldownFramesCfg;
836
1049
  }
@@ -896,7 +1109,15 @@ function shouldTriggerBubble(deps) {
896
1109
  }
897
1110
  function updateBubble(deps) {
898
1111
  const s = deps.stateRef.current;
899
- if (!s.bubbleEnabledCfg) {
1112
+ if (s.sayRequest) {
1113
+ s.bubbleVisible = true;
1114
+ s.bubbleForced = true;
1115
+ s.bubbleTimer = s.sayRequest.frames;
1116
+ deps.bubbleTextEl.textContent = s.sayRequest.text;
1117
+ deps.bubbleEl.style.opacity = "1";
1118
+ s.sayRequest = null;
1119
+ }
1120
+ if (!s.bubbleEnabledCfg && !s.bubbleForced) {
900
1121
  if (s.bubbleVisible) {
901
1122
  hideBubble(deps);
902
1123
  }
@@ -935,6 +1156,17 @@ function leaderPosition(state) {
935
1156
  const leader = pack[pack.indexOf(state) - 1];
936
1157
  return leader ? { x: leader.nekoPosX, y: leader.nekoPosY } : null;
937
1158
  }
1159
+ function isNearestCat(state, point) {
1160
+ const distance = (cat) => Math.hypot(cat.nekoPosX - point.x, cat.nekoPosY - point.y);
1161
+ const mine = distance(state);
1162
+ const order = pack.indexOf(state);
1163
+ return pack.every(
1164
+ (cat, index) => cat === state || distance(cat) > mine || distance(cat) === mine && index > order
1165
+ );
1166
+ }
1167
+ function packSize() {
1168
+ return pack.length;
1169
+ }
938
1170
  function napmateNearby(state) {
939
1171
  const reach = TILE * Math.max(1, state.scale) * 2;
940
1172
  return pack.some(
@@ -971,8 +1203,181 @@ function onCatsHiddenChange(callback) {
971
1203
  window.removeEventListener("storage", onStorage);
972
1204
  };
973
1205
  }
1206
+ const GREETING_GAP_MS = 30 * 60 * 1e3;
1207
+ const MAX_AFFECTION = 999;
1208
+ function parseAffection(value) {
1209
+ if (!value || typeof value !== "object" || Array.isArray(value)) return null;
1210
+ const { version, affection, lastVisit } = value;
1211
+ if (version !== 1) return null;
1212
+ if (typeof affection !== "number" || !Number.isFinite(affection)) return null;
1213
+ if (typeof lastVisit !== "number" || !Number.isFinite(lastVisit)) return null;
1214
+ return {
1215
+ version: 1,
1216
+ affection: Math.max(0, Math.min(MAX_AFFECTION, Math.floor(affection))),
1217
+ lastVisit
1218
+ };
1219
+ }
1220
+ function readAffection(key) {
1221
+ try {
1222
+ return parseAffection(JSON.parse(window.localStorage.getItem(key) ?? "null"));
1223
+ } catch {
1224
+ return null;
1225
+ }
1226
+ }
1227
+ function writeAffection(key, affection, lastVisit = Date.now()) {
1228
+ try {
1229
+ window.localStorage.setItem(key, JSON.stringify({ version: 1, affection, lastVisit }));
1230
+ } catch {
1231
+ }
1232
+ }
1233
+ function affectionTier(affection) {
1234
+ if (affection >= 15) return 3;
1235
+ if (affection >= 5) return 2;
1236
+ if (affection >= 1) return 1;
1237
+ return 0;
1238
+ }
1239
+ const GREETINGS = {
1240
+ 0: ["oh. a new hooman", "who are you. do you have snacks", "hello, stranger"],
1241
+ 1: ["you again. acceptable", "I remember you. vaguely", "back so soon?"],
1242
+ 2: ["my hooman is back!", "I saved you a warm pixel", "there you are. I waited"],
1243
+ 3: [
1244
+ "I missed you. don't tell anyone",
1245
+ "best hooman has returned",
1246
+ "finally. I have so much to tell you"
1247
+ ]
1248
+ };
1249
+ function greetingFor(saved, now = Date.now()) {
1250
+ if (saved && now - saved.lastVisit < GREETING_GAP_MS) return null;
1251
+ const pool = GREETINGS[affectionTier(saved?.affection ?? 0)];
1252
+ return pool[Math.floor(Math.random() * pool.length)];
1253
+ }
1254
+ const NEUTRAL_MOOD = { speed: 1, freerun: 1, idle: 1 };
1255
+ function moodForHour(hour) {
1256
+ if (hour >= 5 && hour < 8) return { speed: 1.25, freerun: 2.5, idle: 1.5 };
1257
+ if (hour >= 8 && hour < 12) return { speed: 1.1, freerun: 1.4, idle: 1 };
1258
+ if (hour >= 12 && hour < 17) return { speed: 0.9, freerun: 0.7, idle: 0.6 };
1259
+ if (hour >= 17 && hour < 21) return { speed: 1.25, freerun: 2.5, idle: 1.5 };
1260
+ if (hour >= 21 && hour < 23) return { speed: 1, freerun: 0.8, idle: 0.8 };
1261
+ return { speed: 0.7, freerun: 0.25, idle: 0.35 };
1262
+ }
1263
+ const TREAT_MESSAGES = [
1264
+ "nom nom nom",
1265
+ "fish! my favorite food group",
1266
+ "more please",
1267
+ "this is why I keep you around",
1268
+ "crunchy. 5 stars"
1269
+ ];
1270
+ const TOSS_SOFT_MESSAGES = [
1271
+ "I meant to do that",
1272
+ "nailed the landing",
1273
+ "10/10 would fly again",
1274
+ "always land on my feet",
1275
+ "wheee. again?"
1276
+ ];
1277
+ const TOSS_HARD_MESSAGES = [
1278
+ "HEY. rude",
1279
+ "I was not done being held",
1280
+ "this is a lawsuit",
1281
+ "my dignity. gone",
1282
+ "do that again and I sit on your keyboard"
1283
+ ];
1284
+ const HELD_MESSAGES = [
1285
+ "put me down",
1286
+ "unhand me, hooman",
1287
+ "I am a liquid",
1288
+ "where are we going"
1289
+ ];
1290
+ const BOX_MESSAGES = [
1291
+ "if I fits, I sits",
1292
+ "this box is mine now",
1293
+ "perfect fit",
1294
+ "box. good."
1295
+ ];
1296
+ const KNOCK_STARE_MESSAGES = [
1297
+ "...",
1298
+ "don't you dare look away",
1299
+ "what's this",
1300
+ "watch this"
1301
+ ];
1302
+ const KNOCK_MESSAGES = [
1303
+ "oops",
1304
+ "it was in my way",
1305
+ "gravity check: still works",
1306
+ "I regret nothing",
1307
+ "you saw nothing"
1308
+ ];
1309
+ const FOND_PET_MESSAGES = [
1310
+ "purrrrrr",
1311
+ "you are my favorite",
1312
+ "more pets. now. forever",
1313
+ "this is the good stuff",
1314
+ "*slow blink*"
1315
+ ];
1316
+ const FISH = pixelArtSVG([".OO...O", "OOOO.OO", "OBOOOOO", "OOOO.OO", ".OO...O"], {
1317
+ O: "#f4a261",
1318
+ B: "#1b1b1b"
1319
+ });
1320
+ const PIXEL = 2;
1321
+ const MAX_TREATS = 5;
1322
+ const treats = [];
1323
+ function nearestTreat(x, y, skip) {
1324
+ let best;
1325
+ for (const treat of treats)
1326
+ if (skip?.(treat)) continue;
1327
+ else if (!best || Math.hypot(treat.x - x, treat.y - y) < Math.hypot(best.x - x, best.y - y))
1328
+ best = treat;
1329
+ return best;
1330
+ }
1331
+ function dropTreat(x, y, zIndex) {
1332
+ const el = document.createElement("div");
1333
+ el.setAttribute("aria-hidden", "true");
1334
+ el.setAttribute("data-oneko-treat", "");
1335
+ el.innerHTML = FISH;
1336
+ const width = 9 * PIXEL;
1337
+ const height = 7 * PIXEL;
1338
+ const clampedX = Math.max(width, Math.min(window.innerWidth - width, x));
1339
+ const clampedY = Math.max(height, Math.min(window.innerHeight - height, y));
1340
+ el.style.cssText = [
1341
+ "position:fixed",
1342
+ "pointer-events:none",
1343
+ `left:${clampedX - width / 2}px`,
1344
+ `top:${clampedY - height / 2}px`,
1345
+ `width:${width}px`,
1346
+ `height:${height}px`,
1347
+ `z-index:${zIndex}`
1348
+ ].join(";");
1349
+ document.body.appendChild(el);
1350
+ el.animate?.(
1351
+ [
1352
+ { transform: "translateY(-14px)", opacity: 0 },
1353
+ { transform: "none", opacity: 1 }
1354
+ ],
1355
+ { duration: 220, easing: "cubic-bezier(0.3, 1.4, 0.6, 1)" }
1356
+ );
1357
+ const treat = { x: clampedX, y: clampedY, el, eaten: false };
1358
+ treats.push(treat);
1359
+ while (treats.length > MAX_TREATS) removeTreat(treats[0]);
1360
+ return treat;
1361
+ }
1362
+ function removeTreat(treat) {
1363
+ treat.eaten = true;
1364
+ treat.el.remove();
1365
+ const index = treats.indexOf(treat);
1366
+ if (index !== -1) treats.splice(index, 1);
1367
+ }
1368
+ function eatTreat(treat) {
1369
+ if (treat.eaten) return false;
1370
+ removeTreat(treat);
1371
+ return true;
1372
+ }
1373
+ function clearTreats() {
1374
+ while (treats.length) removeTreat(treats[0]);
1375
+ }
974
1376
  function getActivityLabel(deps) {
975
1377
  const s = deps.stateRef.current;
1378
+ if (s.grab && s.grab.phase !== "landed") {
1379
+ return "alert";
1380
+ }
976
1381
  if (s.freerunMode) {
977
1382
  return "freerun";
978
1383
  }
@@ -1008,6 +1413,551 @@ function playSound(deps, pool) {
1008
1413
  s.soundCooldown = 0;
1009
1414
  });
1010
1415
  }
1416
+ function setSprite(deps, name, frame) {
1417
+ const sprites = defaultSpriteSets[name];
1418
+ if (!sprites?.length) {
1419
+ return;
1420
+ }
1421
+ const sprite = sprites[frame % sprites.length];
1422
+ deps.el.style.backgroundPosition = `${sprite[0] * TILE}px ${sprite[1] * TILE}px`;
1423
+ if (deps.hat) {
1424
+ const offset = hatOffset(sprite[0], sprite[1]);
1425
+ deps.hat.style.display = offset ? "" : "none";
1426
+ if (offset) deps.hat.style.transform = `translate(${offset.left}px, ${offset.top}px)`;
1427
+ }
1428
+ }
1429
+ function getDirectionFromAngle(angle) {
1430
+ if (angle >= 157.5 || angle < -157.5) {
1431
+ return "W";
1432
+ }
1433
+ for (const range of DIRECTION_RANGES) {
1434
+ if (angle >= range.min && angle < range.max) {
1435
+ return range.dir;
1436
+ }
1437
+ }
1438
+ return "E";
1439
+ }
1440
+ function directionFromDelta(dx, dy) {
1441
+ if (Math.abs(dx) < 1 && Math.abs(dy) < 1) {
1442
+ return null;
1443
+ }
1444
+ return getDirectionFromAngle(Math.atan2(dy, dx) * (180 / Math.PI));
1445
+ }
1446
+ function clearRotation(deps) {
1447
+ const s = deps.stateRef.current;
1448
+ if (s.currentRotation !== 0) {
1449
+ s.currentRotation = 0;
1450
+ deps.el.style.transform = `scale(${s.scale}) rotate(0deg)`;
1451
+ }
1452
+ }
1453
+ const CLOCK_CHECK_FRAMES = 600;
1454
+ const ERRAND_CHECK_FRAMES = 50;
1455
+ const ERRAND_CHANCE = 0.25;
1456
+ const ERRAND_REST_FRAMES = 300;
1457
+ const BOX_NAP_FRAMES = 150;
1458
+ const KNOCK_STARE_FRAMES = 12;
1459
+ const KNOCKED_DOWN_MS = 5e3;
1460
+ const MAX_TOSS_SPEED = 3;
1461
+ const HARD_TOSS_SPEED = 1.6;
1462
+ const TYPING_TARGETS = "a,button,input,select,textarea,label,summary,[role='button'],[contenteditable]";
1463
+ function speak(deps, pool) {
1464
+ const s = deps.stateRef.current;
1465
+ if (!s.bubbleEnabledCfg) return;
1466
+ s.bubbleVisible = true;
1467
+ s.bubbleTimer = s.bubbleDisplayFramesCfg;
1468
+ s.bubbleCooldown = 0;
1469
+ deps.bubbleTextEl.textContent = pickFromPool(deps, pool);
1470
+ deps.bubbleEl.style.opacity = "1";
1471
+ }
1472
+ function bumpAffection(s) {
1473
+ s.affection = Math.min(MAX_AFFECTION, s.affection + 1);
1474
+ if (s.affectionCfg) writeAffection(s.affectionKey, s.affection);
1475
+ return s.affection;
1476
+ }
1477
+ function rouse(s) {
1478
+ s.typingNap = false;
1479
+ s.freerunMode = false;
1480
+ s.idleAnimation = null;
1481
+ s.idleAnimationFrame = 0;
1482
+ s.idleTime = Math.max(s.idleTime, 1);
1483
+ }
1484
+ function petCat(deps) {
1485
+ const s = deps.stateRef.current;
1486
+ rouse(s);
1487
+ const affection = bumpAffection(s);
1488
+ speak(deps, s.affectionCfg && affectionTier(affection) >= 2 ? FOND_PET_MESSAGES : PET_MESSAGES);
1489
+ playSound(deps, PET_SOUND_POOL);
1490
+ s.handlers.onPet?.({ affection });
1491
+ }
1492
+ const knocked = /* @__PURE__ */ new Map();
1493
+ function knockOver(el, direction) {
1494
+ if (knocked.has(el) || !(el instanceof HTMLElement || el instanceof SVGElement)) return;
1495
+ const rect = el.getBoundingClientRect();
1496
+ const fall = Math.max(0, window.innerHeight - rect.bottom - 4);
1497
+ const animation = el.animate(
1498
+ [
1499
+ { transform: "none", offset: 0 },
1500
+ {
1501
+ transform: `translateX(${direction * 6}px) rotate(${direction * 12}deg)`,
1502
+ offset: 0.25
1503
+ },
1504
+ {
1505
+ transform: `translate(${direction * 48}px, ${fall}px) rotate(${direction * 100}deg)`,
1506
+ offset: 1
1507
+ }
1508
+ ],
1509
+ {
1510
+ duration: 700,
1511
+ easing: "cubic-bezier(0.5, 0, 0.9, 0.6)",
1512
+ fill: "forwards"
1513
+ }
1514
+ );
1515
+ knocked.set(el, animation);
1516
+ setTimeout(() => {
1517
+ animation.playbackRate = 2;
1518
+ animation.reverse();
1519
+ animation.finished.then(
1520
+ () => {
1521
+ animation.cancel();
1522
+ knocked.delete(el);
1523
+ },
1524
+ () => knocked.delete(el)
1525
+ );
1526
+ }, KNOCKED_DOWN_MS);
1527
+ }
1528
+ function restoreKnocked() {
1529
+ for (const animation of knocked.values()) animation.cancel();
1530
+ knocked.clear();
1531
+ }
1532
+ function isVisible(el) {
1533
+ const rect = el.getBoundingClientRect();
1534
+ return rect.width > 0 && rect.height > 0 && rect.top >= 0 && rect.left >= 0 && rect.bottom <= window.innerHeight && rect.right <= window.innerWidth;
1535
+ }
1536
+ const handledEvents = /* @__PURE__ */ new WeakSet();
1537
+ function isOverText(x, y) {
1538
+ const node = document.caretRangeFromPoint?.(x, y)?.startContainer;
1539
+ if (!node || node.nodeType !== Node.TEXT_NODE || !node.textContent?.trim()) return false;
1540
+ const range = document.createRange();
1541
+ range.selectNodeContents(node);
1542
+ return [...range.getClientRects()].some(
1543
+ (rect) => x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom
1544
+ );
1545
+ }
1546
+ function createPlay(deps) {
1547
+ const s = () => deps.stateRef.current;
1548
+ const { el } = deps;
1549
+ let seasonKey = "";
1550
+ let seasonCheckedFrame = -Infinity;
1551
+ let grabbable = null;
1552
+ let flight = 0;
1553
+ let sayTarget = null;
1554
+ let sayTimer;
1555
+ const unreachable = /* @__PURE__ */ new WeakSet();
1556
+ const wearHat = () => {
1557
+ const state = s();
1558
+ const key = String(state.seasonalCfg);
1559
+ if (key === seasonKey && state.frameCount - seasonCheckedFrame < CLOCK_CHECK_FRAMES) return;
1560
+ seasonKey = key;
1561
+ seasonCheckedFrame = state.frameCount;
1562
+ const season = resolveSeason(state.seasonalCfg);
1563
+ if (season === state.season && (deps.hat || !season)) return;
1564
+ state.season = season;
1565
+ deps.hat?.remove();
1566
+ deps.hat = void 0;
1567
+ if (!season) return;
1568
+ const hat = document.createElement("div");
1569
+ hat.setAttribute("data-oneko-hat", season);
1570
+ hat.style.cssText = `position:absolute;left:0;top:0;width:${HAT_WIDTH}px;height:${HAT_HEIGHT}px;pointer-events:none;display:none`;
1571
+ hat.innerHTML = hatSVG(season);
1572
+ el.appendChild(hat);
1573
+ deps.hat = hat;
1574
+ };
1575
+ const readClock = () => {
1576
+ const state = s();
1577
+ if (!state.timeOfDayCfg) {
1578
+ state.mood = NEUTRAL_MOOD;
1579
+ state.moodCheckedFrame = -Infinity;
1580
+ } else if (state.frameCount - state.moodCheckedFrame >= CLOCK_CHECK_FRAMES) {
1581
+ state.mood = moodForHour((/* @__PURE__ */ new Date()).getHours());
1582
+ state.moodCheckedFrame = state.frameCount;
1583
+ }
1584
+ };
1585
+ const greet = () => {
1586
+ const state = s();
1587
+ if (!state.affectionCfg) {
1588
+ state.affectionLoaded = false;
1589
+ return;
1590
+ }
1591
+ if (state.affectionLoaded) return;
1592
+ state.affectionLoaded = true;
1593
+ const saved = readAffection(state.affectionKey);
1594
+ state.affection = Math.max(state.affection, saved?.affection ?? 0);
1595
+ const greeting = greetingFor(saved);
1596
+ writeAffection(state.affectionKey, state.affection);
1597
+ if (greeting && state.bubbleEnabledCfg)
1598
+ state.sayRequest = {
1599
+ text: greeting,
1600
+ frames: state.bubbleDisplayFramesCfg
1601
+ };
1602
+ };
1603
+ const syncGrabbable = () => {
1604
+ const on = s().grabbableCfg;
1605
+ if (on === grabbable) return;
1606
+ grabbable = on;
1607
+ el.style.pointerEvents = on ? "auto" : "none";
1608
+ el.style.cursor = on ? "grab" : "";
1609
+ el.style.touchAction = on ? "none" : "";
1610
+ if (!on) el.removeAttribute("title");
1611
+ else if (!el.hasAttribute("aria-label")) el.title = "Drag to toss, click to pet";
1612
+ };
1613
+ const chooseErrand = () => {
1614
+ const state = s();
1615
+ if (state.grab || !state.followCursorCfg || state.typingNap || state.held) return;
1616
+ const current = state.errand?.kind;
1617
+ if (current === "goTo" || current === "sit") return;
1618
+ const treat = nearestTreat(state.nekoPosX, state.nekoPosY, (item) => unreachable.has(item));
1619
+ if (treat && current !== "treat") {
1620
+ rouse(state);
1621
+ state.errand = { kind: "treat", treat, frames: 0 };
1622
+ return;
1623
+ }
1624
+ if (state.errand || state.laserPointerCfg || state.perchTarget || state.zoneState.targetId)
1625
+ return;
1626
+ if (--state.errandCooldown > 0) return;
1627
+ state.errandCooldown = ERRAND_CHECK_FRAMES;
1628
+ if (state.idleTime === 0 || Math.random() >= ERRAND_CHANCE) return;
1629
+ const size = TILE * state.scale;
1630
+ const boxes = [...document.querySelectorAll("[data-oneko-box]")].filter((box) => {
1631
+ const rect = box.getBoundingClientRect();
1632
+ return isVisible(box) && rect.width >= size * 0.75 && rect.height >= size * 0.5;
1633
+ });
1634
+ const knockables = [...document.querySelectorAll("[data-oneko-knockable]")].filter(
1635
+ (item) => isVisible(item) && !knocked.has(item)
1636
+ );
1637
+ const pick = [...boxes, ...knockables][Math.floor(Math.random() * (boxes.length + knockables.length))];
1638
+ if (!pick) return;
1639
+ if (boxes.includes(pick)) {
1640
+ state.errand = { kind: "box", el: pick, frames: 0, arrived: false };
1641
+ } else {
1642
+ const rect = pick.getBoundingClientRect();
1643
+ state.errand = {
1644
+ kind: "knock",
1645
+ el: pick,
1646
+ frames: 0,
1647
+ phase: "approach",
1648
+ // Approach from the side the cat is already on, then push the other way.
1649
+ direction: state.nekoPosX <= rect.left + rect.width / 2 ? 1 : -1
1650
+ };
1651
+ }
1652
+ };
1653
+ const upkeep = () => {
1654
+ const state = s();
1655
+ wearHat();
1656
+ readClock();
1657
+ greet();
1658
+ syncGrabbable();
1659
+ if (state.petRequest) {
1660
+ state.petRequest = false;
1661
+ petCat(deps);
1662
+ }
1663
+ chooseErrand();
1664
+ };
1665
+ const endErrand = (cooldown = 0) => {
1666
+ const state = s();
1667
+ state.errand = null;
1668
+ if (cooldown) state.errandCooldown = cooldown;
1669
+ };
1670
+ const tickErrand = () => {
1671
+ const state = s();
1672
+ const errand = state.errand;
1673
+ const chasingCursor = !errand && state.followCursorCfg && !state.typingNap && !state.held && !state.perchTarget && !state.leaderTarget && !state.zoneState.targetId;
1674
+ const toCursor = Math.hypot(state.mousePosX - state.nekoPosX, state.mousePosY - state.nekoPosY);
1675
+ if (!chasingCursor) state.chasingFar = false;
1676
+ else if (toCursor > 160 && state.idleTime === 0) state.chasingFar = true;
1677
+ else if (state.chasingFar && state.idleTime > 0) {
1678
+ state.chasingFar = false;
1679
+ state.handlers.onCatchCursor?.();
1680
+ }
1681
+ if (!errand || errand.kind === "sit") return;
1682
+ const point = errandPoint(state, errand);
1683
+ if (!point) {
1684
+ if (errand.kind === "goTo") errand.resolve?.(false);
1685
+ endErrand();
1686
+ return;
1687
+ }
1688
+ const distance = Math.hypot(point.x - state.nekoPosX, point.y - state.nekoPosY);
1689
+ const arrived = distance < 14 || state.idleTime > 2 && distance < 64;
1690
+ switch (errand.kind) {
1691
+ case "goTo":
1692
+ if (!errand.arrived && (arrived || state.idleTime > 2 && ++errand.frames > 30)) {
1693
+ errand.arrived = true;
1694
+ errand.resolve?.(true);
1695
+ errand.resolve = void 0;
1696
+ }
1697
+ return;
1698
+ case "treat":
1699
+ if (arrived) {
1700
+ if (eatTreat(errand.treat)) {
1701
+ const affection = bumpAffection(state);
1702
+ speak(deps, TREAT_MESSAGES);
1703
+ playSound(deps, LASER_CATCH_POOL);
1704
+ state.handlers.onTreat?.({ affection });
1705
+ }
1706
+ endErrand();
1707
+ } else if (++errand.frames > 300 || state.idleTime > 20) {
1708
+ unreachable.add(errand.treat);
1709
+ endErrand();
1710
+ }
1711
+ return;
1712
+ case "box":
1713
+ if (!errand.arrived) {
1714
+ if (arrived) {
1715
+ errand.arrived = true;
1716
+ errand.frames = 0;
1717
+ speak(deps, BOX_MESSAGES);
1718
+ if (state.sleepEnabledCfg) {
1719
+ state.idleAnimation = "sleeping";
1720
+ state.idleAnimationFrame = 0;
1721
+ }
1722
+ } else if (++errand.frames > 100) endErrand(ERRAND_REST_FRAMES);
1723
+ } else if (++errand.frames > BOX_NAP_FRAMES) endErrand(ERRAND_REST_FRAMES);
1724
+ return;
1725
+ case "knock":
1726
+ if (errand.phase === "approach") {
1727
+ if (arrived) {
1728
+ errand.phase = "stare";
1729
+ errand.frames = 0;
1730
+ rouse(state);
1731
+ speak(deps, KNOCK_STARE_MESSAGES);
1732
+ } else if (++errand.frames > 100) endErrand(ERRAND_REST_FRAMES);
1733
+ return;
1734
+ }
1735
+ setSprite(
1736
+ deps,
1737
+ ++errand.frames >= KNOCK_STARE_FRAMES ? errand.direction === 1 ? "E" : "W" : "idle",
1738
+ errand.frames
1739
+ );
1740
+ if (errand.frames >= KNOCK_STARE_FRAMES) {
1741
+ knockOver(errand.el, errand.direction);
1742
+ speak(deps, KNOCK_MESSAGES);
1743
+ playSound(deps, HISS_POOL);
1744
+ endErrand(ERRAND_REST_FRAMES);
1745
+ }
1746
+ return;
1747
+ }
1748
+ };
1749
+ const place = (x, y) => {
1750
+ const state = s();
1751
+ state.nekoPosX = x;
1752
+ state.nekoPosY = y;
1753
+ el.style.left = `${Math.floor(x - TILE / 2)}px`;
1754
+ el.style.top = `${Math.floor(y - TILE / 2)}px`;
1755
+ };
1756
+ const land = (hard) => {
1757
+ const state = s();
1758
+ cancelAnimationFrame(flight);
1759
+ flight = 0;
1760
+ state.grab = { phase: "landed", frames: 8 };
1761
+ state.nekoVelX = state.nekoVelY = 0;
1762
+ state.currentPath = [];
1763
+ clearRotation(deps);
1764
+ speak(deps, hard ? TOSS_HARD_MESSAGES : TOSS_SOFT_MESSAGES);
1765
+ playSound(deps, hard ? HISS_POOL : PET_SOUND_POOL);
1766
+ };
1767
+ const fly = (vx, vy, hard) => {
1768
+ let last = performance.now();
1769
+ let spin = 0;
1770
+ const step = (now) => {
1771
+ const state = s();
1772
+ if (state.grab?.phase !== "flying") return;
1773
+ const dt = Math.min(50, now - last);
1774
+ last = now;
1775
+ if (state.pausedCfg) {
1776
+ flight = requestAnimationFrame(step);
1777
+ return;
1778
+ }
1779
+ const from = { x: state.nekoPosX, y: state.nekoPosY };
1780
+ let x = from.x + vx * dt;
1781
+ let y = from.y + vy * dt;
1782
+ const maxX = window.innerWidth - 16;
1783
+ const maxY = window.innerHeight - 16;
1784
+ if (x < 16 || x > maxX) {
1785
+ vx *= -0.5;
1786
+ x = Math.max(16, Math.min(maxX, x));
1787
+ }
1788
+ if (y < 16 || y > maxY) {
1789
+ vy *= -0.5;
1790
+ y = Math.max(16, Math.min(maxY, y));
1791
+ }
1792
+ const safe = constrainZoneMovement(from, { x, y }, state.zoneState.blocked);
1793
+ if (safe.x !== x || safe.y !== y) vx = vy = 0;
1794
+ place(safe.x, safe.y);
1795
+ const drag = Math.exp(-dt / 260);
1796
+ vx *= drag;
1797
+ vy *= drag;
1798
+ spin += vx * dt * 0.9;
1799
+ el.style.transform = `scale(${state.scale}) rotate(${spin}deg)`;
1800
+ if (Math.hypot(vx, vy) < 0.06) land(hard);
1801
+ else flight = requestAnimationFrame(step);
1802
+ };
1803
+ flight = requestAnimationFrame(step);
1804
+ };
1805
+ const tickGrab = () => {
1806
+ const state = s();
1807
+ const grab = state.grab;
1808
+ if (!grab) return false;
1809
+ state.idleAnimation = null;
1810
+ state.freerunMode = false;
1811
+ if (grab.phase === "landed") {
1812
+ setSprite(deps, "idle", 0);
1813
+ state.idleTime = Math.max(state.idleTime, 1);
1814
+ if (--grab.frames <= 0) state.grab = null;
1815
+ return true;
1816
+ }
1817
+ setSprite(deps, "alert", 0);
1818
+ state.idleTime = 0;
1819
+ return true;
1820
+ };
1821
+ const onPointerDown = (event) => {
1822
+ const state = s();
1823
+ if (!state.grabbableCfg || event.button > 0 || state.pausedCfg) return;
1824
+ event.preventDefault();
1825
+ el.setPointerCapture?.(event.pointerId);
1826
+ cancelAnimationFrame(flight);
1827
+ rouse(state);
1828
+ if (state.errand?.kind === "box" || state.errand?.kind === "knock") endErrand();
1829
+ state.grab = {
1830
+ phase: "held",
1831
+ samples: [{ x: event.clientX, y: event.clientY, t: event.timeStamp }],
1832
+ startT: event.timeStamp,
1833
+ travel: 0
1834
+ };
1835
+ el.style.cursor = "grabbing";
1836
+ setSprite(deps, "alert", 0);
1837
+ clearRotation(deps);
1838
+ };
1839
+ const onPointerMove = (event) => {
1840
+ const state = s();
1841
+ const grab = state.grab;
1842
+ if (grab?.phase !== "held") return;
1843
+ const previous = grab.samples.at(-1);
1844
+ grab.travel += Math.hypot(event.clientX - previous.x, event.clientY - previous.y);
1845
+ grab.samples.push({
1846
+ x: event.clientX,
1847
+ y: event.clientY,
1848
+ t: event.timeStamp
1849
+ });
1850
+ if (grab.samples.length > 8) grab.samples.shift();
1851
+ place(
1852
+ Math.max(16, Math.min(window.innerWidth - 16, event.clientX)),
1853
+ Math.max(16, Math.min(window.innerHeight - 16, event.clientY))
1854
+ );
1855
+ state.mousePosX = event.clientX;
1856
+ state.mousePosY = event.clientY;
1857
+ if (grab.travel > 40 && !state.bubbleVisible && Math.random() < 0.02)
1858
+ speak(deps, HELD_MESSAGES);
1859
+ };
1860
+ const onPointerUp = (event) => {
1861
+ const state = s();
1862
+ const grab = state.grab;
1863
+ if (grab?.phase !== "held") return;
1864
+ el.style.cursor = "grab";
1865
+ if (grab.travel < 6 && event.timeStamp - grab.startT < 500) {
1866
+ state.grab = null;
1867
+ petCat(deps);
1868
+ return;
1869
+ }
1870
+ const recent = grab.samples.filter((sample) => event.timeStamp - sample.t < 100);
1871
+ const first = recent[0] ?? grab.samples.at(-1);
1872
+ const elapsed = Math.max(16, event.timeStamp - first.t);
1873
+ let vx = (event.clientX - first.x) / elapsed;
1874
+ let vy = (event.clientY - first.y) / elapsed;
1875
+ const speed = Math.hypot(vx, vy);
1876
+ if (speed > MAX_TOSS_SPEED) {
1877
+ vx = vx / speed * MAX_TOSS_SPEED;
1878
+ vy = vy / speed * MAX_TOSS_SPEED;
1879
+ }
1880
+ if (speed < 0.25) {
1881
+ land(false);
1882
+ return;
1883
+ }
1884
+ const hard = speed > HARD_TOSS_SPEED;
1885
+ state.grab = { phase: "flying", vx, vy, spin: 0, hard };
1886
+ state.handlers.onToss?.({
1887
+ speed: Math.round(Math.min(speed, MAX_TOSS_SPEED) * 1e3)
1888
+ });
1889
+ fly(vx, vy, hard);
1890
+ };
1891
+ const onPointerCancel = () => {
1892
+ if (s().grab?.phase === "held") land(false);
1893
+ };
1894
+ const onDoubleClick = (event) => {
1895
+ const state = s();
1896
+ if (!state.treatsCfg || handledEvents.has(event)) return;
1897
+ if (event.target instanceof Element && event.target.closest(TYPING_TARGETS)) return;
1898
+ if (window.getSelection()?.toString().trim() || isOverText(event.clientX, event.clientY))
1899
+ return;
1900
+ const point = { x: event.clientX, y: event.clientY };
1901
+ if (state.zoneState.blocked.some((rect) => containsPoint(rect, point))) return;
1902
+ handledEvents.add(event);
1903
+ dropTreat(point.x, point.y, state.zIndex);
1904
+ };
1905
+ const requestSay = (target) => {
1906
+ const state = s();
1907
+ const text = target.getAttribute("data-oneko-say")?.trim();
1908
+ if (!text || !state.bubbleEnabledCfg || state.pausedCfg) return;
1909
+ const rect = target.getBoundingClientRect();
1910
+ if (!isNearestCat(state, {
1911
+ x: rect.left + rect.width / 2,
1912
+ y: rect.top + rect.height / 2
1913
+ }))
1914
+ return;
1915
+ state.sayRequest = {
1916
+ text: text.slice(0, 200),
1917
+ frames: state.bubbleDisplayFramesCfg
1918
+ };
1919
+ };
1920
+ const onMouseOver = (event) => {
1921
+ const target = event.target instanceof Element ? event.target.closest("[data-oneko-say]") : null;
1922
+ if (target === sayTarget) return;
1923
+ sayTarget = target;
1924
+ clearTimeout(sayTimer);
1925
+ if (target) sayTimer = setTimeout(() => requestSay(target), 350);
1926
+ };
1927
+ const onFocusIn = (event) => {
1928
+ const target = event.target instanceof Element ? event.target.closest("[data-oneko-say]") : null;
1929
+ if (target) requestSay(target);
1930
+ };
1931
+ syncGrabbable();
1932
+ el.addEventListener("pointerdown", onPointerDown);
1933
+ el.addEventListener("pointermove", onPointerMove);
1934
+ el.addEventListener("pointerup", onPointerUp);
1935
+ el.addEventListener("pointercancel", onPointerCancel);
1936
+ document.addEventListener("dblclick", onDoubleClick);
1937
+ document.addEventListener("mouseover", onMouseOver);
1938
+ document.addEventListener("focusin", onFocusIn);
1939
+ const dispose = () => {
1940
+ const state = s();
1941
+ cancelAnimationFrame(flight);
1942
+ clearTimeout(sayTimer);
1943
+ el.removeEventListener("pointerdown", onPointerDown);
1944
+ el.removeEventListener("pointermove", onPointerMove);
1945
+ el.removeEventListener("pointerup", onPointerUp);
1946
+ el.removeEventListener("pointercancel", onPointerCancel);
1947
+ document.removeEventListener("dblclick", onDoubleClick);
1948
+ document.removeEventListener("mouseover", onMouseOver);
1949
+ document.removeEventListener("focusin", onFocusIn);
1950
+ if (state.affectionCfg && state.affectionLoaded)
1951
+ writeAffection(state.affectionKey, state.affection);
1952
+ if (state.errand?.kind === "goTo") state.errand.resolve?.(false);
1953
+ state.errand = null;
1954
+ state.grab = null;
1955
+ state.season = null;
1956
+ deps.hat?.remove();
1957
+ deps.hat = void 0;
1958
+ };
1959
+ return { upkeep, tickGrab, tickErrand, dispose };
1960
+ }
1011
1961
  const PERCH_SELECTOR = "a[href],button,[role='button'],[data-oneko-perch]";
1012
1962
  const PERCH_DELAY_MS = 700;
1013
1963
  const NOT_TYPED_INPUTS = /* @__PURE__ */ new Set([
@@ -1022,7 +1972,7 @@ const NOT_TYPED_INPUTS = /* @__PURE__ */ new Set([
1022
1972
  "submit"
1023
1973
  ]);
1024
1974
  function stopDistance(s) {
1025
- if (s.perchTarget) return Math.min(s.followDistanceCfg, 8);
1975
+ if (s.errand || s.perchTarget) return Math.min(s.followDistanceCfg, 8);
1026
1976
  if (s.leaderTarget) return Math.max(s.followDistanceCfg, TILE * s.scale);
1027
1977
  return s.followDistanceCfg;
1028
1978
  }
@@ -1144,19 +2094,7 @@ function makePettable(deps) {
1144
2094
  if (!event.repeat) pet();
1145
2095
  };
1146
2096
  const pet = () => {
1147
- const s = deps.stateRef.current;
1148
- s.typingNap = false;
1149
- s.freerunMode = false;
1150
- s.idleAnimation = null;
1151
- s.idleAnimationFrame = 0;
1152
- s.idleTime = Math.max(s.idleTime, 1);
1153
- if (s.bubbleEnabledCfg) {
1154
- s.bubbleVisible = true;
1155
- s.bubbleTimer = s.bubbleDisplayFramesCfg;
1156
- deps.bubbleTextEl.textContent = pickFromPool(deps, PET_MESSAGES);
1157
- deps.bubbleEl.style.opacity = "1";
1158
- }
1159
- playSound(deps, PET_SOUND_POOL);
2097
+ petCat(deps);
1160
2098
  announcer.textContent = ++pets % 2 ? "The cat purrs." : "The cat purrs happily.";
1161
2099
  };
1162
2100
  el.addEventListener("focus", onFocus);
@@ -1342,7 +2280,7 @@ function collectObstacles() {
1342
2280
  const vh = window.innerHeight;
1343
2281
  const rects = [];
1344
2282
  for (const node of document.querySelectorAll(OBSTACLE_SELECTOR)) {
1345
- if (node.closest('[data-oneko-zone="attract"]')) continue;
2283
+ if (node.closest('[data-oneko-zone="attract"],[data-oneko-box]')) continue;
1346
2284
  const r = node.getBoundingClientRect();
1347
2285
  if (shouldIncludeObstacleRect(r, vw, vh)) {
1348
2286
  rects.push(toObstacleRect(r));
@@ -1516,40 +2454,8 @@ function findRoute(startIdx, goalIdx, grid, cols, rows) {
1516
2454
  }
1517
2455
  return smoothPath(reconstructCellPath(prev, goalIdx), cols);
1518
2456
  }
1519
- function setSprite(deps, name, frame) {
1520
- const sprites = defaultSpriteSets[name];
1521
- if (!sprites?.length) {
1522
- return;
1523
- }
1524
- const sprite = sprites[frame % sprites.length];
1525
- deps.el.style.backgroundPosition = `${sprite[0] * TILE}px ${sprite[1] * TILE}px`;
1526
- }
1527
- function getDirectionFromAngle(angle) {
1528
- if (angle >= 157.5 || angle < -157.5) {
1529
- return "W";
1530
- }
1531
- for (const range of DIRECTION_RANGES) {
1532
- if (angle >= range.min && angle < range.max) {
1533
- return range.dir;
1534
- }
1535
- }
1536
- return "E";
1537
- }
1538
- function directionFromDelta(dx, dy) {
1539
- if (Math.abs(dx) < 1 && Math.abs(dy) < 1) {
1540
- return null;
1541
- }
1542
- return getDirectionFromAngle(Math.atan2(dy, dx) * (180 / Math.PI));
1543
- }
1544
- function clearRotation(deps) {
1545
- const s = deps.stateRef.current;
1546
- if (s.currentRotation !== 0) {
1547
- s.currentRotation = 0;
1548
- deps.el.style.transform = `scale(${s.scale}) rotate(0deg)`;
1549
- }
1550
- }
1551
2457
  function idleFrameThreshold(s) {
1552
- return Math.max(1, Math.round(s.idleThresholdMs / 100));
2458
+ return Math.max(1, Math.round(s.idleThresholdMs * s.mood.idle / 100));
1553
2459
  }
1554
2460
  function wallScratchAnimations(s) {
1555
2461
  return [
@@ -1692,7 +2598,7 @@ function moveToward(deps, targetX, targetY) {
1692
2598
  s.nekoVelY *= 0.5;
1693
2599
  return;
1694
2600
  }
1695
- const currentSpeed = s.currentSpeed;
2601
+ const currentSpeed = s.currentSpeed * s.mood.speed;
1696
2602
  s.nekoVelX += (dx / dist * currentSpeed - s.nekoVelX) * STEER_LERP;
1697
2603
  s.nekoVelY += (dy / dist * currentSpeed - s.nekoVelY) * STEER_LERP;
1698
2604
  const velMag = Math.hypot(s.nekoVelX, s.nekoVelY);
@@ -1782,7 +2688,7 @@ function chaseCursor(deps) {
1782
2688
  tickFreerun(deps);
1783
2689
  return;
1784
2690
  }
1785
- if (!s.noFollow && Math.random() < s.freerunChanceCfg) {
2691
+ if (!s.noFollow && !s.errand && Math.random() < s.freerunChanceCfg * s.mood.freerun) {
1786
2692
  enterFreerun(deps);
1787
2693
  moveToward(deps, s.zoneState.movementTarget.x, s.zoneState.movementTarget.y);
1788
2694
  return;
@@ -1886,6 +2792,7 @@ function tickSounds(deps) {
1886
2792
  if (currentState !== deps.lastStateRef.current) {
1887
2793
  deps.lastStateRef.current = currentState;
1888
2794
  deps.onStateChangeRef.current?.(currentState);
2795
+ if (currentState === "sleeping") deps.stateRef.current.handlers.onSleep?.();
1889
2796
  const pool = SOUND_POOLS[currentState];
1890
2797
  if (pool) {
1891
2798
  playSound(deps, pool);
@@ -1894,17 +2801,23 @@ function tickSounds(deps) {
1894
2801
  tickAmbientSounds(deps, currentState);
1895
2802
  return currentState;
1896
2803
  }
1897
- function createFrameLoop(deps) {
2804
+ function createFrameLoop(deps, play) {
1898
2805
  const { maybeRenderDebug } = createDebugRenderer(deps);
1899
2806
  const frame = () => {
1900
2807
  const s = deps.stateRef.current;
1901
2808
  if (s.pausedCfg) return;
1902
2809
  if (!refreshZones(deps)) return;
1903
2810
  tickPrelude(s);
2811
+ play?.upkeep();
1904
2812
  tickAttention(deps);
1905
2813
  updateZoneTarget(s, window.innerWidth, window.innerHeight);
1906
2814
  const dist = Math.hypot(s.mousePosX - s.nekoPosX, s.mousePosY - s.nekoPosY);
1907
- tickMovement(deps, dist);
2815
+ if (play?.tickGrab()) {
2816
+ updateBubble(deps);
2817
+ } else {
2818
+ tickMovement(deps, dist);
2819
+ play?.tickErrand();
2820
+ }
1908
2821
  maybeRenderDebug();
1909
2822
  const currentState = tickSounds(deps);
1910
2823
  tickLiveState(deps, dist, currentState);
@@ -2454,13 +3367,13 @@ function readPersistedPosition(storageKey = ONEKO_DEFAULTS.storageKey) {
2454
3367
  return null;
2455
3368
  }
2456
3369
  if (!position) return null;
2457
- const clamp = (value, extent) => {
3370
+ const clamp2 = (value, extent) => {
2458
3371
  const inset = Math.min(TILE / 2, Math.max(0, extent / 2));
2459
3372
  return Math.max(inset, Math.min(Math.max(inset, extent - inset), value));
2460
3373
  };
2461
3374
  return {
2462
- x: clamp(position.x, window.innerWidth),
2463
- y: clamp(position.y, window.innerHeight)
3375
+ x: clamp2(position.x, window.innerWidth),
3376
+ y: clamp2(position.y, window.innerHeight)
2464
3377
  };
2465
3378
  }
2466
3379
  function loadPersistedCatState(stateRef, el, storageKey = ONEKO_DEFAULTS.storageKey) {
@@ -2545,6 +3458,8 @@ function startCatAnimation({
2545
3458
  }) {
2546
3459
  stateRef.current.bubbleVisible = false;
2547
3460
  stateRef.current.bubbleTimer = 0;
3461
+ stateRef.current.zIndex = zIndex;
3462
+ stateRef.current.affectionKey = `${storageKey}:affection`;
2548
3463
  const el = createCatElement(stateRef.current, zIndex);
2549
3464
  elRef.current = el;
2550
3465
  const debugSVG = createDebugSVG(zIndex);
@@ -2562,7 +3477,8 @@ function startCatAnimation({
2562
3477
  onStateChangeRef,
2563
3478
  liveStateRef
2564
3479
  };
2565
- const frame = createFrameLoop(deps);
3480
+ const play = createPlay(deps);
3481
+ const frame = createFrameLoop(deps, play);
2566
3482
  const debugControls = createDebugControls({
2567
3483
  stateRef,
2568
3484
  el,
@@ -2586,7 +3502,9 @@ function startCatAnimation({
2586
3502
  document.addEventListener("mousemove", onMouseMove);
2587
3503
  const onPointerDown = (event) => {
2588
3504
  if (event.pointerType !== "touch") return;
2589
- if (event.target instanceof Element && event.target.closest("button,a,input,select,textarea,label,summary,[role='button']"))
3505
+ if (event.target instanceof Element && event.target.closest(
3506
+ "button,a,input,select,textarea,label,summary,[role='button'],[data-oneko-cat]"
3507
+ ))
2590
3508
  return;
2591
3509
  onMouseMove(event);
2592
3510
  };
@@ -2614,7 +3532,12 @@ function startCatAnimation({
2614
3532
  return () => {
2615
3533
  stopPetting?.();
2616
3534
  stopWatchingPage();
3535
+ play.dispose();
2617
3536
  leavePack();
3537
+ if (packSize() === 0) {
3538
+ clearTreats();
3539
+ restoreKnocked();
3540
+ }
2618
3541
  persist?.();
2619
3542
  teardownCatAnimation(
2620
3543
  { el, debugSVG, debugWrapper, bubbleEl },
@@ -2669,7 +3592,13 @@ function defaultRuntimeConfigState() {
2669
3592
  chaseCfg: ONEKO_DEFAULTS.chase,
2670
3593
  napWhileTypingCfg: ONEKO_DEFAULTS.napWhileTyping,
2671
3594
  followFocusCfg: ONEKO_DEFAULTS.followFocus,
2672
- perchOnHoverCfg: ONEKO_DEFAULTS.perchOnHover
3595
+ perchOnHoverCfg: ONEKO_DEFAULTS.perchOnHover,
3596
+ seasonalCfg: ONEKO_DEFAULTS.seasonal,
3597
+ grabbableCfg: ONEKO_DEFAULTS.grabbable,
3598
+ treatsCfg: ONEKO_DEFAULTS.treats,
3599
+ timeOfDayCfg: ONEKO_DEFAULTS.timeOfDay,
3600
+ affectionCfg: ONEKO_DEFAULTS.affection,
3601
+ handlers: {}
2673
3602
  };
2674
3603
  }
2675
3604
  function applyRuntimeConfig(state, config) {
@@ -2719,6 +3648,20 @@ function applyRuntimeConfig(state, config) {
2719
3648
  state.perchOnHoverCfg = config.perchOnHover ?? ONEKO_DEFAULTS.perchOnHover;
2720
3649
  if (!state.napWhileTypingCfg) state.typingNap = false;
2721
3650
  if (!state.perchOnHoverCfg) state.perchTarget = null;
3651
+ const seasonal = config.seasonal ?? ONEKO_DEFAULTS.seasonal;
3652
+ state.seasonalCfg = typeof seasonal === "boolean" || isOnekoSeason(seasonal) ? seasonal : true;
3653
+ state.grabbableCfg = config.grabbable ?? ONEKO_DEFAULTS.grabbable;
3654
+ state.treatsCfg = config.treats ?? ONEKO_DEFAULTS.treats;
3655
+ state.timeOfDayCfg = config.timeOfDay ?? ONEKO_DEFAULTS.timeOfDay;
3656
+ state.affectionCfg = config.affection ?? ONEKO_DEFAULTS.affection;
3657
+ state.handlers = {
3658
+ onPet: config.onPet,
3659
+ onCatchCursor: config.onCatchCursor,
3660
+ onSleep: config.onSleep,
3661
+ onToss: config.onToss,
3662
+ onTreat: config.onTreat
3663
+ };
3664
+ if (!state.grabbableCfg && state.grab?.phase === "held") state.grab = null;
2722
3665
  }
2723
3666
  const classic = "data:image/png;base64,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";
2724
3667
  const classicSheet = {
@@ -2802,6 +3745,67 @@ async function loadSkinSource(skin = "classic") {
2802
3745
  }
2803
3746
  return loaded.get(id) ?? loaded.get("classic");
2804
3747
  }
3748
+ function createCatControls(stateRef) {
3749
+ const s = () => stateRef.current;
3750
+ const release = () => {
3751
+ const errand = s().errand;
3752
+ if (errand?.kind === "goTo") errand.resolve?.(false);
3753
+ s().errand = null;
3754
+ };
3755
+ return {
3756
+ goTo(target) {
3757
+ const resolved = typeof target === "string" ? document.querySelector(target) : target;
3758
+ release();
3759
+ if (!resolved) return Promise.resolve(false);
3760
+ return new Promise((resolve) => {
3761
+ const state = s();
3762
+ state.errand = {
3763
+ kind: "goTo",
3764
+ target: resolved,
3765
+ frames: 0,
3766
+ arrived: false,
3767
+ resolve
3768
+ };
3769
+ state.typingNap = false;
3770
+ });
3771
+ },
3772
+ say(text, options = {}) {
3773
+ const state = s();
3774
+ const frames = Number.isFinite(options.duration) ? Math.max(1, Math.round(options.duration / 100)) : state.bubbleDisplayFramesCfg;
3775
+ state.sayRequest = { text: String(text).slice(0, 280), frames };
3776
+ },
3777
+ sit() {
3778
+ release();
3779
+ s().errand = { kind: "sit" };
3780
+ },
3781
+ follow: release,
3782
+ pet() {
3783
+ s().petRequest = true;
3784
+ },
3785
+ dropTreat(x, y) {
3786
+ const state = s();
3787
+ dropTreat(x ?? state.mousePosX, y ?? state.mousePosY, state.zIndex);
3788
+ }
3789
+ };
3790
+ }
3791
+ function defaultPlayState() {
3792
+ return {
3793
+ season: null,
3794
+ grab: null,
3795
+ errand: null,
3796
+ errandCooldown: 50,
3797
+ sayRequest: null,
3798
+ bubbleForced: false,
3799
+ petRequest: false,
3800
+ chasingFar: false,
3801
+ mood: { speed: 1, freerun: 1, idle: 1 },
3802
+ moodCheckedFrame: -Infinity,
3803
+ affection: 0,
3804
+ affectionLoaded: false,
3805
+ affectionKey: "oneko:affection",
3806
+ zIndex: DEFAULT_Z_INDEX
3807
+ };
3808
+ }
2805
3809
  function createInitialCatState(options) {
2806
3810
  const startX = options.initialPos?.x ?? DEFAULT_POSITION.x;
2807
3811
  const startY = options.initialPos?.y ?? DEFAULT_POSITION.y;
@@ -2840,7 +3844,8 @@ function createInitialCatState(options) {
2840
3844
  typingNap: false,
2841
3845
  held: false,
2842
3846
  perchTarget: null,
2843
- leaderTarget: null
3847
+ leaderTarget: null,
3848
+ ...defaultPlayState()
2844
3849
  };
2845
3850
  applyRuntimeConfig(state, options);
2846
3851
  return state;
@@ -2921,6 +3926,7 @@ function createOneko(initial = {}, base = {}) {
2921
3926
  const stopWatchingHidden = onCatsHiddenChange(start);
2922
3927
  start();
2923
3928
  return {
3929
+ ...createCatControls(stateRef),
2924
3930
  update(next) {
2925
3931
  if (destroyed) return;
2926
3932
  const previous = options;
@@ -2980,10 +3986,11 @@ export {
2980
3986
  applyRuntimeConfig as a,
2981
3987
  createInitialCatState as b,
2982
3988
  createOneko as c,
2983
- areCatsHidden as d,
2984
- ONEKO_SKINS as e,
2985
- setCatsHidden as f,
3989
+ createCatControls as d,
3990
+ areCatsHidden as e,
3991
+ ONEKO_SKINS as f,
2986
3992
  getSkinSource as g,
3993
+ setCatsHidden as h,
2987
3994
  isOnekoSkin as i,
2988
3995
  loadSkinSource as l,
2989
3996
  mountLaserCursor as m,