File size: 16,594 Bytes
64ace35 d00bf3f ef177ba d00bf3f 7cbbe8b d00bf3f 5791f39 d00bf3f ef177ba d00bf3f 7cbbe8b b1ed896 d00bf3f 5791f39 d00bf3f 3085e1b 5791f39 d00bf3f 7cbbe8b ef177ba 8c5be36 d00bf3f b1ed896 d00bf3f 64ace35 d00bf3f 7cbbe8b b1ed896 d00bf3f b1ed896 d00bf3f 3085e1b d00bf3f b1ed896 d00bf3f 5791f39 22b0596 5791f39 22b0596 5791f39 22b0596 5791f39 d00bf3f 7cbbe8b d00bf3f b1ed896 d00bf3f 7cbbe8b b1ed896 d00bf3f ef177ba 23715ae | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 | <!-- 小貓中文 DOS interface; Copyright (C) 2026 HelloSun. Author: HelloSun. First version: 1.0, 2026-10-25.
SPDX-License-Identifier: GPL-3.0-only, with GPLv3 section 7(b) additional term: keep the author attribution. See LICENSE. -->
<!doctype html>
<html lang="zh-Hant">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1,user-scalable=no">
<meta name="theme-color" content="#05070a">
<title>小貓中文 DOS · FreeDOS</title>
<style>
:root { color-scheme: dark; font-family: system-ui, sans-serif; background: #05070a; color: #e7edf5; }
* { box-sizing: border-box; }
body { margin: 0; min-height: 100vh; display: flex; flex-direction: column; }
header, footer { padding: .7rem 1rem; background: #0c1118; border-color: #202a36; }
header { border-bottom: 1px solid #202a36; display: flex; justify-content: space-between; gap: 1rem; align-items: center; }
header strong { color: #80d8ff; }
#memory { color: #a7b8ca; font-size: .85rem; }
main { width: min(100%, 1060px); margin: auto; padding: 1rem; }
#screen-wrap { width: 100%; background: #000; border: 1px solid #263342; border-radius: 8px; overflow: hidden; }
#dosbox { display: block; width: 100%; height: auto; aspect-ratio: 4 / 3; background: #000; image-rendering: pixelated; touch-action: none; }
#message { min-height: 1.4em; padding: .35rem .15rem; color: #93a4b6; font: .8rem ui-monospace, monospace; }
.toolbar { display: flex; flex-wrap: wrap; align-items: center; gap: .5rem; margin: .4rem 0 .75rem; }
button { border: 1px solid #344457; border-radius: 6px; background: #141e2a; color: #e7edf5; min-height: 40px; padding: .45rem .75rem; font: inherit; cursor: pointer; touch-action: manipulation; }
button:active, button.active { background: #164467; border-color: #52b9ec; }
.spacer { flex: 1; }
#caption { min-height: 2.6em; margin: .5rem 0 0; padding: .55rem .8rem; border-left: 3px solid #52b9ec; background: #0e1722; color: #d7e6f5; font-size: 1.02rem; line-height: 1.5; border-radius: 0 6px 6px 0; }
#caption:empty { visibility: hidden; }
#demo.running { background: #5a1d1d; border-color: #e06c6c; }
.keypad { display: grid; grid-template-columns: repeat(10, minmax(0, 1fr)); gap: .35rem; }
.keypad button { min-width: 0; padding: .45rem .15rem; font-size: .9rem; }
.keypad .wide { grid-column: span 2; }
.hint { color: #9aabba; font-size: .82rem; line-height: 1.5; margin: .8rem 0; }
footer { border-top: 1px solid #202a36; font-size: .78rem; color: #9aabba; text-align: center; }
@media (max-width: 620px) {
main { padding: .55rem; }
header { padding: .6rem .7rem; align-items: flex-start; flex-direction: column; gap: .2rem; }
.keypad { grid-template-columns: repeat(6, minmax(0, 1fr)); gap: .3rem; }
.keypad button { min-height: 42px; }
}
</style>
</head>
<body>
<header>
<strong>小貓中文 FreeDOS</strong>
<span id="memory">載入時於 DOS 畫面顯示即時 MEM</span>
</header>
<main>
<div id="screen-wrap"><canvas id="dosbox" width="640" height="480" aria-label="DOS emulator screen"></canvas></div>
<div id="caption" role="status" aria-live="polite"></div>
<div class="toolbar">
<button id="ime-toggle" type="button">Ctrl+Space 注音切換</button>
<button id="big5-toggle" type="button">Big5 顯示開關</button>
<button id="demo" type="button">▶ 示範 Demo</button>
<button id="fullscreen" type="button">全螢幕</button>
<span class="spacer"></span>
<span id="message" role="status">正在載入 FreeDOS…</span>
</div>
<p class="hint">點選 DOS 畫面後可使用實體鍵盤。手機可用下方按鍵輸入;注音使用相容鍵位。F1 顯示系統資訊,Alt+X 離開。</p>
<section class="keypad" aria-label="虛擬鍵盤">
<button type="button" data-key="49">1 ㄅ</button><button type="button" data-key="81">Q ㄆ</button><button type="button" data-key="65">A ㄇ</button><button type="button" data-key="90">Z ㄈ</button><button type="button" data-key="50">2 ㄉ</button><button type="button" data-key="87">W ㄊ</button>
<button type="button" data-key="83">S ㄋ</button><button type="button" data-key="88">X ㄌ</button><button type="button" data-key="69">E ㄍ</button><button type="button" data-key="68">D ㄎ</button><button type="button" data-key="67">C ㄏ</button><button type="button" data-key="82">R ㄐ</button>
<button type="button" data-key="70">F ㄑ</button><button type="button" data-key="86">V ㄒ</button><button type="button" data-key="53">5 ㄓ</button><button type="button" data-key="84">T ㄔ</button><button type="button" data-key="71">G ㄕ</button><button type="button" data-key="66">B ㄖ</button>
<button type="button" data-key="89">Y ㄗ</button><button type="button" data-key="72">H ㄘ</button><button type="button" data-key="78">N ㄙ</button><button type="button" data-key="85">U ㄧ</button><button type="button" data-key="74">J ㄨ</button><button type="button" data-key="77">M ㄩ</button>
<button type="button" data-key="56">8 ㄚ</button><button type="button" data-key="73">I ㄛ</button><button type="button" data-key="75">K ㄜ</button><button type="button" data-key="188">, ㄝ</button><button type="button" data-key="57">9 ㄞ</button><button type="button" data-key="79">O ㄟ</button>
<button type="button" data-key="76">L ㄠ</button><button type="button" data-key="190">. ㄡ</button><button type="button" data-key="48">0 ㄢ</button><button type="button" data-key="80">P ㄣ</button><button type="button" data-key="186">; ㄤ</button><button type="button" data-key="191">/ ㄥ</button>
<button type="button" data-key="189">- ㄦ</button><button type="button" data-key="54">6 ˊ</button><button type="button" data-key="51">3 ˇ</button><button type="button" data-key="52">4 ˋ</button><button type="button" data-key="55">7 ˙</button><button type="button" data-key="8">⌫</button>
<button type="button" data-key="27">Esc</button><button type="button" data-key="13">Enter</button><button type="button" data-key="32" class="wide">Space</button><button type="button" data-key="37">←</button><button type="button" data-key="38">↑</button><button type="button" data-key="39">→</button><button type="button" data-key="40">↓</button>
<button type="button" data-key="220">\</button><button type="button" data-key="186" data-shift="1">:</button><button type="button" data-key="9">Tab</button><button id="shift" type="button">Shift</button>
<button type="button" data-key="112">F1</button><button id="alt-x" type="button" class="wide">Alt+X</button>
</section>
</main>
<footer>小貓中文 v1.0 · 作者 HelloSun · 2026-10-25 · GPL-3.0(§7(b):須保留作者聲明)· OpenWatcom · GNU Unifont · Big5 注音 · 載入時顯示即時 MEM · <a href="LICENSE">License</a></footer>
<script src="js-dos.js"></script>
<script src="emu.b64.js"></script>
<script src="dos.b64.js"></script>
<script>
(() => {
const status = document.getElementById('message');
const memory = document.getElementById('memory');
const host = document.getElementById('dosbox');
let controller = null;
const canvas = host;
const send = (code, down) => {
if (!controller) return;
controller.simulateKeyEvent(code, down);
};
const tap = (code) => {
send(code, true);
window.setTimeout(() => send(code, false), 90);
};
let shiftOn = false;
const setShift = (on) => { shiftOn = on; document.getElementById('shift').classList.toggle('active', on); };
document.getElementById('shift').addEventListener('click', () => setShift(!shiftOn));
const toggleIme = () => {
send(17, true);
tap(32);
window.setTimeout(() => send(17, false), 130);
};
window.etImeToggle = toggleIme;
document.getElementById('ime-toggle').addEventListener('click', toggleIme);
document.getElementById('big5-toggle').addEventListener('click', () => {
send(17, true); tap(220); window.setTimeout(() => send(17, false), 130);
});
document.getElementById('fullscreen').addEventListener('click', () => controller && controller.fullscreen());
document.getElementById('alt-x').addEventListener('click', () => {
send(18, true); tap(88); window.setTimeout(() => send(18, false), 130);
});
document.querySelectorAll('.keypad [data-key]').forEach((button) => {
button.addEventListener('pointerdown', (event) => {
event.preventDefault();
const code = Number(button.dataset.key);
if (button.dataset.shift || shiftOn) {
send(16, true);
window.setTimeout(() => { tap(code); window.setTimeout(() => send(16, false), 130); }, 40);
setShift(false);
} else tap(code);
button.classList.add('active');
});
button.addEventListener('pointerup', () => button.classList.remove('active'));
button.addEventListener('pointercancel', () => button.classList.remove('active'));
button.addEventListener('pointerleave', () => button.classList.remove('active'));
});
// ---- guided demo: paced for human reading (captions + generous pauses) ----
const sleep = (ms) => new Promise((r) => window.setTimeout(r, ms));
const KEYS = { ' ': 32, '\n': 13, '.': 190, ',': 188, ';': 186, '/': 191, '-': 189 };
const keyCode = (ch) => KEYS[ch] || (/[a-z]/.test(ch) ? ch.toUpperCase().charCodeAt(0) : ch.charCodeAt(0));
const caption = document.getElementById('caption');
const demoBtn = document.getElementById('demo');
let demoRun = 0;
const SPEED = 4; // demo speed-up factor (DOS needs >= ~90 ms between keys)
const runDemo = async () => {
if (demoRun) { demoRun = 0; return; }
if (!controller) { status.textContent = '請等 DOS 啟動完成'; return; }
const me = demoRun = Date.now();
const alive = () => demoRun === me;
const say = async (text, ms) => { caption.textContent = text; await sleep(ms / SPEED); };
const press = async (code, hold = 50) => { send(code, true); await sleep(hold); send(code, false); await sleep(30); };
const keys = async (str, gap = 650) => { for (const ch of str) { if (!alive()) return; await press(keyCode(ch)); await sleep(Math.max(gap / SPEED, 50)); } };
const imeToggle = async () => { send(17, true); await sleep(60); await press(32); send(17, false); await sleep(200); };
const step = async (text, readMs, action) => { if (!alive()) return; await say(text, 400); if (action) await action(); if (alive()) await sleep(readMs / SPEED); };
demoBtn.classList.add('running'); demoBtn.textContent = '■ 停止示範';
canvas.focus();
try {
await step('1. 小貓中文是 DOS 常駐程式:用 TYPE 顯示含中文的檔案', 5500, () => keys('type demo.txt\n', 140));
await step('2. 按 Ctrl+Space 開啟注音輸入(畫面最底列會顯示「注音」)', 3500, imeToggle);
await step('3. 輸入「你」:ㄋㄧˇ = S U 3,底列出現候選字', 4500, () => keys('su3'));
await step('4. 按數字 1 選第一個候選字', 3000, () => keys('1'));
await step('5. 輸入「好」:ㄏㄠˇ = C L 3', 3500, () => keys('cl3'));
await step('按 1 選字', 2500, () => keys('1'));
await step('6. 再輸入「電腦」:ㄉㄧㄢˋ = 2 U 0 4,ㄋㄠˇ = S L 3', 3500, () => keys('2u04'));
await step('按 1 選字', 2500, () => keys('1'));
await step('接著「腦」:ㄋㄠˇ = S L 3', 2500, () => keys('sl3'));
await step('按 1 選字', 3000, () => keys('1'));
await step('7. 一個音超過 9 個候選字時會分頁,按空白鍵環形切換(右側 1/7 是頁碼)', 4500, () => keys('u4', 800));
await step('按空白鍵:下一頁', 3200, () => keys(' '));
await step('再按一次:再下一頁', 3200, () => keys(' '));
await step('按 Esc 取消這次輸入', 2800, () => press(27));
await step('8. 再按 Ctrl+Space 回到英文輸入', 3000, imeToggle);
await step('9. 執行 XEDIT:仿 PE2 的全螢幕編輯器,直接寫螢幕記憶體,一樣能顯示中文', 6500, async () => { await keys('\n', 100); await keys('xedit demo.txt\n', 140); });
await step('10. 在編輯器裡也能用注音輸入:Ctrl+Space,「我」= J I 3', 4500, async () => { await imeToggle(); await keys('ji3'); });
await step('按 1 選字', 4000, () => keys('1'));
await step('按 F3 離開編輯器,回到 DOS', 3500, async () => { await imeToggle(); await press(114); });
await say('示範結束。傳統記憶體只常駐約 16 KB,字型與詞庫放在 XMS。', 6000);
} finally {
if (alive() || demoRun === 0) { caption.textContent = ''; }
demoBtn.classList.remove('running'); demoBtn.textContent = '▶ 示範 Demo';
demoRun = 0;
}
};
demoBtn.addEventListener('click', runDemo);
canvas.tabIndex = 0;
canvas.addEventListener('pointerdown', () => canvas.focus());
document.addEventListener('keydown', (event) => {
if (!controller) return;
if (event.code === 'Space' && event.ctrlKey) {
if (canvas.contains(event.target)) return;
event.preventDefault();
toggleIme();
return;
}
if (canvas.contains(event.target)) {
if (event.isTrusted && event.key === 'x' && event.altKey) event.preventDefault();
return;
}
if (!event.isTrusted) return;
if (event.code === 'AltLeft' || event.code === 'AltRight') {
send(18, true);
return;
}
if (event.code === 'ControlLeft' || event.code === 'ControlRight') {
send(17, true);
return;
}
if (event.code === 'KeyX' && event.altKey) event.preventDefault();
if (!event.repeat) send(event.keyCode, true);
});
document.addEventListener('keyup', (event) => {
if (!controller) return;
if (canvas.contains(event.target)) return;
if (!event.isTrusted) return;
if (event.code === 'AltLeft' || event.code === 'AltRight') { send(18, false); return; }
if (event.code === 'ControlLeft' || event.code === 'ControlRight') { send(17, false); return; }
send(event.keyCode, false);
});
if (typeof Dos !== 'function') { status.textContent = 'js-dos 核心載入失敗'; return; }
// Self-hosted emulator: HF static Spaces send binary files through a CORS-less
// redirect, so the DOSBox core ships as base64 text and is served from Blob URLs.
const b64blob = (b64, type) => {
const bin = Uint8Array.from(atob(b64), (c) => c.charCodeAt(0));
return URL.createObjectURL(new Blob([bin], { type }));
};
if (!window.XM_WDOSBOX_B64 || !window.XM_WASM_B64) { status.textContent = '啟動失敗:emu.b64.js'; return; }
const virtual = {
'wdosbox.js': b64blob(window.XM_WDOSBOX_B64, 'application/javascript'),
'wdosbox.wasm.js': b64blob(window.XM_WASM_B64, 'application/octet-stream'),
};
const xhrOpen = XMLHttpRequest.prototype.open;
XMLHttpRequest.prototype.open = function (method, url, ...rest) {
const name = String(url).split('?')[0].split('/').pop();
return xhrOpen.call(this, method, virtual[name] || url, ...rest);
};
Dos(host, { wdosboxUrl: 'wdosbox.js' }).ready((fs, main) => {
status.textContent = '正在載入 DOS 開機磁碟…';
if (!window.XIAOMAO_B64) { status.textContent = '啟動失敗:dos.b64.js'; return; }
const bin = Uint8Array.from(atob(window.XIAOMAO_B64), (c) => c.charCodeAt(0));
const zipUrl = URL.createObjectURL(new Blob([bin], { type: 'application/zip' }));
fs.extract(zipUrl, '/').then(() => main(['-c', 'boot XIAOMAO.img']))
.then((ci) => {
controller = ci;
window.etController = ci;
canvas.tabIndex = 0;
status.textContent = 'FreeDOS 已啟動;點選畫面輸入';
})
.catch((error) => { status.textContent = '啟動失敗:' + (error.message || error); });
});
})();
</script>
</body>
</html>
|