「益智遊戲」和「抽象弈棋」
回复
头像
ejsoon
一枝独秀一枝独秀
帖子: 6597
注册时间: 2022年 11月 18日 17:36
联系:

Re: 一個新的遊戲創意

帖子 ejsoon »

修復和改進
一,提示圓點改色

把「對方最後下的一個或兩個棋子」上標的白色小圓點,改成「選中對方棋子的藍色」,而在本方回合中對方的棋子的圓點將改成紫色。

在下載svg中,如果是「動畫」則兩個小圓點的顏色也改成這種藍色,如果是「數字」則去掉小圓點。

二,更改操作說明

在「操作說明」中,把「該棋子中心會出現藍點」改為「紫色的圓點」。英文也要作相應更改。同時演示中的藍點也應該變為紫色的圓點。

在「確認取消」中,在最後加上一句「如果打勾確認為未激活狀態,點擊可以切換默認AI」。英文也要作相應更改。

在「界面介紹」的svg動畫演示中,如果是手機端,則調整viewBox使其左右空白區域去掉。

為何操作說明窗口的文字不可選中?也不能用鍵盤中的上下鍵或翻頁鍵來滾動。修復這個問題。

二,為AI增加選項

當前AI只有思考時間的差異,現在增加一個「搜尋次數」的選項。

在AI設置窗口中,在最下方增加一個開關按鈕,其右方文字為「搜尋次數」,默認關閉。當其開啟時,其上方的限時設置將被一個「規定搜尋次數」的輸入框替代。

默認的搜尋次數為,簡單120,困難240,專家480,自訂960。

同時把AI默認思考時間改掉:簡單7s,困難12s,專家24s,自訂36s。

將開關按鈕做成現代美觀的樣式,其標題為「思考模式」。

在MCTS搜尋中,當思考模式改為「規定搜尋次數」時,將不限時間,直至搜尋到規定次數。而minimax搜尋將不受時間或搜尋次數限制。

回答要求:不要影響當前正確的遊戲流程,明確給出所要修改的地方和所要替換的代碼。

代码: 全选

修復和改進
一,提示圓點改色

把「對方最後下的一個或兩個棋子」上標的白色小圓點,改成「選中對方棋子的藍色」,而在本方回合中對方的棋子的圓點將改成紫色。

在下載svg中,如果是「動畫」則兩個小圓點的顏色也改成這種藍色,如果是「數字」則去掉小圓點。

二,更改操作說明

在「操作說明」中,把「該棋子中心會出現藍點」改為「紫色的圓點」。英文也要作相應更改。同時演示中的藍點也應該變為紫色的圓點。

在「確認取消」中,在最後加上一句「如果打勾確認為未激活狀態,點擊可以切換默認AI」。英文也要作相應更改。

在「界面介紹」的svg動畫演示中,如果是手機端,則調整viewBox使其左右空白區域去掉。

為何操作說明窗口的文字不可選中?也不能用鍵盤中的上下鍵或翻頁鍵來滾動。修復這個問題。

二,為AI增加選項

當前AI只有思考時間的差異,現在增加一個「搜尋次數」的選項。

在AI設置窗口中,在最下方增加一個開關按鈕,其右方文字為「搜尋次數」,默認關閉。當其開啟時,其上方的限時設置將被一個「規定搜尋次數」的輸入框替代。

默認的搜尋次數為,簡單120,困難240,專家480,自訂960。

同時把AI默認思考時間改掉:簡單7s,困難12s,專家24s,自訂36s。

將開關按鈕做成現代美觀的樣式,其標題為「思考模式」。

在MCTS搜尋中,當思考模式改為「規定搜尋次數」時,將不限時間,直至搜尋到規定次數。而minimax搜尋將不受時間或搜尋次數限制。

回答要求:不要影響當前正確的遊戲流程,明確給出所要修改的地方和所要替換的代碼。
附件
trigolden_game2665.html.7z
(66.7 KiB) 已下载 1 次
https://ejsoon.vip/
弈趣極光:享受思維樂趣
头像
ejsoon
一枝独秀一枝独秀
帖子: 6597
注册时间: 2022年 11月 18日 17:36
联系:

Re: 一個新的遊戲創意

帖子 ejsoon »

代码: 全选

以下修改只涉及**提示圓點、操作說明、SVG 匯出、AI 設定及搜尋停止條件**,不改動 `validateTurnOnState()`、`actionCheck()`、計分、仲裁、回合切換和棋譜邏輯,因此不會破壞目前正確的遊戲流程。

---

# 一、修復操作說明不能選字、不能用鍵盤滾動

原因有兩個:

1. `body` 設定了 `user-select: none`,操作說明繼承後無法選中文字。
2. `#operation-modal-scroll` 不是可聚焦元素,所以方向鍵、PageUp、PageDown 不會作用在它上面。

## 1. 在 CSS 中加入

建議加在原有這段後面:

```css
#rules-modal-scroll,
#operation-modal-scroll {
  overflow-y: auto;
  padding: 25px;
  box-sizing: border-box;
  flex: 1;
  min-height: 0;
  overscroll-behavior: contain;
}
```

加入:

```css
/* 操作說明允許選取文字,並可接收鍵盤滾動 */
#operation-modal-scroll,
#operation-modal-scroll * {
  -webkit-user-select: text;
  user-select: text;
}

#operation-modal-scroll {
  touch-action: pan-y;
  outline: none;
  scrollbar-gutter: stable;
}

#operation-modal-scroll:focus-visible {
  box-shadow: inset 0 0 0 2px rgba(114, 137, 218, 0.72);
}
```

## 2. 修改操作說明滾動容器

搜尋:

```html
<div id="operation-modal-scroll">
```

替換為:

```html
<div id="operation-modal-scroll" tabindex="0">
```

## 3. 替換 `openOperationGuide()`

搜尋原函數:

```js
function openOperationGuide() {
  const modal = document.getElementById('operation-modal');
  const scroll = document.getElementById('operation-modal-scroll');

  if (!modal) return;

  // 避免兩個開始頁面說明視窗同時顯示。
  const rulesModal = document.getElementById('rules-modal');
  if (rulesModal) {
    rulesModal.style.display = 'none';
  }

  modal.style.display = 'flex';

  if (scroll) {
    scroll.scrollTop = 0;
  }

  randomizeOperationGuideDemos();
}
```

替換為:

```js
function openOperationGuide() {
  const modal = document.getElementById('operation-modal');
  const scroll = document.getElementById('operation-modal-scroll');

  if (!modal) return;

  // 避免兩個開始頁面說明視窗同時顯示。
  const rulesModal = document.getElementById('rules-modal');

  if (rulesModal) {
    rulesModal.style.display = 'none';
  }

  modal.style.display = 'flex';

  updateOperationInterfaceDemoViewBox();
  randomizeOperationGuideDemos();

  if (scroll) {
    scroll.scrollTop = 0;

    // 讓方向鍵、PageUp、PageDown、Home、End 作用於說明內容。
    requestAnimationFrame(() => {
      try {
        scroll.focus({ preventScroll: true });
      } catch (error) {
        scroll.focus();
      }
    });
  }
}
```

---

# 二、操作說明文字和紫色圓點

## 1. 修改「選擇目標和落子」文字

搜尋:

```html
<span class="zh-text">
  當有棋子被選中時,點擊棋盤上的對方棋子,該棋子中心會出現藍點。己方所選棋子的所有可放置位置會以半透明棋子顯示;點擊其中一個半透明棋子,即可把棋子暫時落到棋盤上。
</span>
<span class="en-text">
  After selecting a piece, click an opponent’s piece on the board. A blue dot appears at its center, and
  all legal placements for your selected piece appear as translucent pieces. Click one of them to place
  the piece temporarily on the board.
</span>
```

替換為:

```html
<span class="zh-text">
  當有棋子被選中時,點擊棋盤上的對方棋子,該棋子中心會出現紫色的圓點。己方所選棋子的所有可放置位置會以半透明棋子顯示;點擊其中一個半透明棋子,即可把棋子暫時落到棋盤上。
</span>
<span class="en-text">
  After selecting a piece, click an opponent’s piece on the board. A purple dot appears at its center, and
  all legal placements for your selected piece appear as translucent pieces. Click one of them to place
  the piece temporarily on the board.
</span>
```

## 2. 修改「確認取消」文字

搜尋:

```html
<span class="zh-text">
  當本回合行動完成時,點擊「打勾確認」結束回合。如果想取消本回合的所有選擇和暫時落子,點擊「打叉取消」。
</span>
<span class="en-text">
  When your move is complete, click the check button to confirm and end the turn. Click the cross button
  to cancel all selections and temporary placements made during the current turn.
</span>
```

替換為:

```html
<span class="zh-text">
  當本回合行動完成時,點擊「打勾確認」結束回合。如果想取消本回合的所有選擇和暫時落子,點擊「打叉取消」。如果打勾確認為未激活狀態,點擊可以切換默認 AI。
</span>
<span class="en-text">
  When your move is complete, click the check button to confirm and end the turn. Click the cross button
  to cancel all selections and temporary placements made during the current turn. If the confirm
  checkmark is inactive, click it to cycle the default AI.
</span>
```

---

# 三、手機端裁掉「界面介紹」SVG 左右空白

## 1. 給界面介紹 SVG 加上 ID

搜尋第一個「界面介紹」SVG:

```html
<svg
  class="operation-demo operation-svg"
  viewBox="0 0 760 310"
  role="img"
  aria-label="Animated desktop and mobile interface layout"
>
```

替換為:

```html
<svg
  id="op-interface-demo"
  class="operation-demo operation-svg"
  viewBox="0 0 760 310"
  role="img"
  aria-label="Animated desktop and mobile interface layout"
>
```

## 2. 在 `openOperationGuide()` 前加入

