YUA様のゲーム内イベントをXToysデバイスとリアルタイムで連携します。
// ==UserScript==
// @name YUA × XToys Bridge
// @name:zh-CN YUA × XToys Bridge
// @name:ja YUA × XToys Bridge
// @name:en YUA × XToys Bridge
// @namespace yua-xtoys
// @version 1.0.0
// @description Connect YUA's games with XToys.
// @description:zh-CN 将 YUA 的游戏事件实时联动至 XToys 设备。
// @description:ja YUA様のゲーム内イベントをXToysデバイスとリアルタイムで連携します。
// @description:en Connect YUA's in-game events to XToys devices in real time.
// @author Aoba
// @match https://yotogi-chan.itch.io/*
// @match https://html-classic.itch.zone/*
// @run-at document-idle
// @grant none
// ==/UserScript==
(() => {
'use strict';
const VERSION = '1.0.0';
const OUTER_ORIGIN = 'https://yotogi-chan.itch.io';
const GAME_ORIGIN = 'https://html-classic.itch.zone';
const STORAGE_KEY = 'yua.manager.v1';
const LANG_KEY = 'yua.xtoys.lang';
// Prevent duplicate initialization
if (window.__YUA_XTOYS_V1__) return;
window.__YUA_XTOYS_V1__ = true;
const log = (...args) => console.log(
`%c[YUA × XToys ${VERSION}]`,
'background:#8e24aa;color:white;font-weight:bold;padding:2px 5px',
...args
);
/* =========================================================
i18n
========================================================= */
const I18N = {
'zh-CN': {
bridgeReady: 'Bridge 已就绪',
waitingGame: '等待游戏',
gameReady: '游戏检测已就绪',
lastEvent: '最后事件',
none: '—',
pulse: 'Rapid Tap',
punishment: '处罚',
sec: '秒',
temptation: '拒绝 YUA 的诱惑',
false_cheat: '错误揭发作弊',
tongue_self: '舌尖裁定:选择自己',
othello_loss: '黑白棋失败',
mahjong_loss: '麻将失败',
othello_win: '黑白棋胜利',
mahjong_win: '麻将胜利',
temptation_follow: '服从 YUA 的诱惑',
tongue_follow: '舌尖裁定:交给 YUA'
},
ja: {
bridgeReady: 'Bridge 準備完了',
waitingGame: 'ゲームを待機中',
gameReady: 'ゲーム検出 準備完了',
lastEvent: '最後のイベント',
none: '—',
pulse: 'Rapid Tap',
punishment: '罰',
sec: '秒',
temptation: 'YUA様の誘惑を拒否',
false_cheat: '誤って作弊を指摘',
tongue_self: '舌尖裁定:自分で打つ',
othello_loss: 'オセロ敗北',
mahjong_loss: '麻雀敗北',
othello_win: 'オセロ勝利',
mahjong_win: '麻雀勝利',
temptation_follow: 'YUA様の誘惑に従う',
tongue_follow: '舌尖裁定:YUA様に任せる'
},
en: {
bridgeReady: 'Bridge Ready',
waitingGame: 'Waiting for Game',
gameReady: 'Game Detector Ready',
lastEvent: 'Last Event',
none: '—',
pulse: 'Rapid Tap',
punishment: 'Punishment',
sec: 'sec',
temptation: "YUA's temptation refused",
false_cheat: 'False cheat accusation',
tongue_self: 'Tongue Judgement: self choice',
othello_loss: 'Othello loss',
mahjong_loss: 'Mahjong loss',
othello_win: 'Othello win',
mahjong_win: 'Mahjong win',
temptation_follow: "Followed YUA's temptation",
tongue_follow: 'Tongue Judgement: followed YUA'
}
};
function detectLanguage() {
const saved = localStorage.getItem(LANG_KEY);
if (I18N[saved]) {
return saved;
}
const browser =
(navigator.language || '').toLowerCase();
if (browser.startsWith('ja')) {
return 'ja';
}
if (browser.startsWith('zh')) {
return 'zh-CN';
}
return 'en';
}
/* =========================================================
OUTER PAGE
itch.io → XToys Webpage Monitor
========================================================= */
if (location.origin === OUTER_ORIGIN) {
let lang = detectLanguage();
let lastState = {
type: 'none'
};
let gameReady = false;
/* -----------------------------------------------------
Hidden XToys bridge
----------------------------------------------------- */
let bridge =
document.querySelector('#yua-xtoys-bridge');
if (!bridge) {
bridge = document.createElement('div');
bridge.id = 'yua-xtoys-bridge';
bridge.style.display = 'none';
bridge.textContent = 'ready';
document.body.appendChild(bridge);
}
/* -----------------------------------------------------
Status UI
----------------------------------------------------- */
let status =
document.querySelector('#yua-xtoys-status');
if (!status) {
status = document.createElement('div');
status.id = 'yua-xtoys-status';
Object.assign(
status.style,
{
position: 'fixed',
right: '12px',
bottom: '12px',
zIndex: '2147483647',
minWidth: '230px',
padding: '10px 12px',
background:
'rgba(20,12,24,.90)',
color: '#eee',
border:
'1px solid rgba(210,120,255,.45)',
borderRadius: '9px',
font:
'12px/1.45 sans-serif',
boxShadow:
'0 2px 12px rgba(0,0,0,.35)',
backdropFilter:
'blur(5px)'
}
);
document.body.appendChild(status);
}
function eventLabel(reason) {
return (
I18N[lang][reason]
||
reason
||
I18N[lang].none
);
}
function render() {
const t = I18N[lang];
let eventText = t.none;
let detail = '';
if (lastState.type === 'pulse') {
eventText =
t.pulse;
} else if (
lastState.type === 'punish'
) {
eventText =
eventLabel(
lastState.reason
);
detail =
`${t.punishment}: ` +
`${lastState.seconds} ${t.sec}`;
} else if (
lastState.type === 'status'
) {
eventText =
eventLabel(
lastState.key
);
}
status.innerHTML = `
<div style="
display:flex;
align-items:center;
justify-content:space-between;
gap:12px;
">
<strong style="
color:#e69cff;
">
YUA × XToys
</strong>
<select
id="yua-xtoys-lang"
style="
font:11px sans-serif;
background:#211526;
color:#eee;
border:1px solid #704080;
border-radius:5px;
padding:2px 4px;
"
>
<option
value="zh-CN"
${lang === 'zh-CN'
? 'selected'
: ''}
>
中文
</option>
<option
value="ja"
${lang === 'ja'
? 'selected'
: ''}
>
日本語
</option>
<option
value="en"
${lang === 'en'
? 'selected'
: ''}
>
English
</option>
</select>
</div>
<div style="
border-top:
1px solid rgba(255,255,255,.12);
margin:6px 0;
"></div>
<div>
● ${t.bridgeReady}
</div>
<div>
${
gameReady
? '● ' + t.gameReady
: '○ ' + t.waitingGame
}
</div>
<div style="
margin-top:7px;
opacity:.7;
">
${t.lastEvent}
</div>
<div>
${eventText}
</div>
${
detail
? `
<div style="
color:#e69cff;
">
${detail}
</div>
`
: ''
}
<div style="
text-align:right;
opacity:.45;
margin-top:4px;
">
v${VERSION}
</div>
`;
const selector =
status.querySelector(
'#yua-xtoys-lang'
);
selector.addEventListener(
'change',
e => {
lang =
e.target.value;
localStorage.setItem(
LANG_KEY,
lang
);
render();
}
);
}
render();
/* -----------------------------------------------------
Game → outer page receiver
----------------------------------------------------- */
window.addEventListener(
'message',
e => {
if (
e.origin !== GAME_ORIGIN
) {
return;
}
const d =
e.data;
if (
!d
||
d.source !== 'YUA_XTOYS'
) {
return;
}
/* ---------- READY ---------- */
if (
d.type === 'ready'
) {
gameReady = true;
render();
log(
'Game Detector connected',
d.version
);
return;
}
/* ---------- STATUS ---------- */
if (
d.type === 'status'
) {
lastState = {
type: 'status',
key: d.key
};
render();
return;
}
/* ---------- PULSE ---------- */
if (
d.type === 'pulse'
) {
bridge.textContent =
`pulse:${d.seq}`;
gameReady = true;
lastState = {
type: 'pulse',
reason: d.reason
};
render();
log(
'PULSE ← GAME',
d.reason || '',
d.seq
);
return;
}
/* ---------- PUNISH ---------- */
if (
d.type === 'punish'
) {
const seconds =
Number(
d.seconds
);
if (
!Number.isFinite(seconds)
||
seconds <= 0
) {
return;
}
/*
* Keep protocol reasons language-independent.
*/
const reason =
String(
d.reason || 'unknown'
)
.replace(
/[^a-z0-9_]/gi,
'_'
)
.toLowerCase();
bridge.textContent =
`punish:${seconds}:${reason}:${d.seq}`;
gameReady = true;
lastState = {
type: 'punish',
seconds,
reason
};
render();
log(
'PUNISH ← GAME',
`${seconds}s`,
reason,
d.seq
);
}
}
);
log(
'Outer Bridge READY'
);
return;
}
/* =========================================================
GAME IFRAME
========================================================= */
if (
location.origin !== GAME_ORIGIN
) {
return;
}
/* =========================================================
Sequence
========================================================= */
let seq =
Date.now();
function nextSeq() {
const now =
Date.now();
seq =
Math.max(
seq + 1,
now
);
return seq;
}
/* =========================================================
Messaging
========================================================= */
function sendMessage(data) {
parent.postMessage(
{
source: 'YUA_XTOYS',
...data
},
OUTER_ORIGIN
);
}
function sendStatus(key) {
sendMessage({
type: 'status',
key
});
}
/* =========================================================
XToys output
========================================================= */
function pulse(
reason = 'pulse'
) {
const id =
nextSeq();
sendMessage({
type: 'pulse',
reason,
seq: id
});
log(
'PULSE → XToys',
reason,
id
);
}
function punish(
seconds,
reason = 'unknown'
) {
seconds =
Number(seconds);
if (
!Number.isFinite(seconds)
||
seconds <= 0
) {
return;
}
const id =
nextSeq();
sendMessage({
type: 'punish',
seconds,
reason,
seq: id
});
log(
'PUNISH → XToys',
`${seconds}s`,
reason,
id
);
}
/*
* Debug interface.
*/
window.YUA_XTOYS = {
pulse,
punish,
version: VERSION
};
/* =========================================================
Game state
========================================================= */
function getState() {
try {
return JSON.parse(
localStorage.getItem(
STORAGE_KEY
)
||
'{}'
);
} catch {
return {};
}
}
const initialState =
getState();
/*
* Remember the event that already existed
* when the script started.
*
* This prevents a refresh from replaying
* the previous punishment.
*/
let lastEventId =
initialState.events
?.at(-1)
?.id
||
null;
/* =========================================================
OTHELLO / MAHJONG
========================================================= */
function handleGameEvent(event) {
if (!event) {
return;
}
log(
'GAME EVENT',
event.type,
event
);
/* -----------------------------------------------------
OTHELLO WIN
----------------------------------------------------- */
if (
event.type === 'othello.win'
) {
log(
'Othello WIN → no punishment'
);
sendStatus(
'othello_win'
);
return;
}
/* -----------------------------------------------------
OTHELLO LOSE
----------------------------------------------------- */
if (
event.type === 'othello.lose'
) {
const lost =
Number(
event.detail
?.losingSquares
||
0
);
if (
lost > 0
) {
log(
`Othello LOSE → ${lost}s`,
event.detail
);
punish(
lost,
'othello_loss'
);
}
return;
}
/* -----------------------------------------------------
MAHJONG WIN
----------------------------------------------------- */
if (
event.type === 'mahjong.win'
) {
log(
'Mahjong WIN → no punishment'
);
sendStatus(
'mahjong_win'
);
return;
}
/* -----------------------------------------------------
MAHJONG LOSE
----------------------------------------------------- */
if (
event.type === 'mahjong.lose'
) {
const points =
Number(
event.detail
?.points
||
0
);
/*
* 1000 points = 1 second
* Minimum 1 second
* Maximum 9.6 seconds
*/
const seconds =
Math.min(
9.6,
Math.max(
1,
points / 1000
)
);
log(
`Mahjong LOSE ${points}pt → ${seconds}s`,
event.detail
);
punish(
seconds,
'mahjong_loss'
);
return;
}
}
/* ---------------------------------------------------------
Match event watcher
--------------------------------------------------------- */
setInterval(
() => {
const state =
getState();
const latest =
state.events
?.at(-1);
if (!latest) {
return;
}
const id =
latest.id
||
JSON.stringify(
latest
);
if (
id === lastEventId
) {
return;
}
lastEventId =
id;
handleGameEvent(
latest
);
},
200
);
/* =========================================================
RAPID TAP
========================================================= */
document.addEventListener(
'pointerdown',
e => {
const button =
e.target.closest?.(
'#tapTemptationBtn'
);
if (!button) {
return;
}
pulse(
'rapid_tap'
);
},
true
);
/* =========================================================
TONGUE JUDGEMENT
========================================================= */
document.addEventListener(
'pointerdown',
e => {
/*
* Follow YUA
*/
const follow =
e.target.closest?.(
'#tongueJudgementFollowBtn'
);
if (follow) {
log(
'Tongue → Follow YUA → no punishment'
);
sendStatus(
'tongue_follow'
);
return;
}
/*
* Choose self
*/
const refuse =
e.target.closest?.(
'#tongueJudgementRefuseBtn'
);
if (refuse) {
log(
'Tongue → Self choice → 1s'
);
punish(
1,
'tongue_self'
);
return;
}
},
true
);
/* =========================================================
NORMAL TEMPTATION
Verified final method:
capture-phase pointerdown
Legal cell:
.cell.legal
YUA target:
.cell.legal.tempted
========================================================= */
document.addEventListener(
'pointerdown',
e => {
/*
* These events are handled separately.
*/
if (
e.target.closest?.(
'#tongueJudgementFollowBtn'
)
||
e.target.closest?.(
'#tongueJudgementRefuseBtn'
)
||
e.target.closest?.(
'#tapTemptationBtn'
)
) {
return;
}
/*
* Find the clicked board cell from
* the actual event path.
*/
const path =
e.composedPath?.()
||
[];
const clicked =
path.find(
el =>
el
?.classList
?.contains?.(
'cell'
)
);
if (!clicked) {
return;
}
/*
* Only legal moves matter.
*/
if (
!clicked
.classList
.contains(
'legal'
)
) {
return;
}
/*
* Read YUA's target at the exact
* pointerdown moment.
*/
const tempted =
document.querySelector(
'.cell.legal.tempted'
);
/*
* No temptation event active.
*/
if (!tempted) {
return;
}
const clickedIndex =
clicked.dataset.index;
const temptedIndex =
tempted.dataset.index;
if (
clickedIndex == null
||
temptedIndex == null
) {
return;
}
log(
'Temptation move',
{
clickedIndex,
temptedIndex
}
);
/* -------------------------------------------------
Follow YUA
------------------------------------------------- */
if (
clickedIndex ===
temptedIndex
) {
log(
'Temptation → Follow YUA → no punishment'
);
sendStatus(
'temptation_follow'
);
return;
}
/* -------------------------------------------------
Refuse YUA
------------------------------------------------- */
log(
'Temptation → Refused YUA → 1s'
);
punish(
1,
'temptation'
);
},
true
);
/* =========================================================
CHEAT CHALLENGE
========================================================= */
let previousCheatFalse =
false;
function detectCheatResult(
text
) {
const falseCheat =
text.includes(
'误判惩罚:'
)
||
text.includes(
'猜错♡'
);
/*
* Trigger only on false → true.
*/
if (
falseCheat
&&
!previousCheatFalse
) {
log(
'False Cheat accusation → 1s'
);
punish(
1,
'false_cheat'
);
}
previousCheatFalse =
falseCheat;
}
let cheatScanScheduled =
false;
function scanCheatDOM() {
cheatScanScheduled =
false;
const text =
document.body
?.innerText
||
'';
detectCheatResult(
text
);
}
const cheatObserver =
new MutationObserver(
() => {
if (
cheatScanScheduled
) {
return;
}
cheatScanScheduled =
true;
requestAnimationFrame(
scanCheatDOM
);
}
);
cheatObserver.observe(
document.body,
{
subtree: true,
childList: true,
characterData: true
}
);
/* =========================================================
READY
========================================================= */
log(
'Game Detector READY'
);
log(
'Enabled:',
'Othello / Mahjong / Temptation / Cheat / Rapid Tap / Tongue'
);
sendMessage({
type: 'ready',
version: VERSION
});
})();