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>