```js
function updateOperationInterfaceDemoViewBox() {
  const svg = document.getElementById('op-interface-demo');

  if (!svg) return;

  /*
   * 手機版裁掉左右空白,但仍保留桌面端和手機端兩層動畫的完整內容。
   * 桌面內容約位於 x=207.5~552.5,所以使用 x=190~570。
   */
  const isPhoneLayout = window.matchMedia('(max-width: 768px)').matches;

  svg.setAttribute('viewBox', isPhoneLayout ? '190 0 380 310' : '0 0 760 310');
}

window.addEventListener('resize', updateOperationInterfaceDemoViewBox);
```

---

# 四、修改棋盤上的藍色、紫色提示圓點

## 1. 增加統一顏色常數

搜尋:

```js
const MCTS_C = 1.414;
```

在其後加入:

```js
// 上一回合棋子提示:沿用原本選中對方棋子的亮藍色。
const LAST_MOVE_DOT_COLOR = '#00bfff';

// 本回合選中的對方棋子提示:紫色。
const TARGET_DOT_COLOR = '#a855f7';
```

## 2. 將選中的對方棋子改為紫色

在 `renderBoard()` 中搜尋:

```js
if (targetOpponentPieceId) {
  let tp = pieces.find(x => x.id === targetOpponentPieceId);
  if (tp) {
    const pt = getPieceTransform(tp.svgId, tp.vertices);
    const circle = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
    circle.setAttribute('cx', trimNum(pt.tx));
    circle.setAttribute('cy', trimNum(pt.ty));
    circle.setAttribute('r', '3');
    circle.setAttribute('fill', '#00bfff');
    circle.setAttribute('stroke', 'none');
    etanidrop.appendChild(circle);
  }
}
```

替換為:

```js
if (targetOpponentPieceId) {
  const tp = pieces.find(piece => piece.id === targetOpponentPieceId);

  if (tp) {
    const pt = getPieceTransform(tp.svgId, tp.vertices);
    const circle = document.createElementNS('http://www.w3.org/2000/svg', 'circle');

    circle.setAttribute('cx', trimNum(pt.tx));
    circle.setAttribute('cy', trimNum(pt.ty));
    circle.setAttribute('r', '3');
    circle.setAttribute('fill', TARGET_DOT_COLOR);
    circle.setAttribute('stroke', 'none');

    // 供數字 SVG 模式移除所有操作提示圓點。
    circle.classList.add('board-marker-dot', 'target-selection-dot');

    etanidrop.appendChild(circle);
  }
}
```

## 3. 給暫時落子的圓點增加標記 class

在 `tempPieces.forEach()` 內搜尋:

```js
circle.setAttribute('stroke-width', '1');
```

在其後加入:

```js
circle.classList.add('board-marker-dot', 'temporary-piece-dot');
```

## 4. 將對方上一回合棋子圓點由白色改為藍色

搜尋:

```js
} else if (!selectedTile && !targetOpponentPieceId) {
  lastTurnPieces.forEach(tp => {
    const pt = getPieceTransform(tp.svgId, tp.vertices);
    const circle = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
    circle.setAttribute('cx', trimNum(pt.tx));
    circle.setAttribute('cy', trimNum(pt.ty));
    circle.setAttribute('r', '3');
    circle.setAttribute('fill', '#ffffff');
    circle.setAttribute('stroke', 'none');
    etanidrop.appendChild(circle);
  });
}
```

替換為:

```js
} else if (!targetOpponentPieceId) {
  /*
   * 沒有選中目標時,繼續在對方上一回合的一個或兩個棋子上顯示藍點。
   * 選中目標後,這些藍點會隱藏,只保留被選中棋子上的紫點。
   */
  lastTurnPieces.forEach(tp => {
    const pt = getPieceTransform(tp.svgId, tp.vertices);
    const circle = document.createElementNS('http://www.w3.org/2000/svg', 'circle');

    circle.setAttribute('cx', trimNum(pt.tx));
    circle.setAttribute('cy', trimNum(pt.ty));
    circle.setAttribute('r', '3');
    circle.setAttribute('fill', LAST_MOVE_DOT_COLOR);
    circle.setAttribute('stroke', 'none');
    circle.classList.add('board-marker-dot', 'last-move-dot');

    etanidrop.appendChild(circle);
  });
}
```

這裡特意去掉了 `!selectedTile` 條件,因此選中本方棋子、但尚未選中對方目標時,上一回合的藍色提示仍會保留。

---

# 五、操作說明動畫中的目標點改為紫色

在 `renderOperationPlacementDemo()` 中搜尋:

```js
if (state.stage === 'ghosts' || state.stage === 'placed') {
  boardPiecesGroup.appendChild(
    createOperationSvgElement('circle', {
      cx: targetCenter.x,
      cy: targetCenter.y,
      r: 4,
      fill: '#00bfff',
      stroke: 'none'
    })
  );
}
```

替換為:

```js
if (state.stage === 'ghosts' || state.stage === 'placed') {
  boardPiecesGroup.appendChild(
    createOperationSvgElement('circle', {
      cx: targetCenter.x,
      cy: targetCenter.y,
      r: 4,
      fill: TARGET_DOT_COLOR,
      stroke: 'none'
    })
  );
}
```

---

# 六、SVG 下載中的圓點

## 1. 數字模式移除所有提示圓點

搜尋:

```js
function addDownloadPieceNumbers(svg) {
  prependDownloadFontStyle(svg);

  const drop = svg.querySelector('.etdrop');
```

替換開頭為:

```js
function addDownloadPieceNumbers(svg) {
  prependDownloadFontStyle(svg);

  /*
   * 數字模式使用數字標示落子順序,因此移除:
   * 1. 上一回合藍點;
   * 2. 被選中目標的紫點;
   * 3. 暫時落子的白底紅框圓點。
   */
  svg.querySelectorAll('.board-marker-dot').forEach(dot => {
    dot.remove();
  });

  const drop = svg.querySelector('.etdrop');
```

其餘函數內容保持不變。

## 2. 動畫模式的兩個提示點改為藍色

將函數名稱:

```js
function appendDownloadWhiteDotCircles(drop, turns, cycleDurationSeconds) {
```

改為:

```js
function appendDownloadLastMoveDots(drop, turns, cycleDurationSeconds) {
```

在同一函數中搜尋:

```js
const circle = createDownloadSvgElement('circle', {
  id: `btwhite${circleIndex + 1}`,
  cx: '0',
  cy: '0',
  r: '3',
  fill: '#ffffff',
  stroke: 'none',
```

替換為:

```js
const circle = createDownloadSvgElement('circle', {
  id: `btlastdot${circleIndex + 1}`,
  cx: '0',
  cy: '0',
  r: '3',
  fill: LAST_MOVE_DOT_COLOR,
  stroke: 'none',
```

## 3. 修復空棋盤動畫可能重複生成四個圓點

在 `addDownloadAnimation()` 中搜尋:

```js
const downloadAnimationCycleDuration = turns.length * 2 + 12;

appendDownloadWhiteDotCircles(drop, turns, downloadAnimationCycleDuration);

/*
 * 沒有棋子時仍顯示一個可循環的空局面預覽。
 */
if (turns.length === 0) {
```

替換為:

```js
const downloadAnimationCycleDuration = turns.length === 0 ? 8 : turns.length * 2 + 12;

appendDownloadLastMoveDots(drop, turns, downloadAnimationCycleDuration);

/*
 * 沒有棋子時仍顯示一個可循環的空局面預覽。
 */
if (turns.length === 0) {
```

然後在這個 `if (turns.length === 0)` 的末尾搜尋並刪除:

```js
appendDownloadWhiteDotCircles(drop, turns, 8);
```

最後確認整個文件中已經沒有:

```js
appendDownloadWhiteDotCircles
```

全部應改為:

```js
appendDownloadLastMoveDots
```

---

# 七、AI 設定窗口增加「思考模式」開關

## 1. 增加現代開關 CSS

在 AI 設定相關 CSS 的末尾,例如 `.ai-param-row input:disabled` 後面加入:

```css
/* AI 思考模式開關 */
.ai-thinking-mode-row {
  grid-column: 1 / -1;
  min-height: 58px;
  padding: 12px 2px 0;
  box-sizing: border-box;
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 16px;
  border-top: 1px solid #444;
}

.ai-thinking-mode-title {
  color: #fff;
  font-size: 0.95em;
  font-weight: bold;
  letter-spacing: 0.02em;
}

.ai-settings-grid .ai-mode-switch {
  position: relative;
  display: inline-flex;
  flex-direction: row;
  align-items: center;
  gap: 10px;
  color: #ddd;
  font-size: 0.9em;
  cursor: pointer;
  user-select: none;
}

.ai-settings-grid .ai-mode-switch input {
  position: absolute;
  width: 1px;
  height: 1px;
  margin: 0;
  padding: 0;
  border: 0;
  opacity: 0;
  pointer-events: none;
}

.ai-mode-slider {
  position: relative;
  width: 50px;
  height: 28px;
  flex: none;
  box-sizing: border-box;
  border: 1px solid #5d636b;
  border-radius: 14px;
  background: #363b42;
  box-shadow:
    inset 0 2px 5px rgba(0, 0, 0, 0.35),
    0 2px 6px rgba(0, 0, 0, 0.25);
  transition:
    background 0.22s,
    border-color 0.22s,
    box-shadow 0.22s;
}

.ai-mode-slider::before {
  content: '';
  position: absolute;
  top: 3px;
  left: 3px;
  width: 20px;
  height: 20px;
  border-radius: 50%;
  background: #e8eaed;
  box-shadow: 0 2px 5px rgba(0, 0, 0, 0.42);
  transition:
    transform 0.22s,
    background 0.22s;
}

.ai-mode-switch input:checked + .ai-mode-slider {
  border-color: #7289da;
  background: linear-gradient(135deg, #7289da, #8d6fe8);
  box-shadow:
    inset 0 1px 4px rgba(255, 255, 255, 0.18),
    0 0 12px rgba(114, 137, 218, 0.62);
}

.ai-mode-switch input:checked + .ai-mode-slider::before {
  transform: translateX(22px);
  background: #fff;
}

.ai-mode-switch input:focus-visible + .ai-mode-slider {
  outline: 2px solid #fff;
  outline-offset: 3px;
}

.ai-mode-switch-text {
  min-width: 72px;
  color: #ddd;
  line-height: 1.3;
}
```

## 2. 替換 AI 參數 HTML

在 AI 設定窗口中搜尋:

```html
<div class="ai-param-row">
  <div class="ai-param-title" id="ai-param-title-time">限時 (秒)</div>
  <div class="ai-param-values">
    <input type="number" id="ai-time-2" value="3" step="1" oninput="handleAIParamInput(2)" />
    <input type="number" id="ai-time-1" value="18" step="1" oninput="handleAIParamInput(1)" />
  </div>
</div>
```

替換為:

```html
<div class="ai-param-row">
  <div class="ai-param-title" id="ai-param-title-time">限時 (秒)</div>

  <div class="ai-param-values">
    <input
      type="number"
      id="ai-limit-2"
      value="7"
      min="0.2"
      step="0.1"
      inputmode="decimal"
      oninput="handleAIParamInput(2)"
    />

    <input
      type="number"
      id="ai-limit-1"
      value="36"
      min="0.2"
      step="0.1"
      inputmode="decimal"
      oninput="handleAIParamInput(1)"
    />
  </div>
</div>

<div class="ai-thinking-mode-row">
  <div class="ai-thinking-mode-title" id="ai-thinking-mode-title">思考模式</div>

  <label class="ai-mode-switch">
    <input
      type="checkbox"
      id="ai-search-count-mode"
      onchange="handleAISearchModeToggle()"
    />

    <span class="ai-mode-slider" aria-hidden="true"></span>
    <span class="ai-mode-switch-text" id="ai-search-count-label">搜尋次數</span>
  </label>
</div>
```

開關沒有 `checked`,所以默認為關閉,即默認使用限時模式。

---

# 八、增加 AI 雙語文字

在 `i18n` 中搜尋:

```js
'ai-param-title-time': { zh: '限時 (秒)', en: 'Time Limit (s)' },
```

替換為:

```js
'ai-param-title-time': {
  zh: '限時 (秒)',
  en: 'Time Limit (s)'
},
'ai-param-title-searches': {
  zh: '規定搜尋次數',
  en: 'Fixed Search Count'
},
'ai-thinking-mode-title': {
  zh: '思考模式',
  en: 'Thinking Mode'
},
'ai-search-count-label': {
  zh: '搜尋次數',
  en: 'Search Count'
},
```

## 替換 `updateAIDialogLang()`

```js
function updateAIDialogLang() {
  [1, 2].forEach(p => {
    document.querySelectorAll(`#ai-list-${p} .ai-type-option`).forEach(opt => {
      const val = opt.getAttribute('data-val');
      const labels = AI_TYPE_LABELS[val];

      if (labels) {
        opt.querySelector('.opt-label').textContent = labels[currentLang];
      }
    });

    updateAISelectedDisplay(p);
  });

  // 語言切換後重新套用目前的限時/搜尋次數標題。
  updateAISearchModeUI(false);
}
```

---

# 九、替換 AI 默認參數

搜尋:

```js
const CUSTOM_AI_DEFAULT = { time: 5 };

let aiConfig = {
  1: false,
  2: true,
  type: { 1: 'human', 2: 'easy' },
  lastAIType: { 1: 'easy', 2: 'easy' },
  params: {
    easy: { time: 3 },
    hard: { time: 7 },
    expert: { time: 12 },
    custom: { ...CUSTOM_AI_DEFAULT }
  },

  settings: {
    1: { time: 18 },
    2: { time: 3 }
  }
};
```

替換為:

```js
const CUSTOM_AI_DEFAULT = Object.freeze({
  time: 36,
  searches: 960
});

let aiConfig = {
  1: false,
  2: true,

  type: {
    1: 'human',
    2: 'easy'
  },

  lastAIType: {
    1: 'easy',
    2: 'easy'
  },

  // time:限時秒數;searches:MCTS 規定搜尋次數。
  params: {
    easy: {
      time: 7,
      searches: 120
    },
    hard: {
      time: 12,
      searches: 240
    },
    expert: {
      time: 24,
      searches: 480
    },
    custom: {
      ...CUSTOM_AI_DEFAULT
    }
  },

  // 全局思考模式;默認仍是限時模式。
  searchMode: 'time',

  // 各玩家實際使用的參數。
  settings: {
    1: {
      ...CUSTOM_AI_DEFAULT
    },
    2: {
      time: 7,
      searches: 120
    }
  },

  // 保存雙方各自的自訂數值,切換到預設等級後不會遺失。
  customSettings: {
    1: {
      ...CUSTOM_AI_DEFAULT
    },
    2: {
      ...CUSTOM_AI_DEFAULT
    }
  }
};
```

---

# 十、替換 AI 設定窗口程式

搜尋從:

```js
function updateAIConfig() {
```

開始,一直到:

```js
function cancelAISettingsDialog() {
  syncAIDialogFromConfirmed();
  document.getElementById('ai-settings-dialog').style.display = 'none';
}
```

將這整段替換為以下程式:

```js
let aiDialogDraft = null;

function cloneAISetting(setting = {}) {
  const rawTime = Number(setting.time);
  const rawSearches = Number(setting.searches);

  return {
    time: Number.isFinite(rawTime)
      ? Math.max(0.2, rawTime)
      : CUSTOM_AI_DEFAULT.time,

    searches: Number.isFinite(rawSearches)
      ? Math.max(1, Math.floor(rawSearches))
      : CUSTOM_AI_DEFAULT.searches
  };
}

function getAIParamField(mode) {
  return mode === 'count' ? 'searches' : 'time';
}

function createAIDialogDraft() {
  return {
    searchMode: aiConfig.searchMode === 'count' ? 'count' : 'time',

    type: {
      1: aiConfig.type[1] || 'human',
      2: aiConfig.type[2] || 'human'
    },

    settings: {
      1: cloneAISetting(aiConfig.settings[1]),
      2: cloneAISetting(aiConfig.settings[2])
    },

    customSettings: {
      1: cloneAISetting(aiConfig.customSettings[1]),
      2: cloneAISetting(aiConfig.customSettings[2])
    }
  };
}

function ensureAIDialogDraft() {
  if (!aiDialogDraft) {
    aiDialogDraft = createAIDialogDraft();
  }

  return aiDialogDraft;
}

function setAITypeControl(p, type) {
  const hiddenInput = document.getElementById('ai-p' + p + '-type');

  if (hiddenInput) {
    hiddenInput.value = type;
  }

  document.querySelectorAll(`#ai-list-${p} .ai-type-option`).forEach(option => {
    option.classList.toggle('selected', option.getAttribute('data-val') === type);
  });

  updateAISelectedDisplay(p);
}

function findAIPresetType(field, value) {
  if (!Number.isFinite(value)) {
    return 'custom';
  }

  for (const type of ['easy', 'hard', 'expert']) {
    const presetValue = aiConfig.params[type][field];

    if (Math.abs(value - presetValue) < 1e-9) {
      return type;
    }
  }

  return 'custom';
}

function renderAIDialogParam(p) {
  if (!aiDialogDraft) return;

  const input = document.getElementById('ai-limit-' + p);

  if (!input) return;

  const type = aiDialogDraft.type[p];
  const mode = aiDialogDraft.searchMode;
  const field = getAIParamField(mode);
  const isHuman = type === 'human';

  input.disabled = isHuman;

  if (mode === 'count') {
    input.min = '1';
    input.step = '1';
    input.setAttribute('inputmode', 'numeric');
  } else {
    input.min = '0.2';
    input.step = '0.1';
    input.setAttribute('inputmode', 'decimal');
  }

  if (isHuman) {
    input.value = '';
    return;
  }

  const setting = cloneAISetting(aiDialogDraft.settings[p]);
  const value = setting[field];

  input.value = field === 'searches'
    ? String(Math.floor(value))
    : String(value);
}

function updateAISearchModeUI(updateDraft = true) {
  const checkbox = document.getElementById('ai-search-count-mode');
  const title = document.getElementById('ai-param-title-time');

  if (!checkbox || !title) return;

  if (aiDialogDraft) {
    if (updateDraft) {
      aiDialogDraft.searchMode = checkbox.checked ? 'count' : 'time';
    } else {
      checkbox.checked = aiDialogDraft.searchMode === 'count';
    }
  } else {
    checkbox.checked = aiConfig.searchMode === 'count';
  }

  const mode = aiDialogDraft
    ? aiDialogDraft.searchMode
    : aiConfig.searchMode;

  title.textContent =
    mode === 'count'
      ? i18n['ai-param-title-searches'][currentLang]
      : i18n['ai-param-title-time'][currentLang];

  if (aiDialogDraft) {
    renderAIDialogParam(1);
    renderAIDialogParam(2);
  }
}

function handleAISearchModeToggle() {
  ensureAIDialogDraft();
  updateAISearchModeUI(true);
}

function detectAIPresetType(p) {
  const draft = ensureAIDialogDraft();
  const input = document.getElementById('ai-limit-' + p);

  if (!input) {
    return 'custom';
  }

  const field = getAIParamField(draft.searchMode);
  const value = Number(input.value);

  return findAIPresetType(field, value);
}

function handleAIParamInput(p) {
  const draft = ensureAIDialogDraft();
  const input = document.getElementById('ai-limit-' + p);

  if (!input || input.disabled) return;

  const mode = draft.searchMode;
  const field = getAIParamField(mode);
  const rawValue = Number(input.value);

  // 輸入框被暫時清空時不立刻覆寫設定。
  if (!Number.isFinite(rawValue)) return;

  const value =
    field === 'searches'
      ? Math.max(1, Math.floor(rawValue))
      : Math.max(0.2, rawValue);

  if (field === 'searches') {
    input.value = String(value);
  }

  const previousType = draft.type[p];
  const detectedType = findAIPresetType(field, value);

  if (
    detectedType === 'easy' ||
    detectedType === 'hard' ||
    detectedType === 'expert'
  ) {
    // 輸入值恰好符合預設時,同步整組時間和搜尋次數。
    draft.settings[p] = cloneAISetting(aiConfig.params[detectedType]);
  } else {
    /*
     * 第一次由預設等級改成自訂時,另一種模式使用自訂默認值:
     * 36 秒/960 次。
     */
    if (previousType !== 'custom') {
      draft.customSettings[p] = cloneAISetting(CUSTOM_AI_DEFAULT);
    }

    draft.customSettings[p][field] = value;
    draft.settings[p] = cloneAISetting(draft.customSettings[p]);
  }

  draft.type[p] = detectedType;
  setAITypeControl(p, detectedType);
}

function handleAITypeSelect(p) {
  const draft = ensureAIDialogDraft();
  const type = document.getElementById('ai-p' + p + '-type').value;

  draft.type[p] = type;

  if (
    type === 'easy' ||
    type === 'hard' ||
    type === 'expert'
  ) {
    draft.settings[p] = cloneAISetting(aiConfig.params[type]);
  } else if (type === 'custom') {
    draft.settings[p] = cloneAISetting(draft.customSettings[p]);
  }

  setAITypeControl(p, type);
  renderAIDialogParam(p);
}

// 人類控制的一方不需要顯示 AI 參數。
function updateAIParamInputsState(p) {
  renderAIDialogParam(p);
}

function updateAIConfig(sourceDraft = null) {
  if (sourceDraft) {
    aiConfig.searchMode =
      sourceDraft.searchMode === 'count' ? 'count' : 'time';
  }

  [1, 2].forEach(p => {
    const hiddenInput = document.getElementById('ai-p' + p + '-type');

    const type = sourceDraft
      ? sourceDraft.type[p]
      : hiddenInput
        ? hiddenInput.value
        : aiConfig.type[p];

    if (sourceDraft) {
      // 即使目前選擇人類或預設 AI,也保留尚未啟用的自訂數值。
      aiConfig.customSettings[p] = cloneAISetting(
        sourceDraft.customSettings[p]
      );
    }

    aiConfig.type[p] = type;
    aiConfig[p] = type !== 'human';

    if (type !== 'human') {
      aiConfig.lastAIType[p] = type;
    }

    if (
      type === 'easy' ||
      type === 'hard' ||
      type === 'expert'
    ) {
      aiConfig.settings[p] = cloneAISetting(aiConfig.params[type]);
    } else if (type === 'custom') {
      const customSetting = sourceDraft
        ? sourceDraft.customSettings[p]
        : aiConfig.customSettings[p];

      aiConfig.customSettings[p] = cloneAISetting(customSetting);
      aiConfig.settings[p] = cloneAISetting(customSetting);
    } else if (sourceDraft && sourceDraft.settings[p]) {
      // 人類狀態保留原參數,日後重新啟用 AI 時仍可使用。
      aiConfig.settings[p] = cloneAISetting(sourceDraft.settings[p]);
    }

    setAITypeControl(p, type);
  });

  updateUI();
  checkAndTriggerAI();
}

function syncAIDialogFromConfirmed() {
  aiDialogDraft = createAIDialogDraft();

  const checkbox = document.getElementById('ai-search-count-mode');

  if (checkbox) {
    checkbox.checked = aiDialogDraft.searchMode === 'count';
  }

  [1, 2].forEach(p => {
    setAITypeControl(p, aiDialogDraft.type[p]);

    const dropdown = document.getElementById('ai-dropdown-' + p);

    if (dropdown) {
      dropdown.classList.remove('open');
    }
  });

  updateAISearchModeUI(false);
}

function openAISettingsDialog() {
  syncAIDialogFromConfirmed();
  document.getElementById('ai-settings-dialog').style.display = 'flex';
}

// 按下確認才套用草稿。
function confirmAISettingsDialog() {
  const draft = ensureAIDialogDraft();

  /*
   * 確認前再讀一次輸入框,處理使用鍵盤或瀏覽器數字按鈕
   * 修改後尚未失去焦點的情況。
   */
  [1, 2].forEach(p => {
    const input = document.getElementById('ai-limit-' + p);

    if (input && !input.disabled && input.value !== '') {
      handleAIParamInput(p);
    }
  });

  document.getElementById('ai-settings-dialog').style.display = 'none';

  aiDialogDraft = null;
  updateAIConfig(draft);
}

// 叉叉關閉時捨棄草稿,還原已確認的設定。
function cancelAISettingsDialog() {
  syncAIDialogFromConfirmed();

  document.getElementById('ai-settings-dialog').style.display = 'none';

  aiDialogDraft = null;
}
```

確認整份文件中不再有:

```js
ai-time-1
ai-time-2
```

現在統一使用:

```js
ai-limit-1
ai-limit-2
```

---

# 十一、讓 minimax 不受時間或搜尋次數限制

目前 `runStateMinimax()` 使用 `deadline` 中止。需要移除時間判斷,只保留使用者主動取消 AI 的判斷。

## 1. 修改函數參數

搜尋:

```js
async function runStateMinimax(rootState, rootMoves, maxDepth, deadline) {
```

替換為:

```js
async function runStateMinimax(rootState, rootMoves, maxDepth) {
```

## 2. 在 `runStateMinimax()` 函數內

將所有:

```js
if (cancelAi || performance.now() >= deadline) {
  throw new SearchTimeout();
}
```

替換為:

```js
if (cancelAi) {
  throw new SearchTimeout();
}
```

附件中該函數內共有多處相同判斷,全部只保留 `cancelAi`。

其中這一段:

```js
if (cancelAi || performance.now() >= deadline) {
  throw new SearchTimeout();
}
```

也同樣替換。

這樣 minimax 仍然可以由使用者按打叉取消,但不會因限時或 MCTS 搜尋次數設定而中止。

---

# 十二、讓 MCTS 支援規定搜尋次數

## 1. 修改 MCTS 函數參數

搜尋:

```js
async function runStateMCTS(rootState, rootMoves, deadline, cValue) {
```

替換為:

```js
async function runStateMCTS(
  rootState,
  rootMoves,
  deadline,
  cValue,
  maxSearchCount = Infinity
) {
```

## 2. 修改 MCTS 主循環

搜尋:

```js
while (performance.now() < deadline && !cancelAi) {
```

替換為:

```js
while (
  !cancelAi &&
  searchCount < maxSearchCount &&
  performance.now() < deadline
) {
```

限時模式會傳入有限的 `deadline` 和無限搜尋次數;搜尋次數模式則傳入 `Infinity` 作為 deadline,直到完成指定次數。

---

# 十三、修改 `startAI()` 的停止條件

## 1. 替換時間設定

在 `startAI()` 中搜尋:

```js
const timeLimit = Math.max(0.2, aiConfig.settings[aiPlayer].time || 3) * 1000;
```

替換為:

```js
const activeAISettings =
  aiConfig.settings[aiPlayer] || CUSTOM_AI_DEFAULT;

const searchMode =
  aiConfig.searchMode === 'count' ? 'count' : 'time';

const timeLimit =
  Math.max(
    0.2,
    Number(activeAISettings.time) || CUSTOM_AI_DEFAULT.time
  ) * 1000;

const searchLimit =
  Math.max(
    1,
    Math.floor(
      Number(activeAISettings.searches) ||
        CUSTOM_AI_DEFAULT.searches
    )
  );
```

## 2. 替換搜尋啟動程式

搜尋:

```js
const searchStartTime = performance.now();
const searchDeadline = searchStartTime + timeLimit;

if (useMinimax) {
  result = await runStateMinimax(rootState, rootMoves, turnsLeft, searchDeadline);
} else {
  result = await runStateMCTS(rootState, rootMoves, searchDeadline, MCTS_C);
}
```

替換為:

```js
const searchStartTime = performance.now();

if (useMinimax) {
  /*
   * 終盤 minimax 不受限時或規定搜尋次數限制。
   * 只會在完成指定深度,或使用者主動取消 AI 時結束。
   */
  result = await runStateMinimax(
    rootState,
    rootMoves,
    turnsLeft
  );
} else {
  /*
   * 限時模式:
   *   deadline 為指定時間,maxSearchCount 為 Infinity。
   *
   * 搜尋次數模式:
   *   deadline 為 Infinity,完成指定模擬次數才結束。
   */
  const searchDeadline =
    searchMode === 'time'
      ? searchStartTime + timeLimit
      : Infinity;

  const maxSearchCount =
    searchMode === 'count'
      ? searchLimit
      : Infinity;

  result = await runStateMCTS(
    rootState,
    rootMoves,
    searchDeadline,
    MCTS_C,
    maxSearchCount
  );
}
```

---

# 十四、最終行為

修改後的預期結果如下:

| AI 等級 | 限時模式 | 搜尋次數模式 |
|---|---:|---:|
| 簡單 | 7 秒 | 120 次 |
| 困難 | 12 秒 | 240 次 |
| 專家 | 24 秒 | 480 次 |
| 自訂 | 36 秒 | 960 次 |

搜尋規則:

- 一般局面使用 MCTS。
- 限時模式下,MCTS 到達指定時間停止。
- 搜尋次數模式下,MCTS 不限時間,完成指定次數後停止。
- 終盤 minimax 不受時間或搜尋次數限制。
- minimax 和 MCTS 都仍可由玩家按打叉主動取消。
- 開局隨機落子、最後一子精確求解、仲裁判定均保留原流程。
- 正式落子仍經過原本的 `validateTurnOnState()`,不會繞過合法性檢查。
- 動畫 SVG 顯示兩個藍點。
- 數字 SVG 不顯示藍點、紫點或暫時落子圓點。
- 正式棋盤中,上一回合棋子顯示藍點;選中對方棋子後只顯示紫點。
https://ejsoon.vip/
弈趣極光:享受思維樂趣
回复
  • 相似主题
    回复总数
    阅读次数
    最新帖子

在线用户

正浏览此版面之用户: 没有注册用户 和 4 访客