c_dos / index.html
HelloSun's picture
小貓中文 v1.0: add author HelloSun, first-version date and GPLv3 section 7(b) attribution term (docs, source headers, startup notice)
64ace35 verified
Raw History Blame Contribute Delete
16.6 kB
<!-- 小貓中文 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>