- Add the script below to your tile's files. Name it oneko.js
- Add the HTML snippet at the end of this Gist inside the body of your tile
- Upload these images to your tile's files:
- Inside one of your scripts, run
window.initTileOneko()- Make sure the script is executed after oneko.js
Last active
January 24, 2026 18:50
-
-
Save netux/bb51d659aaca5cd823244f4dfc7095bc to your computer and use it in GitHub Desktop.
How to add Oneko to WebTiles
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| /** | |
| * Port of https://sleepie.dev/oneko for https://webtiles.kicya.net/ ! | |
| * See https://gist.github.com/netux/bb51d659aaca5cd823244f4dfc7095bc for instructions | |
| */ | |
| function initTileOneko(options) { | |
| var nekoEl = document.querySelector('.oneko'); | |
| var nekoStyleEl = nekoEl.querySelector('style'); | |
| var NEKO_BACKGROUND_POSITION_CLASS_PREFIX = 'oneko--bg-pos_'; | |
| var neko = { | |
| posX: 32, | |
| posY: 32, | |
| speed: 10 | |
| }; | |
| var nekoZIndex = 2147483647; | |
| if (options != null) { | |
| if (options.nekoPosX != null) { | |
| neko.posX = options.nekoPosX; | |
| } | |
| if (options.nekoPosY != null) { | |
| neko.posY = options.nekoPosY; | |
| } | |
| if (options.nekoSpeed != null) { | |
| neko.speed = options.nekoSpeed; | |
| } | |
| if (options.nekoZIndex != null) { | |
| nekoZIndex = options.nekoZIndex; | |
| } | |
| } | |
| var mousePosX = neko.posX; | |
| var mousePosY = neko.posY; | |
| var frameCount = 0; | |
| var idleTime = 0; | |
| var idleAnimation = null; | |
| var idleAnimationFrame = 0; | |
| var spriteSets = { | |
| idle: [[-3, -3]], | |
| alert: [[-7, -3]], | |
| scratchSelf: [ | |
| [-5, 0], | |
| [-6, 0], | |
| [-7, 0], | |
| ], | |
| scratchWallN: [ | |
| [0, 0], | |
| [0, -1], | |
| ], | |
| scratchWallS: [ | |
| [-7, -1], | |
| [-6, -2], | |
| ], | |
| scratchWallE: [ | |
| [-2, -2], | |
| [-2, -3], | |
| ], | |
| scratchWallW: [ | |
| [-4, 0], | |
| [-4, -1], | |
| ], | |
| tired: [[-3, -2]], | |
| sleeping: [ | |
| [-2, 0], | |
| [-2, -1], | |
| ], | |
| N: [ | |
| [-1, -2], | |
| [-1, -3], | |
| ], | |
| NE: [ | |
| [0, -2], | |
| [0, -3], | |
| ], | |
| E: [ | |
| [-3, 0], | |
| [-3, -1], | |
| ], | |
| SE: [ | |
| [-5, -1], | |
| [-5, -2], | |
| ], | |
| S: [ | |
| [-6, -3], | |
| [-7, -2], | |
| ], | |
| SW: [ | |
| [-5, -3], | |
| [-6, -1], | |
| ], | |
| W: [ | |
| [-4, -2], | |
| [-4, -3], | |
| ], | |
| NW: [ | |
| [-1, 0], | |
| [-1, -1], | |
| ], | |
| }; | |
| function arrayFindIndex(arr, cb) { | |
| for (var i = 0; i < arr.length; i++) { | |
| if (cb(arr[i], i, arr)) { | |
| return i; | |
| } | |
| } | |
| return -1; | |
| } | |
| function requestAnimationFrame(cb) { | |
| setTimeout(function () { | |
| cb(Date.now()); | |
| }, 1000 / 120); | |
| } | |
| function init() { | |
| nekoEl.ariaHidden = true; | |
| nekoEl.style.width = '32px'; | |
| nekoEl.style.height = '32px'; | |
| nekoEl.style.position = 'absolute'; | |
| nekoEl.style.imageRendering = 'pixelated'; | |
| nekoEl.style.left = (neko.posX - 16).toString() + 'px'; | |
| nekoEl.style.top = (neko.posY - 16).toString() + 'px'; | |
| nekoEl.style.zIndex = nekoZIndex; | |
| nekoEl.style.display = 'initial'; | |
| for (var x = -8; x < 8; x++) { | |
| for (var y = -4; y < 4; y++) { | |
| var selector = '.' + NEKO_BACKGROUND_POSITION_CLASS_PREFIX + x.toString() + '_' + y.toString(); | |
| nekoStyleEl.textContent += '\n' + selector + ' { background-position: ' + (x * 32).toString() + 'px ' + (y * 32).toString() + 'px; }'; | |
| } | |
| } | |
| nekoEl.appendChild(nekoStyleEl); | |
| nekoEl.parentElement.addEventListener('mousemove', function(event) { | |
| mousePosX = event.offsetX; | |
| mousePosY = event.offsetY; | |
| }); | |
| setSprite('idle', 0); | |
| requestAnimationFrame(onAnimationFrame); | |
| } | |
| var lastFrameTimestamp; | |
| function onAnimationFrame(timestamp) { | |
| if (!lastFrameTimestamp) { | |
| lastFrameTimestamp = timestamp; | |
| } | |
| if (timestamp - lastFrameTimestamp > 100) { | |
| lastFrameTimestamp = timestamp | |
| frame() | |
| } | |
| if (options != null && options.onAnimationFrame != null) { | |
| options.onAnimationFrame.apply(neko, arguments); | |
| } | |
| requestAnimationFrame(onAnimationFrame); | |
| } | |
| function setSprite(name, frame) { | |
| var sprite = spriteSets[name][frame % spriteSets[name].length]; | |
| var nekoClasses = nekoEl.className.split(' '); | |
| var backgroundPosClassIdx = arrayFindIndex(nekoClasses, function(className) { | |
| return className.indexOf(NEKO_BACKGROUND_POSITION_CLASS_PREFIX) === 0; | |
| }); | |
| if (backgroundPosClassIdx >= 0) { | |
| nekoClasses.splice(backgroundPosClassIdx, 1); | |
| } | |
| nekoClasses.push(NEKO_BACKGROUND_POSITION_CLASS_PREFIX + sprite[0].toString() + '_' + sprite[1].toString()); | |
| nekoEl.className = nekoClasses.join(' '); | |
| } | |
| function resetIdleAnimation() { | |
| idleAnimation = null; | |
| idleAnimationFrame = 0; | |
| } | |
| function idle() { | |
| idleTime += 1; | |
| // every ~ 20 seconds | |
| if ( | |
| idleTime > 10 && | |
| Math.floor(Math.random() * 200) == 0 && | |
| idleAnimation == null | |
| ) { | |
| var avalibleIdleAnimations = ['sleeping', 'scratchSelf']; | |
| if (neko.posX < 32) { | |
| avalibleIdleAnimations.push('scratchWallW'); | |
| } | |
| if (neko.posY < 32) { | |
| avalibleIdleAnimations.push('scratchWallN'); | |
| } | |
| if (neko.posX > nekoEl.parentElement.clientWidth - 32) { | |
| avalibleIdleAnimations.push('scratchWallE'); | |
| } | |
| if (neko.posY > nekoEl.parentElement.clientHeight - 32) { | |
| avalibleIdleAnimations.push('scratchWallS'); | |
| } | |
| idleAnimation = | |
| avalibleIdleAnimations[ | |
| Math.floor(Math.random() * avalibleIdleAnimations.length) | |
| ]; | |
| } | |
| switch (idleAnimation) { | |
| case 'sleeping': | |
| if (idleAnimationFrame < 8) { | |
| setSprite('tired', 0); | |
| break; | |
| } | |
| setSprite('sleeping', Math.floor(idleAnimationFrame / 4)); | |
| if (idleAnimationFrame > 192) { | |
| resetIdleAnimation(); | |
| } | |
| break; | |
| case 'scratchWallN': | |
| case 'scratchWallS': | |
| case 'scratchWallE': | |
| case 'scratchWallW': | |
| case 'scratchSelf': | |
| setSprite(idleAnimation, idleAnimationFrame); | |
| if (idleAnimationFrame > 9) { | |
| resetIdleAnimation(); | |
| } | |
| break; | |
| default: | |
| setSprite('idle', 0); | |
| return; | |
| } | |
| idleAnimationFrame += 1; | |
| } | |
| function frame() { | |
| frameCount += 1; | |
| var diffX = neko.posX - mousePosX; | |
| var diffY = neko.posY - mousePosY; | |
| var distance = Math.sqrt(Math.pow(diffX, 2) + Math.pow(diffY, 2)); | |
| if (distance < neko.speed || distance < 48) { | |
| idle(); | |
| return; | |
| } | |
| idleAnimation = null; | |
| idleAnimationFrame = 0; | |
| if (idleTime > 1) { | |
| setSprite('alert', 0); | |
| idleTime = Math.min(idleTime, 7); | |
| idleTime -= 1; | |
| return; | |
| } | |
| var direction; | |
| direction = diffY / distance > 0.5 ? 'N' : ''; | |
| direction += diffY / distance < -0.5 ? 'S' : ''; | |
| direction += diffX / distance > 0.5 ? 'W' : ''; | |
| direction += diffX / distance < -0.5 ? 'E' : ''; | |
| setSprite(direction, frameCount); | |
| neko.posX -= (diffX / distance) * neko.speed; | |
| neko.posY -= (diffY / distance) * neko.speed; | |
| neko.posX = Math.min(Math.max(16, neko.posX), nekoEl.parentElement.clientWidth - 16); | |
| neko.posY = Math.min(Math.max(16, neko.posY), nekoEl.parentElement.clientHeight - 16); | |
| nekoEl.style.left = (neko.posX - 16).toString() + 'px'; | |
| nekoEl.style.top = (neko.posY - 16).toString() + 'px'; | |
| } | |
| init(); | |
| return neko; | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <div id="oneko-container"> | |
| <a class="oneko" href="https://sleepie.dev/oneko" target="_blank"> | |
| <style> | |
| .oneko { | |
| background-image: url('./oneko.gif'); | |
| cursor: default; | |
| display: none; | |
| } | |
| </style> | |
| <!-- #region Make oneko sleep when the tile is inactive --> | |
| <div class="oneko__inactive oneko__inactive--frame-1"></div> | |
| <div class="oneko__inactive oneko__inactive--frame-2"></div> | |
| <div class="oneko__inactive oneko__inactive--sleeping-loop"></div> | |
| <style> | |
| .oneko { | |
| overflow-y: hidden; | |
| & .oneko__inactive { | |
| position: absolute; | |
| top: 0; | |
| left: -100%; | |
| width: 100%; | |
| height: 100%; | |
| opacity: 1; | |
| } | |
| } | |
| body:not(.active) .oneko { | |
| background-image: none !important; | |
| opacity: 0.5; | |
| & .oneko__inactive { | |
| background-image: url('./oneko.gif'); | |
| left: 0; | |
| /* WebTiles pauses all scripts and animations in inactive tiles, so we have to improvise. */ | |
| &.oneko__inactive--frame-1 { | |
| background-position: calc(32px * -3) calc(32px * -3); /* idle */ | |
| transition: opacity 0s linear 2s; | |
| opacity: 0; | |
| } | |
| &.oneko__inactive--frame-2 { | |
| background-position: calc(32px * -3) calc(32px * -2); /* tired */ | |
| transition: left 0s linear 2s, opacity 0s linear 3s; | |
| opacity: 0; | |
| } | |
| &.oneko__inactive--sleeping-loop { | |
| background-image: url('./oneko-sleeping.gif'); | |
| transition: left 0s linear 3s; | |
| } | |
| } | |
| } | |
| </style> | |
| <!-- #endregion Make oneko sleep when the tile is inactive --> | |
| <script src="/oneko.js"></script> | |
| </a> | |
| </div> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment