/* ===== engine.js ===== */ /* ==================== engine.js ==================== */ (function (root) { "use strict"; const YAOCHUU = [0, 8, 9, 17, 18, 26, 27, 28, 29, 30, 31, 32, 33]; const HONOR_START = 27; function tileSuit(t) { if (t < 9) return "m"; if (t < 18) return "p"; if (t < 27) return "s"; return "z"; } function tileNum(t) { if (t < 27) return (t % 9) + 1; return t - 26; } function isTerminalOrHonor(t) { if (t >= HONOR_START) return true; const n = tileNum(t); return n === 1 || n === 9; } function isTerminal(t) { if (t >= HONOR_START) return false; const n = tileNum(t); return n === 1 || n === 9; } function isHonor(t) { return t >= HONOR_START; } function isGreenTile(t) { const greens = [19, 20, 21, 23, 25, 32]; return greens.indexOf(t) !== -1; } function newCounts() { return new Array(34).fill(0); } function countsFromArray(tiles) { const c = newCounts(); for (const t of tiles) c[t]++; return c; } function cloneCounts(c) { return c.slice(); } function chiitoiShanten(counts) { let pairs = 0, kinds = 0; for (let i = 0; i < 34; i++) { if (counts[i] > 0) kinds++; if (counts[i] >= 2) pairs++; } return 6 - pairs + Math.max(0, 7 - kinds); } function kokushiShanten(counts) { let kinds = 0, hasPair = false; for (const t of YAOCHUU) { if (counts[t] > 0) kinds++; if (counts[t] >= 2) hasPair = true; } return 13 - kinds - (hasPair ? 1 : 0); } const suitMemo = new Map(); function decomposeSuit(counts9) { const key = counts9.join(""); if (suitMemo.has(key)) return suitMemo.get(key); const result = decomposeSuitRec(counts9.slice(), 0); suitMemo.set(key, result); return result; } function score(r) { return r.sets * 2 + r.partials; } function better(a, b) { return score(a) > score(b) ? a : b; } function decomposeSuitRec(c, i) { while (i < 9 && c[i] === 0) i++; if (i >= 9) return { sets: 0, partials: 0 }; let best = { sets: 0, partials: 0 }; if (c[i] >= 3) { const c2 = c.slice(); c2[i] -= 3; const r = decomposeSuitRec(c2, i); best = better(best, { sets: r.sets + 1, partials: r.partials }); } if (i <= 6 && c[i] >= 1 && c[i + 1] >= 1 && c[i + 2] >= 1) { const c2 = c.slice(); c2[i]--; c2[i + 1]--; c2[i + 2]--; const r = decomposeSuitRec(c2, i); best = better(best, { sets: r.sets + 1, partials: r.partials }); } if (c[i] >= 2) { const c2 = c.slice(); c2[i] -= 2; const r = decomposeSuitRec(c2, i); best = better(best, { sets: r.sets, partials: r.partials + 1 }); } if (i <= 7 && c[i] >= 1 && c[i + 1] >= 1) { const c2 = c.slice(); c2[i]--; c2[i + 1]--; const r = decomposeSuitRec(c2, i); best = better(best, { sets: r.sets, partials: r.partials + 1 }); } if (i <= 6 && c[i] >= 1 && c[i + 2] >= 1) { const c2 = c.slice(); c2[i]--; c2[i + 2]--; const r = decomposeSuitRec(c2, i); best = better(best, { sets: r.sets, partials: r.partials + 1 }); } { const c2 = c.slice(); c2[i] = 0; const r = decomposeSuitRec(c2, i); best = better(best, r); } return best; } function decomposeHonors(counts) { let sets = 0, partials = 0; for (let t = 27; t < 34; t++) { if (counts[t] >= 3) sets++; else if (counts[t] === 2) partials++; } return { sets, partials }; } function standardShanten(counts, meldCount) { meldCount = meldCount || 0; let best = 8; const pairCandidates = [null]; for (let i = 0; i < 34; i++) if (counts[i] >= 2) pairCandidates.push(i); for (const pc of pairCandidates) { const work = counts.slice(); let hasPair = 0; if (pc !== null) { work[pc] -= 2; hasPair = 1; } let sets = meldCount, partials = 0; const suits = [work.slice(0, 9), work.slice(9, 18), work.slice(18, 27)]; for (const s of suits) { const r = decomposeSuit(s); sets += r.sets; partials += r.partials; } const h = decomposeHonors(work); sets += h.sets; partials += h.partials; let blocks = sets + partials; if (blocks > 4) { partials = 4 - sets; if (partials < 0) partials = 0; blocks = sets + partials; } let shanten = 8 - 2 * sets - partials - hasPair; if (best > shanten) best = shanten; } return best; } function calcShanten(counts, meldCount) { let s = standardShanten(counts, meldCount || 0); if (!meldCount) { s = Math.min(s, chiitoiShanten(counts)); s = Math.min(s, kokushiShanten(counts)); } return s; } function ukeire(counts, meldCount) { const cur = calcShanten(counts, meldCount); const res = []; for (let t = 0; t < 34; t++) { if (counts[t] >= 4) continue; const c2 = counts.slice(); c2[t]++; if (calcShanten(c2, meldCount) < cur) res.push(t); } return res; } function isCompleteStandard(counts, meldCount) { meldCount = meldCount || 0; const needSets = 4 - meldCount; for (let p = 0; p < 34; p++) { if (counts[p] >= 2) { const w = counts.slice(); w[p] -= 2; if (canDecomposeAllSets(w, needSets)) return true; } } return false; } function canDecomposeAllSets(counts, needSets) { let total = 0; for (const c of counts) total += c; if (total === 0) return needSets === 0; if (needSets === 0) return total === 0; let i = 0; while (i < 34 && counts[i] === 0) i++; if (i >= 34) return needSets === 0; if (isHonor(i)) { if (counts[i] === 3) { const w = counts.slice(); w[i] -= 3; return canDecomposeAllSets(w, needSets - 1); } return false; } const posInSuit = i % 9; if (counts[i] >= 3) { const w = counts.slice(); w[i] -= 3; if (canDecomposeAllSets(w, needSets - 1)) return true; } if (posInSuit <= 6 && counts[i + 1] > 0 && counts[i + 2] > 0) { const w = counts.slice(); w[i]--; w[i + 1]--; w[i + 2]--; if (canDecomposeAllSets(w, needSets - 1)) return true; } return false; } function isChiitoiComplete(counts) { let pairs = 0, kinds = 0; for (const c of counts) { if (c > 0) kinds++; if (c === 2) pairs++; if (c > 2) return false; } return pairs === 7 && kinds === 7; } function isKokushiComplete(counts) { let hasPair = false; for (let t = 0; t < 34; t++) { if (counts[t] === 0) continue; if (YAOCHUU.indexOf(t) === -1) return false; if (counts[t] >= 2) hasPair = true; } let kinds = 0; for (const t of YAOCHUU) if (counts[t] > 0) kinds++; return kinds === 13 && hasPair; } function isAgari(counts, meldCount) { return isCompleteStandard(counts, meldCount || 0) || (!meldCount && isChiitoiComplete(counts)) || (!meldCount && isKokushiComplete(counts)); } root.MJCore = { tileSuit, tileNum, isTerminalOrHonor, isTerminal, isHonor, isGreenTile, newCounts, countsFromArray, cloneCounts, chiitoiShanten, kokushiShanten, standardShanten, calcShanten, ukeire, isCompleteStandard, isChiitoiComplete, isKokushiComplete, isAgari, YAOCHUU, }; })(window); /* ===== yaku.js ===== */ /* ==================== yaku.js ==================== */ window.MJYaku = (function (Core) { "use strict"; function isHonor(t) { return t >= 27; } function tileNum(t) { return Core.tileNum(t); } function isTerminal(t) { return Core.isTerminal(t); } function isTerminalOrHonor(t) { return Core.isTerminalOrHonor(t); } function collectSetDecomps(counts, needSets) { const results = []; const cur = []; const c = counts.slice(); function rec(i) { while (i < 34 && c[i] === 0) i++; if (cur.length === needSets) { for (let k = i; k < 34; k++) if (c[k] !== 0) return; results.push(cur.slice()); return; } if (i >= 34) return; if (isHonor(i)) { if (c[i] >= 3) { c[i] -= 3; cur.push({ type: "triplet", tile: i, open: false, kan: false }); rec(i); cur.pop(); c[i] += 3; } return; } const pos = i % 9; if (c[i] >= 3) { c[i] -= 3; cur.push({ type: "triplet", tile: i, open: false, kan: false }); rec(i); cur.pop(); c[i] += 3; } if (pos <= 6 && c[i] >= 1 && c[i + 1] >= 1 && c[i + 2] >= 1) { c[i]--; c[i + 1]--; c[i + 2]--; cur.push({ type: "sequence", tile: i, open: false }); rec(i); cur.pop(); c[i]++; c[i + 1]++; c[i + 2]++; } } rec(0); return results; } function allHandDecompositions(counts14, meldCount) { const needSets = 4 - meldCount; const decomps = []; for (let p = 0; p < 34; p++) { if (counts14[p] >= 2) { const c = counts14.slice(); c[p] -= 2; const total = c.reduce((a, b) => a + b, 0); if (total !== 3 * needSets) continue; const sub = collectSetDecomps(c, needSets); for (const groups of sub) decomps.push({ pair: p, groups }); } } return decomps; } function meldToGroup(meld) { if (meld.type === "chi") { const sorted = meld.tiles.slice().sort((a, b) => a - b); return { type: "sequence", tile: sorted[0], open: true, kan: false }; } if (meld.type === "pon") return { type: "triplet", tile: meld.tiles[0], open: true, kan: false }; if (meld.type === "minkan") return { type: "triplet", tile: meld.tiles[0], open: true, kan: true }; if (meld.type === "kakan") return { type: "triplet", tile: meld.tiles[0], open: true, kan: true }; if (meld.type === "ankan") return { type: "triplet", tile: meld.tiles[0], open: false, kan: true }; throw new Error("unknown meld type " + meld.type); } function waitShape(group, winNum) { const a = group.tile % 9; const an = a + 1; if (winNum === an + 1) return "kanchan"; if (winNum === an) { if (an === 7) return "penchan"; return "ryanmen"; } if (winNum === an + 2) { if (an === 1) return "penchan"; return "ryanmen"; } return "ryanmen"; } function windTileFor(idx) { return 27 + idx; } const DRAGONS = [31, 32, 33]; function evaluateDecomp(ctx, allGroups, pairTile) { const { winTile, isTsumo, isMenzen, isRiichi, isDoubleRiichi, isIppatsu, isHaitei, isHoutei, isRinshan, isChankan, seatWindIdx, roundWindIdx, doraIndicators, uraDoraIndicators, akaCount, } = ctx; const winNum = tileNum(winTile); let winGroupIdx = -1; for (let i = 0; i < allGroups.length; i++) { const g = allGroups[i]; if (g.type === "triplet") { if (g.tile === winTile) { winGroupIdx = i; break; } } else { const a = g.tile; if ((winTile === a || winTile === a + 1 || winTile === a + 2) && Core.tileSuit(winTile) === Core.tileSuit(a)) { winGroupIdx = i; break; } } } let waitType = "tanki"; if (winGroupIdx >= 0) { const wg = allGroups[winGroupIdx]; waitType = wg.type === "sequence" ? waitShape(wg, winNum) : "shanpon"; } const isAllSequence = allGroups.every((g) => g.type === "sequence"); const yaku = []; let yakumanCount = 0; const pairIsYakuhai = DRAGONS.indexOf(pairTile) !== -1 || pairTile === windTileFor(seatWindIdx) || pairTile === windTileFor(roundWindIdx); const isPinfu = isMenzen && isAllSequence && !pairIsYakuhai && waitType === "ryanmen"; if (isPinfu) yaku.push({ name: "平和", han: 1 }); const noTerminalHonor = allGroups.every((g) => g.type === "sequence" ? (!isTerminalOrHonor(g.tile) && !isTerminalOrHonor(g.tile + 2)) : !isTerminalOrHonor(g.tile)) && !isTerminalOrHonor(pairTile); if (noTerminalHonor) yaku.push({ name: "断幺九", han: 1 }); if (isMenzen) { const seqKeys = allGroups.filter((g) => g.type === "sequence" && !g.open).map((g) => g.tile); const seen = {}; let iipeikou = 0; for (const k of seqKeys) seen[k] = (seen[k] || 0) + 1; for (const k in seen) if (seen[k] >= 2) iipeikou += Math.floor(seen[k] / 2); if (iipeikou > 0) yaku.push({ name: "一盃口", han: iipeikou }); } let yakuhaiHan = 0; for (const g of allGroups) { if (g.type !== "triplet") continue; if (DRAGONS.indexOf(g.tile) !== -1) yakuhaiHan += 1; if (g.tile === windTileFor(seatWindIdx)) yakuhaiHan += 1; if (g.tile === windTileFor(roundWindIdx)) yakuhaiHan += 1; } if (yakuhaiHan > 0) yaku.push({ name: "役牌", han: yakuhaiHan }); { const seqNums = {}; for (const g of allGroups) { if (g.type !== "sequence") continue; const suit = Core.tileSuit(g.tile); const num = g.tile % 9; seqNums[num] = seqNums[num] || new Set(); seqNums[num].add(suit); } let found = false, allConcealed = true; for (const num in seqNums) if (seqNums[num].size === 3) { found = true; for (const g of allGroups) if (g.type === "sequence" && g.tile % 9 === Number(num) && g.open) allConcealed = false; } if (found) yaku.push({ name: "三色同順", han: allConcealed ? 2 : 1 }); } { const bySuit = { m: new Set(), p: new Set(), s: new Set() }; for (const g of allGroups) { if (g.type !== "sequence") continue; const suit = Core.tileSuit(g.tile); if (bySuit[suit]) bySuit[suit].add(g.tile % 9); } let found = false; for (const suit in bySuit) if (bySuit[suit].has(0) && bySuit[suit].has(3) && bySuit[suit].has(6)) found = true; let concealed = true; if (found) { for (const g of allGroups) if (g.type === "sequence" && [0, 3, 6].indexOf(g.tile % 9) !== -1 && g.open) concealed = false; yaku.push({ name: "一気通貫", han: concealed ? 2 : 1 }); } } { const allHaveTermHonor = allGroups.every((g) => g.type === "sequence" ? (isTerminalOrHonor(g.tile) || isTerminalOrHonor(g.tile + 2)) : isTerminalOrHonor(g.tile)) && isTerminalOrHonor(pairTile); if (allHaveTermHonor) { const hasSequence = allGroups.some((g) => g.type === "sequence"); const hasHonorGroup = allGroups.some((g) => g.type === "triplet" && isHonor(g.tile)) || isHonor(pairTile); if (hasSequence) { if (hasHonorGroup) yaku.push({ name: "混全帯幺九", han: isMenzen ? 2 : 1 }); else yaku.push({ name: "純全帯幺九", han: isMenzen ? 3 : 2 }); } } } const allTriplet = allGroups.every((g) => g.type === "triplet"); if (allTriplet) yaku.push({ name: "対々和", han: 2 }); { let ankouCount = 0; for (let i = 0; i < allGroups.length; i++) { const g = allGroups[i]; if (g.type !== "triplet" || g.open) continue; if (i === winGroupIdx && !isTsumo) continue; ankouCount++; } if (ankouCount === 4) { yakumanCount += 1; yaku.push({ name: "四暗刻", han: 13, yakuman: true }); } else if (ankouCount === 3) yaku.push({ name: "三暗刻", han: 2 }); } const allTermHonorTiles = allGroups.every((g) => g.type !== "sequence" && isTerminalOrHonor(g.tile)) && isTerminalOrHonor(pairTile); if (allTermHonorTiles && allTriplet) yaku.push({ name: "混老頭", han: 2 }); if (allTriplet && allGroups.every((g) => isTerminal(g.tile)) && isTerminal(pairTile)) { yakumanCount += 1; yaku.push({ name: "清老頭", han: 13, yakuman: true }); } { const tripNums = {}; for (const g of allGroups) { if (g.type !== "triplet" || isHonor(g.tile)) continue; const num = g.tile % 9; tripNums[num] = tripNums[num] || new Set(); tripNums[num].add(Core.tileSuit(g.tile)); } let found = false; for (const num in tripNums) if (tripNums[num].size === 3) found = true; if (found) yaku.push({ name: "三色同刻", han: 2 }); } { let dragonTriplets = 0; for (const g of allGroups) if (g.type === "triplet" && DRAGONS.indexOf(g.tile) !== -1) dragonTriplets++; const dragonPair = DRAGONS.indexOf(pairTile) !== -1; if (dragonTriplets === 3) { yakumanCount += 1; yaku.push({ name: "大三元", han: 13, yakuman: true }); } else if (dragonTriplets === 2 && dragonPair) yaku.push({ name: "小三元", han: 2 }); } { let windTriplets = 0; for (const g of allGroups) if (g.type === "triplet" && g.tile >= 27 && g.tile <= 30) windTriplets++; const windPair = pairTile >= 27 && pairTile <= 30; if (windTriplets === 4) { yakumanCount += 1; yaku.push({ name: "大四喜", han: 13, yakuman: true }); } else if (windTriplets === 3 && windPair) { yakumanCount += 1; yaku.push({ name: "小四喜", han: 13, yakuman: true }); } } { const suits = new Set(); let hasHonor = false; for (const g of allGroups) { if (g.type === "sequence") suits.add(Core.tileSuit(g.tile)); else if (isHonor(g.tile)) hasHonor = true; else suits.add(Core.tileSuit(g.tile)); } if (isHonor(pairTile)) hasHonor = true; else suits.add(Core.tileSuit(pairTile)); if (suits.size === 1) { if (hasHonor) yaku.push({ name: "混一色", han: isMenzen ? 3 : 2 }); else yaku.push({ name: "清一色", han: isMenzen ? 6 : 5 }); } } if (allGroups.every((g) => isHonor(g.tile)) && isHonor(pairTile)) { yakumanCount += 1; yaku.push({ name: "字一色", han: 13, yakuman: true }); } { const greenOk = allGroups.every((g) => g.type === "sequence" ? (Core.isGreenTile(g.tile) && Core.isGreenTile(g.tile + 1) && Core.isGreenTile(g.tile + 2)) : Core.isGreenTile(g.tile)) && Core.isGreenTile(pairTile); if (greenOk) { yakumanCount += 1; yaku.push({ name: "緑一色", han: 13, yakuman: true }); } } if (ctx.isChuuren) { yakumanCount += 1; yaku.push({ name: "九蓮宝燈", han: 13, yakuman: true }); } if (isDoubleRiichi) yaku.push({ name: "ダブル立直", han: 2 }); else if (isRiichi) yaku.push({ name: "立直", han: 1 }); if (isIppatsu) yaku.push({ name: "一発", han: 1 }); if (isMenzen && isTsumo) yaku.push({ name: "門前清自摸和", han: 1 }); if (isRinshan) yaku.push({ name: "嶺上開花", han: 1 }); if (isChankan) yaku.push({ name: "槍槓", han: 1 }); if (isHaitei && isTsumo) yaku.push({ name: "海底摸月", han: 1 }); if (isHoutei && !isTsumo) yaku.push({ name: "河底撈魚", han: 1 }); if (ctx.isTenhou) { yakumanCount += 1; yaku.push({ name: "天和", han: 13, yakuman: true }); } if (ctx.isChiihou) { yakumanCount += 1; yaku.push({ name: "地和", han: 13, yakuman: true }); } let fu = 20; const fuDetail = ["副底20"]; if (isMenzen && !isTsumo) { fu += 10; fuDetail.push("門前ロン+10"); } for (let i = 0; i < allGroups.length; i++) { const g = allGroups[i]; if (g.type !== "triplet") continue; const term = isTerminalOrHonor(g.tile); let f = g.kan ? (term ? 32 : 16) : (term ? 8 : 4); let open = g.open; if (i === winGroupIdx && !isTsumo && !g.open) open = true; if (open) f = f / 2; fu += f; fuDetail.push((g.kan ? "槓子" : "刻子") + f); } const pairFu = (DRAGONS.indexOf(pairTile) !== -1 ? 2 : 0) + (pairTile === windTileFor(seatWindIdx) ? 2 : 0) + (pairTile === windTileFor(roundWindIdx) ? 2 : 0); if (pairFu > 0) { fu += pairFu; fuDetail.push("雀頭役牌+" + pairFu); } if (waitType === "kanchan" || waitType === "penchan" || waitType === "tanki") { fu += 2; fuDetail.push("待ち+2"); } if (isTsumo && !isPinfu) { fu += 2; fuDetail.push("自摸+2"); } let finalFu = (isPinfu && isTsumo) ? 20 : Math.ceil(fu / 10) * 10; let han = 0; for (const y of yaku) han += y.han; let doraHan = 0; const allTiles14 = allGroups.reduce((arr, g) => { if (g.type === "sequence") arr.push(g.tile, g.tile + 1, g.tile + 2); else { const n = g.kan ? 4 : 3; for (let k = 0; k < n; k++) arr.push(g.tile); } return arr; }, [pairTile, pairTile]); for (const ind of doraIndicators || []) { const dt = nextDoraTile(ind); for (const t of allTiles14) if (t === dt) doraHan++; } if (isRiichi && uraDoraIndicators) for (const ind of uraDoraIndicators) { const dt = nextDoraTile(ind); for (const t of allTiles14) if (t === dt) doraHan++; } doraHan += akaCount || 0; return { yaku, han, yakumanCount, fu: finalFu, fuDetail, doraHan, waitType, totalHan: han + doraHan }; } function nextDoraTile(indicator) { if (indicator >= 27) { if (indicator <= 30) return 27 + ((indicator - 27 + 1) % 4); return 31 + ((indicator - 31 + 1) % 3); } const suitBase = indicator - (indicator % 9); const num = indicator % 9; return suitBase + ((num + 1) % 9); } function checkChuuren(counts, winTile) { for (const base of [0, 9, 18]) { const suitCounts = counts.slice(base, base + 9); const total = suitCounts.reduce((a, b) => a + b, 0); if (total !== 14) continue; const otherSuits = counts.slice(0, base).concat(counts.slice(base + 9)); if (otherSuits.some((c) => c > 0)) continue; const need = [3, 1, 1, 1, 1, 1, 1, 1, 3]; let ok = true; for (let i = 0; i < 9; i++) if (suitCounts[i] < need[i]) { ok = false; break; } if (ok && Core.tileSuit(winTile) === Core.tileSuit(base)) return true; } return false; } function evaluateWin(input) { const { concealedTiles, melds, winTile, isTsumo } = input; const counts = Core.countsFromArray(concealedTiles); const meldGroups = (melds || []).map(meldToGroup); const isMenzen = (melds || []).every((m) => m.type === "ankan"); const results = []; const decomps = allHandDecompositions(counts, (melds || []).length); for (const d of decomps) { const allGroups = d.groups.concat(meldGroups); const ctx = Object.assign({}, input, { isMenzen }); results.push(evaluateDecomp(ctx, allGroups, d.pair)); } if (!melds || melds.length === 0) { if (Core.isChiitoiComplete(counts)) { const ctx = Object.assign({}, input, { isMenzen: true }); const yaku = [{ name: "七対子", han: 2 }]; if (ctx.isDoubleRiichi) yaku.push({ name: "ダブル立直", han: 2 }); else if (ctx.isRiichi) yaku.push({ name: "立直", han: 1 }); if (ctx.isIppatsu) yaku.push({ name: "一発", han: 1 }); if (ctx.isTsumo) yaku.push({ name: "門前清自摸和", han: 1 }); if (ctx.isRinshan) yaku.push({ name: "嶺上開花", han: 1 }); if (ctx.isChankan) yaku.push({ name: "槍槓", han: 1 }); if (ctx.isHaitei && ctx.isTsumo) yaku.push({ name: "海底摸月", han: 1 }); if (ctx.isHoutei && !ctx.isTsumo) yaku.push({ name: "河底撈魚", han: 1 }); if (ctx.isTenhou) yaku.push({ name: "天和", han: 13, yakuman: true }); if (ctx.isChiihou) yaku.push({ name: "地和", han: 13, yakuman: true }); let allSimple = true, suitSet = new Set(), hasHonor = false, allTermHonor = true; for (let t = 0; t < 34; t++) if (counts[t] > 0) { if (isTerminalOrHonor(t)) allSimple = false; else allTermHonor = false; if (isHonor(t)) hasHonor = true; else suitSet.add(Core.tileSuit(t)); } if (allSimple) yaku.push({ name: "断幺九", han: 1 }); if (suitSet.size === 1 && !hasHonor) yaku.push({ name: "清一色", han: 6 }); else if (suitSet.size <= 1 && hasHonor) yaku.push({ name: "混一色", han: 3 }); if (allTermHonor) yaku.push({ name: "混老頭", han: 2 }); let han = 0, yakumanCount = 0; for (const y of yaku) { han += y.han; if (y.yakuman) yakumanCount++; } let doraHan = 0; for (const ind of ctx.doraIndicators || []) { const dt = nextDoraTile(ind); doraHan += counts[dt] || 0; } if (ctx.isRiichi) for (const ind of ctx.uraDoraIndicators || []) { const dt = nextDoraTile(ind); doraHan += counts[dt] || 0; } doraHan += ctx.akaCount || 0; results.push({ yaku, han, yakumanCount, fu: 25, fuDetail: ["七対子固定25符"], doraHan, waitType: "chiitoi", totalHan: han + doraHan }); } if (Core.isKokushiComplete(counts)) results.push({ yaku: [{ name: "国士無双", han: 13, yakuman: true }], han: 13, yakumanCount: 1, fu: 25, fuDetail: ["役満"], doraHan: 0, waitType: "kokushi", totalHan: 13 }); } if ((!melds || melds.length === 0) && checkChuuren(counts, winTile)) { for (const d of decomps) { const allGroups = d.groups.concat(meldGroups); const ctx = Object.assign({}, input, { isMenzen, isChuuren: true }); results.push(evaluateDecomp(ctx, allGroups, d.pair)); } } if (results.length === 0) return null; const valid = results.filter((r) => r.han > 0 || r.yakumanCount > 0); if (valid.length === 0) return null; valid.sort((a, b) => { const av = a.yakumanCount > 0 ? 999999 : scoreRank(a); const bv = b.yakumanCount > 0 ? 999999 : scoreRank(b); return bv - av; }); return valid[0]; } function scoreRank(r) { return r.totalHan * 1000 + r.fu; } function calcScore(result, isDealer) { const han = result.totalHan; const yakumanCount = result.yakumanCount || 0; let basePoints, label = ""; if (yakumanCount > 0) { basePoints = 8000 * yakumanCount; label = yakumanCount > 1 ? `${yakumanCount}倍役満` : "役満"; } else if (han >= 11) { basePoints = 6000; label = "三倍満"; } else if (han >= 8) { basePoints = 4000; label = "倍満"; } else if (han >= 6) { basePoints = 3000; label = "跳満"; } else if (han >= 5) { basePoints = 2000; label = "満貫"; } else { basePoints = result.fu * Math.pow(2, 2 + han); if (basePoints > 2000) { basePoints = 2000; label = "満貫"; } } const ru = (x) => Math.ceil(x / 100) * 100; let payments; if (isDealer) { const each = ru(basePoints * 2); payments = { total: each * 3, detail: [each, each, each] }; } else { const fromDealer = ru(basePoints * 2); const fromOthers = ru(basePoints * 1); payments = { total: fromDealer + fromOthers * 2, detail: [fromDealer, fromOthers, fromOthers] }; } const ronTotal = isDealer ? ru(basePoints * 6) : ru(basePoints * 4); return { basePoints, label, tsumoPayments: payments, ronTotal }; } return { allHandDecompositions, evaluateDecomp, evaluateWin, calcScore, meldToGroup, nextDoraTile, checkChuuren }; })(window.MJCore); /* ===== game.js ===== */ /* ==================== game.js ==================== */ window.MJGame = (function (Core, Yaku) { "use strict"; const RED_IDS = new Set([16, 52, 88]); function tileType(id) { return Math.floor(id / 4); } function isRedId(id) { return RED_IDS.has(id); } function shuffle(arr) { for (let i = arr.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [arr[i], arr[j]] = [arr[j], arr[i]]; } return arr; } function buildWall() { const ids = []; for (let i = 0; i < 136; i++) ids.push(i); shuffle(ids); return ids; } const WIND_NAMES = ["東", "南", "西", "北"]; class MahjongGame { constructor(opts) { this.opts = opts || {}; this.players = [0, 1, 2, 3].map((i) => ({ seat: i, score: 25000, hand: [], melds: [], discards: [], riichi: false, doubleRiichi: false, riichiDiscardIndex: -1, ippatsuEligible: false, isHuman: i === 0, character: null, tempFuriten: false, permFuriten: false })); this.roundWindIdx = 0; this.roundNumber = 1; this.honba = 0; this.riichiSticks = 0; this.dealerSeat = Math.floor(Math.random() * 4); this.gameOver = false; this.log = []; this.uiEvents = []; } seatWindIdx(seat) { return (seat - this.dealerSeat + 4) % 4; } startHand() { this.wall = buildWall(); this.deadWall = this.wall.splice(this.wall.length - 14, 14); this.rinshanUsed = 0; this.doraRevealed = 1; this.kanCount = 0; for (const p of this.players) { p.hand = []; p.melds = []; p.discards = []; p.riichi = false; p.doubleRiichi = false; p.riichiDiscardIndex = -1; p.ippatsuEligible = false; p.tempFuriten = false; p.permFuriten = false; } for (let round = 0; round < 13; round++) for (let s = 0; s < 4; s++) this.players[s].hand.push(this.wall.shift()); for (const p of this.players) p.hand.sort((a, b) => tileType(a) - tileType(b)); this.turn = this.dealerSeat; this.turnCount = 0; this.anyCallHappened = false; this.lastDiscard = null; this.log = []; this.uiEvents = []; this.handOver = false; } doraIndicatorTiles() { const res = []; for (let i = 0; i < this.doraRevealed; i++) res.push(tileType(this.deadWall[4 + i])); return res; } uraDoraIndicatorTiles() { const res = []; for (let i = 0; i < this.doraRevealed; i++) res.push(tileType(this.deadWall[9 + i])); return res; } remainingLiveWall() { return this.wall.length; } drawTile(seat, fromRinshan) { let id; if (fromRinshan) { id = this.deadWall[this.rinshanUsed]; this.rinshanUsed++; } else { id = this.wall.shift(); } this.players[seat].hand.push(id); return id; } handConcealedTypes(seat) { return this.players[seat].hand.map(tileType); } countAka(seat, extraId) { let n = 0; for (const id of this.players[seat].hand) if (isRedId(id)) n++; for (const m of this.players[seat].melds) for (const id of m.idTiles || []) if (isRedId(id)) n++; if (extraId !== undefined && isRedId(extraId)) n++; return n; } tryWin(seat, winTile, isTsumo, situational) { const p = this.players[seat]; const concealedTypes = p.hand.map(tileType); const melds = p.melds.map((m) => ({ type: m.type, tiles: m.tiles })); const ctx = Object.assign({ concealedTiles: concealedTypes, melds, winTile: tileType(winTile), isTsumo, isMenzen: p.melds.every((m) => m.type === "ankan"), isRiichi: p.riichi, isDoubleRiichi: p.doubleRiichi, isIppatsu: !!p.ippatsuEligible, seatWindIdx: this.seatWindIdx(seat), roundWindIdx: this.roundWindIdx, doraIndicators: this.doraIndicatorTiles(), uraDoraIndicators: this.uraDoraIndicatorTiles(), akaCount: this.countAka(seat, isTsumo ? undefined : winTile), }, situational || {}); return Yaku.evaluateWin(ctx); } shantenInfo(seat) { const counts = Core.countsFromArray(this.handConcealedTypes(seat)); const meldCount = this.players[seat].melds.length; const shanten = Core.calcShanten(counts, meldCount); const uke = shanten >= 0 ? Core.ukeire(counts, meldCount) : []; return { shanten, ukeire: uke, counts }; } isTenpai(seat) { return this.shantenInfo(seat).shanten === 0; } waitingTiles(seat) { const info = this.shantenInfo(seat); if (info.shanten !== 0) return []; const waits = []; for (let t = 0; t < 34; t++) { const c2 = info.counts.slice(); if (c2[t] >= 4) continue; c2[t]++; if (Core.isAgari(c2, this.players[seat].melds.length)) waits.push(t); } return waits; } updateFuriten(seat) { const p = this.players[seat]; const waits = this.waitingTiles(seat); if (waits.length === 0) { p.permFuriten = false; return; } const discardedTypes = p.discards.map((d) => tileType(d.tile)); p.permFuriten = waits.some((w) => discardedTypes.indexOf(w) !== -1); } } return { MahjongGame, tileType, isRedId, buildWall, WIND_NAMES, shuffle }; })(window.MJCore, window.MJYaku); /* ===== cpu.js ===== */ /* ==================== cpu.js ==================== */ window.MJCpu = (function (Core) { "use strict"; const IMG_BASE = "https://attachstyle.com/wp-content/uploads/2026/09/"; const CHARACTERS = [ { id: "kon", name: "コン", animal: "狐", emoji: "🦊", portrait: IMG_BASE + "Mahjong_CPU01.jpeg", winPortrait: IMG_BASE + "Mahjong_CPU01-2.jpeg", pos: "58% 32%", catch: "攻撃的な鳴き師。速度最優先で愚形でも鳴いて聴牌を急ぐ。", aggression: .85, defense: .25, callThreshold: .25, riichiThreshold: .35, bigHandChase: .15, randomness: .1 }, { id: "mai", name: "マイ", animal: "鶴", emoji: "🕊️", portrait: IMG_BASE + "Mahjong_CPU02.jpeg", winPortrait: IMG_BASE + "Mahjong_CPU02-2.jpeg", pos: "62% 18%", catch: "堅実な面前派。鳴かずにじっくり高い手を育てる。", aggression: .35, defense: .6, callThreshold: .85, riichiThreshold: .75, bigHandChase: .65, randomness: .05 }, { id: "gou", name: "ゴウ", animal: "虎", emoji: "🐯", portrait: IMG_BASE + "Mahjong_CPU03.jpeg", winPortrait: IMG_BASE + "Mahjong_CPU03-2.jpeg", pos: "55% 20%", catch: "リーチ全ツッパ型。聴牌したら即リーチ、押し一択。", aggression: .9, defense: .05, callThreshold: .5, riichiThreshold: .05, bigHandChase: .3, randomness: .1 }, { id: "pyon", name: "ピョン", animal: "兎", emoji: "🐰", portrait: IMG_BASE + "Mahjong_CPU04.jpeg", winPortrait: IMG_BASE + "Mahjong_CPU04-2.jpeg", pos: "52% 32%", catch: "超守備的で慌てやすい。危険な気配を感じたらすぐベタオリ。", aggression: .2, defense: .95, callThreshold: .7, riichiThreshold: .6, bigHandChase: .1, randomness: .1 }, { id: "fuku", name: "フク", animal: "梟", emoji: "🦉", portrait: IMG_BASE + "Mahjong_CPU05.jpeg", winPortrait: IMG_BASE + "Mahjong_CPU05-2.jpeg", pos: "58% 34%", catch: "知略型。場況を見て攻守を柔軟に切り替える。", aggression: .55, defense: .55, callThreshold: .5, riichiThreshold: .45, bigHandChase: .4, randomness: .1 }, { id: "ran", name: "ラン", animal: "猫", emoji: "🐱", portrait: IMG_BASE + "Mahjong_CPU06.jpeg", winPortrait: IMG_BASE + "Mahjong_CPU06-2.jpeg", pos: "56% 32%", catch: "気まぐれ型。同じ場面でも対局ごとに判断がブレる。", aggression: .5, defense: .5, callThreshold: .5, riichiThreshold: .5, bigHandChase: .3, randomness: .4 }, ]; const PLAYER_AVATARS = { male: { label: "男性", portrait: IMG_BASE + "Mahjong_USER01.jpeg", winPortrait: IMG_BASE + "Mahjong_USER01-2.jpeg", pos: "55% 22%" }, female: { label: "女性", portrait: IMG_BASE + "Mahjong_USER02.jpeg", winPortrait: IMG_BASE + "Mahjong_USER02-2.jpeg", pos: "50% 20%" }, }; function pickThree() { const pool = CHARACTERS.slice(); for (let i = pool.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [pool[i], pool[j]] = [pool[j], pool[i]]; } return pool.slice(0, 3); } function jitter(persona, base) { const spread = 0.15 + persona.randomness * 0.5; return Math.max(0, Math.min(1, base + (Math.random() * 2 - 1) * spread)); } function effectiveUkeireCount(counts, meldCount, visibleCounts) { const shanten = Core.calcShanten(counts, meldCount); let total = 0; for (let t = 0; t < 34; t++) { if (counts[t] >= 4) continue; const c2 = counts.slice(); c2[t]++; if (Core.calcShanten(c2, meldCount) < shanten) { const remain = 4 - counts[t] - (visibleCounts[t] || 0); total += Math.max(0, remain); } } return total; } function safeSetAgainst(game, targetSeat) { const p = game.players[targetSeat]; const safe = new Set(p.discards.map((d) => tileTypeOf(d.tile))); if (p.riichi) { for (const other of game.players) { if (other.seat === targetSeat) continue; for (let i = 0; i < other.discards.length; i++) if (other.discards[i].turnMark >= (p.riichiTurnMark || 0)) safe.add(tileTypeOf(other.discards[i].tile)); } } return safe; } function tileTypeOf(id) { return Math.floor(id / 4); } function anyoneRiichi(game, exceptSeat) { return game.players.some((p) => p.seat !== exceptSeat && p.riichi); } function chooseDiscard(game, seat, persona) { const p = game.players[seat]; const handIds = p.hand; const meldCount = p.melds.length; const baseCounts = Core.countsFromArray(handIds.map(tileTypeOf)); const dangerActive = anyoneRiichi(game, seat) && Core.calcShanten(baseCounts, meldCount) > 0; let safeUnion = new Set(); if (dangerActive) for (const other of game.players) if (other.seat !== seat && other.riichi) for (const t of safeSetAgainst(game, other.seat)) safeUnion.add(t); const wantDefense = dangerActive && jitter(persona, persona.defense) > 0.45; const uniqueTypes = Array.from(new Set(handIds.map(tileTypeOf))); let candidates = uniqueTypes.map((t) => { const c2 = baseCounts.slice(); c2[t]--; const shanten = Core.calcShanten(c2, meldCount); const ukeire = shanten >= 0 ? effectiveUkeireCount(c2, meldCount, Core.newCounts()) : 0; return { type: t, shanten, ukeire, isSafe: safeUnion.has(t) }; }); if (wantDefense) { const safeCands = candidates.filter((c) => c.isSafe); if (safeCands.length > 0) { const pick = safeCands[Math.floor(Math.random() * safeCands.length)]; return pickIdOfType(handIds, pick.type); } } candidates.sort((a, b) => a.shanten !== b.shanten ? a.shanten - b.shanten : b.ukeire - a.ukeire); const bestShanten = candidates[0].shanten; let pool = candidates.filter((c) => c.shanten === bestShanten); const topUke = pool[0] ? pool.reduce((m, c) => Math.max(m, c.ukeire), 0) : 0; pool = pool.filter((c) => c.ukeire >= topUke - Math.floor(persona.randomness * 3)); const pick = pool[Math.floor(Math.random() * pool.length)] || candidates[0]; return pickIdOfType(handIds, pick.type); } function pickIdOfType(handIds, type) { for (const id of handIds) if (tileTypeOf(id) === type) return id; return handIds[0]; } function decideRiichi(game, seat, persona) { const roll = jitter(persona, 1 - persona.riichiThreshold); return roll > 0.4; } function decideCall(game, seat, persona, kind, resultingShantenDelta) { if (persona.callThreshold >= 0.8) return false; const willingness = jitter(persona, 1 - persona.callThreshold); if (resultingShantenDelta > 0) return willingness > 0.3; return willingness > 0.7; } function decidePushFold(game, seat, persona) { return jitter(persona, persona.aggression) > 0.35; } return { CHARACTERS, PLAYER_AVATARS, pickThree, chooseDiscard, decideRiichi, decideCall, decidePushFold, tileTypeOf }; })(window.MJCore); /* ===== controller.js ===== */ /* ==================== controller.js ==================== */ window.MJController = (function (Core, Yaku, G, Cpu) { "use strict"; const tileTypeOf = G.tileType; function removeOneOfType(handIds, type) { for (let i = 0; i < handIds.length; i++) if (tileTypeOf(handIds[i]) === type) return handIds.splice(i, 1)[0]; return null; } function canChi(counts, discardType) { if (discardType >= 27) return []; const pos = discardType % 9; const opts = []; if (pos <= 6 && counts[discardType + 1] > 0 && counts[discardType + 2] > 0) opts.push([discardType, discardType + 1, discardType + 2]); if (pos >= 1 && pos <= 7 && counts[discardType - 1] > 0 && counts[discardType + 1] > 0) opts.push([discardType - 1, discardType, discardType + 1]); if (pos >= 2 && counts[discardType - 2] > 0 && counts[discardType - 1] > 0) opts.push([discardType - 2, discardType - 1, discardType]); return opts; } function* playHand(game, personas) { game.startHand(); for (let s = 0; s < 4; s++) game.players[s].character = personas[s] || null; let ippatsuChance = [false, false, false, false]; let hasDrawnOnce = [false, false, false, false]; let turnMark = 0; game.turn = game.dealerSeat; let handResult = null; outer: while (true) { const seat = game.turn; const p = game.players[seat]; if (game.remainingLiveWall() <= 0) { handResult = resolveExhaustiveDraw(game); break; } const drawnId = game.drawTile(seat, false); const isFirstDrawEver = !hasDrawnOnce[seat] && !game.anyCallHappened; hasDrawnOnce[seat] = true; turnMark++; const tsumoResult = game.tryWin(seat, drawnId, true, { isIppatsu: ippatsuChance[seat], isHaitei: game.remainingLiveWall() === 0, isTenhou: isFirstDrawEver && seat === game.dealerSeat, isChiihou: isFirstDrawEver && seat !== game.dealerSeat }); let doTsumo = false; if (tsumoResult) { if (p.character) doTsumo = true; else { const resp = yield { phase: "tsumo-choice", seat, tile: drawnId, result: tsumoResult }; doTsumo = !!(resp && resp.action === "tsumo"); } } if (doTsumo) { yield { phase: "action-log", seat, kind: "tsumo" }; handResult = finishWin(game, [{ seat, winTile: drawnId, isTsumo: true, result: tsumoResult }], null); break; } ippatsuChance[seat] = false; const kyuushuEligible = isFirstDrawEver && p.melds.length === 0 && countYaochuuKinds(p.hand) >= 9; if (kyuushuEligible) { let doAbort = false; if (p.character) doAbort = true; else { const resp = yield { phase: "kyuushu-choice", seat }; doAbort = !!(resp && resp.action === "kyuushu"); } if (doAbort) { handResult = { abortive: true, reason: "九種九牌", dealerSeat: game.dealerSeat, dealerContinues: true }; break; } } const canRiichi = p.melds.every((m) => m.type === "ankan") && !p.riichi && p.score >= 1000 && game.remainingLiveWall() >= 4 && game.shantenInfo(seat).shanten === 0; let discardId, declareRiichi = false; if (p.character) { if (canRiichi && Cpu.decideRiichi(game, seat, p.character)) declareRiichi = true; discardId = Cpu.chooseDiscard(game, seat, p.character); } else if (p.riichi) { // リーチ後は手牌が固定されるため、選択肢を出さずツモった牌をそのまま切る(ツモ切り) discardId = drawnId; } else { const resp = yield { phase: "discard-choice", seat, tile: drawnId, canRiichi }; discardId = (resp && resp.tile != null) ? resp.tile : drawnId; declareRiichi = !!(resp && resp.declareRiichi && canRiichi); } const isPlayersFirstDiscard = p.discards.length === 0; removeOneOfType(p.hand, tileTypeOf(discardId)); p.hand.sort((a, b) => tileTypeOf(a) - tileTypeOf(b)); if (declareRiichi) { p.riichi = true; p.doubleRiichi = isPlayersFirstDiscard && !game.anyCallHappened && p.melds.length === 0; p.score -= 1000; game.riichiSticks += 1; p.riichiTurnMark = turnMark; ippatsuChance[seat] = true; } p.discards.push({ tile: discardId, turnMark, riichiTile: declareRiichi }); game.lastDiscard = { seat, tile: discardId }; if (declareRiichi) { game.uiEvents.push({ type: "riichi", seat }); yield { phase: "action-log", seat, kind: "riichi" }; } game.uiEvents.push({ type: "discard", seat, tile: discardId }); if (p.character && !declareRiichi) { yield { phase: "action-log", seat, kind: "discard" }; } for (const pl of game.players) game.updateFuriten(pl.seat); const ronSeats = []; for (let d = 1; d <= 3; d++) { const other = (seat + d) % 4; const op = game.players[other]; if (op.permFuriten || op.tempFuriten) continue; const r = game.tryWin(other, discardId, false, { isIppatsu: ippatsuChance[other], isHoutei: game.remainingLiveWall() === 0 }); if (r) { let accept = true; if (!op.character) { const resp = yield { phase: "ron-choice", seat: other, tile: discardId, result: r }; accept = !!(resp && resp.action === "ron"); } if (accept) ronSeats.push({ seat: other, winTile: discardId, isTsumo: false, result: r }); else op.tempFuriten = true; } } if (ronSeats.length === 3) { handResult = { abortive: true, reason: "三家和", dealerSeat: game.dealerSeat, dealerContinues: game.dealerSeat === seat }; break; } if (ronSeats.length > 0) { for (const rs of ronSeats) game.players[rs.seat].tempFuriten = false; for (const rs of ronSeats) yield { phase: "action-log", seat: rs.seat, kind: "ron" }; handResult = finishWin(game, ronSeats, seat); break; } let callDone = false; for (let d = 1; d <= 3 && !callDone; d++) { const other = (seat + d) % 4; const op = game.players[other]; const cnt = op.hand.filter((id) => tileTypeOf(id) === tileTypeOf(discardId)).length; if (cnt >= 2 && !op.riichi) { const beforeShanten = game.shantenInfo(other).shanten; const simHand = op.hand.slice(); removeOneOfType(simHand, tileTypeOf(discardId)); removeOneOfType(simHand, tileTypeOf(discardId)); const afterCounts = Core.countsFromArray(simHand.map(tileTypeOf)); const afterShanten = Core.calcShanten(afterCounts, op.melds.length + 1); let accept = false; if (op.character) accept = Cpu.decideCall(game, other, op.character, "pon", beforeShanten - afterShanten); else { const resp = yield { phase: "pon-choice", seat: other, tile: discardId }; accept = !!(resp && resp.action === "pon"); } if (accept) { removeOneOfType(op.hand, tileTypeOf(discardId)); removeOneOfType(op.hand, tileTypeOf(discardId)); op.melds.push({ type: "pon", tiles: [tileTypeOf(discardId), tileTypeOf(discardId), tileTypeOf(discardId)], idTiles: [discardId], fromSeat: seat }); game.uiEvents.push({ type: "meld", seat: other, meldType: "pon" }); yield { phase: "action-log", seat: other, kind: "pon" }; game.anyCallHappened = true; ippatsuChance = [false, false, false, false]; game.turn = other; callDone = true; const postDiscard = yield* handlePostCallDiscard(game, other, turnMark); game.lastDiscard = { seat: other, tile: postDiscard }; for (const pl of game.players) game.updateFuriten(pl.seat); const ronSeats2 = yield* checkRonAfter(game, other, postDiscard, ippatsuChance); if (ronSeats2 && ronSeats2.length) { handResult = finishWin(game, ronSeats2, other); break outer; } } } } if (callDone) { game.turn = (game.turn + 1) % 4; game.turnCount++; continue; } { const other = (seat + 1) % 4; const op = game.players[other]; const counts = Core.countsFromArray(op.hand.map(tileTypeOf)); const opts = canChi(counts, tileTypeOf(discardId)); if (opts.length > 0 && !op.riichi) { const beforeShanten = game.shantenInfo(other).shanten; const chosen = opts[0]; const simHand = op.hand.slice(); for (const t of chosen) if (t !== tileTypeOf(discardId)) removeOneOfType(simHand, t); const afterCounts = Core.countsFromArray(simHand.map(tileTypeOf)); const afterShanten = Core.calcShanten(afterCounts, op.melds.length + 1); let accept = false; if (op.character) accept = Cpu.decideCall(game, other, op.character, "chi", beforeShanten - afterShanten); else { const resp = yield { phase: "chi-choice", seat: other, tile: discardId, options: opts }; accept = !!(resp && resp.action === "chi"); } if (accept) { for (const t of chosen) if (t !== tileTypeOf(discardId)) removeOneOfType(op.hand, t); op.melds.push({ type: "chi", tiles: chosen, idTiles: [discardId], fromSeat: seat }); game.uiEvents.push({ type: "meld", seat: other, meldType: "chi" }); yield { phase: "action-log", seat: other, kind: "chi" }; game.anyCallHappened = true; ippatsuChance = [false, false, false, false]; game.turn = other; const postDiscard = yield* handlePostCallDiscard(game, other, turnMark); game.lastDiscard = { seat: other, tile: postDiscard }; for (const pl of game.players) game.updateFuriten(pl.seat); const ronSeats2 = yield* checkRonAfter(game, other, postDiscard, ippatsuChance); if (ronSeats2 && ronSeats2.length) { handResult = finishWin(game, ronSeats2, other); break; } game.turn = (other + 1) % 4; game.turnCount++; continue outer; } } } game.turn = (seat + 1) % 4; game.turnCount++; } return applyHandResult(game, handResult); } function* handlePostCallDiscard(game, seat, turnMark) { const p = game.players[seat]; let discardId; if (p.character) discardId = Cpu.chooseDiscard(game, seat, p.character); else { const resp = yield { phase: "discard-choice", seat, tile: null, canRiichi: false }; discardId = (resp && resp.tile != null) ? resp.tile : p.hand[0]; } removeOneOfType(p.hand, tileTypeOf(discardId)); p.hand.sort((a, b) => tileTypeOf(a) - tileTypeOf(b)); p.discards.push({ tile: discardId, turnMark, riichiTile: false }); game.uiEvents.push({ type: "discard", seat, tile: discardId }); game.lastDiscard = { seat, tile: discardId }; if (p.character) { yield { phase: "action-log", seat, kind: "discard" }; } return discardId; } function* checkRonAfter(game, seat, discardId, ippatsuChance) { const ronSeats = []; for (let d = 1; d <= 3; d++) { const other = (seat + d) % 4; const op = game.players[other]; if (op.permFuriten || op.tempFuriten) continue; const r = game.tryWin(other, discardId, false, { isIppatsu: ippatsuChance[other], isHoutei: game.remainingLiveWall() === 0 }); if (r) { let accept = true; if (!op.character) { const resp = yield { phase: "ron-choice", seat: other, tile: discardId, result: r }; accept = !!(resp && resp.action === "ron"); } if (accept) { ronSeats.push({ seat: other, winTile: discardId, isTsumo: false, result: r }); yield { phase: "action-log", seat: other, kind: "ron" }; } else op.tempFuriten = true; } } return ronSeats; } function countYaochuuKinds(handIds) { const seen = new Set(); for (const id of handIds) { const t = tileTypeOf(id); if (Core.YAOCHUU.indexOf(t) !== -1) seen.add(t); } return seen.size; } function finishWin(game, winners, discarderSeat) { return { winners, discarderSeat, abortive: false }; } function resolveExhaustiveDraw(game) { const tenpaiSeats = []; for (const p of game.players) if (game.isTenpai(p.seat)) tenpaiSeats.push(p.seat); return { exhaustive: true, tenpaiSeats, dealerContinues: tenpaiSeats.indexOf(game.dealerSeat) !== -1 }; } function applyHandResult(game, handResult) { const dealerBefore = game.dealerSeat; const beforeScores = game.players.map((p) => p.score); const summary = { players: game.players.map((p) => ({ seat: p.seat, score: p.score })), events: [] }; const finalizeSummary = (reasons) => { summary.dealerBefore = dealerBefore; summary.dealerAfter = game.dealerSeat; summary.dealerChanged = dealerBefore !== game.dealerSeat; summary.scoreChanges = game.players.map((p, i) => ({ seat: p.seat, before: beforeScores[i], after: p.score, delta: p.score - beforeScores[i], reason: (reasons && reasons[p.seat]) || "" })); }; if (handResult.abortive) { summary.type = "abortive"; summary.reason = handResult.reason; if (handResult.dealerContinues) game.honba++; else game.honba = 0; advanceDealer(game, handResult.dealerContinues); const reasons = {}; for (const p of game.players) reasons[p.seat] = `途中流局(${handResult.reason})`; finalizeSummary(reasons); return summary; } if (handResult.exhaustive) { summary.type = "exhaustive"; summary.tenpaiSeats = handResult.tenpaiSeats; const T = handResult.tenpaiSeats.length; const table = { 0: [0, 0], 1: [3000, 1000], 2: [1500, 1500], 3: [1000, 3000], 4: [0, 0] }; const [gain, lose] = table[T]; const reasons = {}; for (const p of game.players) { const isTenpai = handResult.tenpaiSeats.indexOf(p.seat) !== -1; if (isTenpai) p.score += gain; else p.score -= lose; reasons[p.seat] = (T === 0 || T === 4) ? `流局(全員${T === 4 ? "聴牌" : "不聴"})` : (isTenpai ? "聴牌料" : "不聴罰符"); } game.honba++; advanceDealer(game, handResult.dealerContinues); summary.players = game.players.map((p) => ({ seat: p.seat, score: p.score })); finalizeSummary(reasons); return summary; } summary.type = "win"; summary.winners = []; const isDealerWin = handResult.winners.some((w) => w.seat === game.dealerSeat); const reasons = {}; if (handResult.discarderSeat === null) { const w = handResult.winners[0]; const score = Yaku.calcScore(w.result, w.seat === game.dealerSeat); const honbaEach = game.honba * 100; const riichiPicked = game.riichiSticks * 1000; if (w.seat === game.dealerSeat) { const each = score.tsumoPayments.detail[0] + honbaEach; for (const p of game.players) if (p.seat !== w.seat) { p.score -= each; reasons[p.seat] = "ツモ支払い"; } game.players[w.seat].score += each * 3; } else { const dealerSeat = game.dealerSeat; for (let i = 0; i < 4; i++) { if (i === w.seat) continue; const amt = (i === dealerSeat ? score.tsumoPayments.detail[0] : score.tsumoPayments.detail[1]) + honbaEach; game.players[i].score -= amt; game.players[w.seat].score += amt; reasons[i] = "ツモ支払い"; } } game.players[w.seat].score += riichiPicked; game.riichiSticks = 0; let winLabel = `ツモ(基本${score.tsumoPayments.total}点`; if (honbaEach > 0) winLabel += `+本場${honbaEach * 3}点`; if (riichiPicked > 0) winLabel += `+供託${riichiPicked}点`; winLabel += ")"; reasons[w.seat] = winLabel; summary.winners.push({ seat: w.seat, han: w.result.totalHan, fu: w.result.fu, yaku: w.result.yaku, label: score.label, hand: game.players[w.seat].hand.slice(), melds: game.players[w.seat].melds.map((m) => ({ type: m.type, tiles: m.tiles.slice() })), winTile: w.winTile, isTsumo: w.isTsumo }); } else { const honbaTotal = game.honba * 300; const riichiPicked = game.riichiSticks * 1000; for (const w of handResult.winners) { const score = Yaku.calcScore(w.result, w.seat === game.dealerSeat); const total = score.ronTotal + honbaTotal; game.players[handResult.discarderSeat].score -= total; game.players[w.seat].score += total; let winLabel = `ロン(基本${score.ronTotal}点`; if (honbaTotal > 0) winLabel += `+本場${honbaTotal}点`; if (w.seat === handResult.winners[0].seat && riichiPicked > 0) winLabel += `+供託${riichiPicked}点`; winLabel += ")"; reasons[w.seat] = winLabel; summary.winners.push({ seat: w.seat, han: w.result.totalHan, fu: w.result.fu, yaku: w.result.yaku, label: score.label, hand: game.players[w.seat].hand.slice(), melds: game.players[w.seat].melds.map((m) => ({ type: m.type, tiles: m.tiles.slice() })), winTile: w.winTile, isTsumo: w.isTsumo }); } reasons[handResult.discarderSeat] = handResult.winners.length > 1 ? "放銃(ダブロン)" : "放銃(ロン)"; game.players[handResult.winners[0].seat].score += riichiPicked; game.riichiSticks = 0; } if (isDealerWin) game.honba++; else game.honba = 0; advanceDealer(game, isDealerWin); summary.players = game.players.map((p) => ({ seat: p.seat, score: p.score })); finalizeSummary(reasons); return summary; } function advanceDealer(game, dealerContinues) { if (dealerContinues) return; game.dealerSeat = (game.dealerSeat + 1) % 4; game.roundNumber++; } function isGameOver(game) { if (game.players.some((p) => p.score < 0)) return true; if (game.players.some((p) => p.score === 0)) return true; if (game.roundNumber > 4) return true; return false; } return { playHand, isGameOver, canChi }; })(window.MJCore, window.MJYaku, window.MJGame, window.MJCpu); /* ===== UI(画面表示) ===== */ /* ==================== UI ==================== */ (function () { "use strict"; const Core = window.MJCore, Yaku = window.MJYaku, G = window.MJGame, Cpu = window.MJCpu, Ctl = window.MJController; const tileTypeOf = G.tileType, isRedId = G.isRedId; function tileGlyph(type) { if (type < 9) return String.fromCodePoint(0x1F007 + type); if (type < 18) return String.fromCodePoint(0x1F019 + (type - 9)); if (type < 27) return String.fromCodePoint(0x1F010 + (type - 18)); const map = { 27: 0x1F000, 28: 0x1F001, 29: 0x1F002, 30: 0x1F003, 31: 0x1F006, 32: 0x1F005, 33: 0x1F004 }; return String.fromCodePoint(map[type]); } /* ---------- 牌イメージ(ユーザー提供のカスタム牌画像) ---------- */ const TILE_IMG_BASE = "https://attachstyle.com/wp-content/uploads/2026/09/"; const TILE_IMG_NAMES = (function () { const arr = []; for (let i = 1; i <= 9; i++) arr.push("mjp_m" + i); for (let i = 1; i <= 9; i++) arr.push("mjp_p" + i); for (let i = 1; i <= 9; i++) arr.push("mjp_s" + i); arr.push("mjp_ton", "mjp_nan", "mjp_sya", "mjp_pei", "mjp_haku", "mjp_hatsu", "mjp_chun"); return arr; })(); function tileImageURL(type) { return TILE_IMG_BASE + TILE_IMG_NAMES[type] + ".png"; } const SEAT_LABELS = ["自分(東家基準:座席0)", "座席1", "座席2", "座席3"]; let game, gen, pendingPhase = null, riichiArmed = false, chosenChars = [], gameEnded = false; let modalOutsideClosable = false; // 局終了・最終結果のモーダルは進行に必須のボタンがあるため外側クリックでは閉じない。戦績画面のみ許可する let highlightSeat = null, newestDiscardSeat = null; let humanAvatarChoice = "male"; /* ---------- 戦績記録(localStorage) ---------- */ const RECORDS_KEY = "mahjong4_records_v1"; let records = null; function defaultRecords() { const chars = {}; for (const c of Cpu.CHARACTERS) chars[c.id] = { games: 0, wins: 0, agariCount: 0 }; return { version: 1, human: { gamesPlayed: 0, rankCounts: [0, 0, 0, 0], agariCount: 0 }, avatars: { male: { games: 0, wins: 0 }, female: { games: 0, wins: 0 } }, chars, }; } function loadRecords() { if (records) return records; const def = defaultRecords(); try { const raw = localStorage.getItem(RECORDS_KEY); if (raw) { const parsed = JSON.parse(raw); records = def; if (parsed && parsed.human) Object.assign(records.human, parsed.human); if (parsed && parsed.avatars) { for (const k in parsed.avatars) if (records.avatars[k]) Object.assign(records.avatars[k], parsed.avatars[k]); } if (parsed && parsed.chars) { for (const k in parsed.chars) if (records.chars[k]) Object.assign(records.chars[k], parsed.chars[k]); } } else { records = def; } } catch (e) { records = def; } return records; } function saveRecords() { try { localStorage.setItem(RECORDS_KEY, JSON.stringify(records)); } catch (e) { /* localStorageが使えない環境では保存をあきらめ、ゲーム自体は継続する */ } } function resetRecords() { records = defaultRecords(); saveRecords(); } function recordGameResult(ranked) { const rec = loadRecords(); const humanRank = ranked.findIndex((p) => p.seat === 0) + 1; rec.human.gamesPlayed++; if (humanRank >= 1 && humanRank <= 4) rec.human.rankCounts[humanRank - 1]++; const av = rec.avatars[humanAvatarChoice] || (rec.avatars[humanAvatarChoice] = { games: 0, wins: 0 }); av.games++; if (humanRank === 1) av.wins++; for (let seat = 1; seat <= 3; seat++) { const ch = game.players[seat].character; if (!ch || !rec.chars[ch.id]) continue; const rank = ranked.findIndex((p) => p.seat === seat) + 1; rec.chars[ch.id].games++; if (rank === 1) rec.chars[ch.id].wins++; } saveRecords(); } function el(tag, cls, html) { const e = document.createElement(tag); if (cls) e.className = cls; if (html !== undefined) e.innerHTML = html; return e; } function avatarHTML(portrait, pos, emoji) { if (!portrait) return `${emoji}`; const posStyle = pos ? ` style="object-position:${pos}"` : ""; return `${emoji}`; } function tileEl(type, opts) { opts = opts || {}; const d = el("div", "tile" + (opts.red ? " red" : "") + (opts.clickable ? " clickable" : "") + (opts.drawn ? " drawn" : "") + (opts.isNew ? " tile-new" : "")); d.innerHTML = `${tileGlyph(type)}`; if (opts.onclick) d.addEventListener("click", opts.onclick); return d; } /* ---------- イントロ画面 ---------- */ function renderIntro() { const pool = document.getElementById("mj-charPool"); pool.innerHTML = ""; for (const c of Cpu.CHARACTERS) { const card = el("div", "char-card"); card.innerHTML = `${avatarHTML(c.portrait, c.pos, c.emoji)}
${c.name}(${c.animal})
${c.catch}
`; pool.appendChild(card); } renderAvatarPicker(); } function renderAvatarPicker() { const wrap = document.getElementById("mj-avatarPick"); wrap.innerHTML = ""; for (const key of ["male", "female"]) { const av = Cpu.PLAYER_AVATARS[key]; const choice = el("div", "avatar-choice" + (humanAvatarChoice === key ? " selected" : "")); choice.innerHTML = `${avatarHTML(av.portrait, av.pos, "🧑")}${av.label}`; choice.addEventListener("click", () => { humanAvatarChoice = key; renderAvatarPicker(); }); wrap.appendChild(choice); } } function bindStartButton() { document.getElementById("mj-startBtn").addEventListener("click", () => { chosenChars = Cpu.pickThree(); game = new G.MahjongGame(); gameEnded = false; document.getElementById("mj-screen-intro").hidden = true; document.getElementById("mj-screen-game").hidden = false; beginNextHand(); }); } function beginNextHand() { if (Ctl.isGameOver(game)) { showFinalResult(); return; } riichiArmed = false; gen = Ctl.playHand(game, [null, chosenChars[0], chosenChars[1], chosenChars[2]]); step(gen.next()); } function respond(obj) { step(gen.next(obj)); } const ACTION_POPUP_MS = window.MJ_TEST_FAST ? 30 : 900; const DISCARD_PAUSE_MS = window.MJ_TEST_FAST ? 20 : 550; const ACTION_LABELS = { chi: "チー", pon: "ポン", riichi: "リーチ!", tsumo: "ツモ!", ron: "ロン!" }; function showActionPopup(seat, kind) { const cl = charLabel(seat); const pop = document.getElementById("mj-action-popup"); pop.innerHTML = `${avatarHTML(cl.portrait, cl.pos, cl.emoji)}
${cl.name}
${ACTION_LABELS[kind] || kind}
`; pop.hidden = false; } function hideActionPopup() { document.getElementById("mj-action-popup").hidden = true; } function step(res) { if (res.done) { onHandEnd(res.value); return; } const val = res.value; if (val && val.phase === "action-log") { pendingPhase = null; highlightSeat = val.seat; newestDiscardSeat = val.kind === "discard" ? val.seat : null; render(); if (val.kind === "discard") { setTimeout(() => { step(gen.next()); }, DISCARD_PAUSE_MS); } else { showActionPopup(val.seat, val.kind); setTimeout(() => { hideActionPopup(); step(gen.next()); }, ACTION_POPUP_MS); } return; } highlightSeat = null; newestDiscardSeat = null; pendingPhase = val; render(); } /* ---------- メイン描画 ---------- */ function render() { document.getElementById("mj-roundLabel").textContent = `東${game.roundNumber}局 ${game.honba}本場`; document.getElementById("mj-stickCount").textContent = game.riichiSticks; document.getElementById("mj-wallCount").textContent = game.remainingLiveWall(); const doraWrap = document.getElementById("mj-doraTiles"); doraWrap.innerHTML = ""; for (const t of game.doraIndicatorTiles()) doraWrap.appendChild(tileEl(t)); renderSeat(0, "mj-seat0", true); renderSeat(1, "mj-seat1", false); renderSeat(2, "mj-seat2", false); renderSeat(3, "mj-seat3", false); renderActionBar(); } function charLabel(seat) { if (seat === 0) { const av = Cpu.PLAYER_AVATARS[humanAvatarChoice] || Cpu.PLAYER_AVATARS.male; return { name: "あなた", emoji: "🧑", portrait: av.portrait, winPortrait: av.winPortrait, pos: av.pos }; } const c = game.players[seat].character; return c ? { name: c.name, emoji: c.emoji, portrait: c.portrait, winPortrait: c.winPortrait, pos: c.pos } : { name: "?", emoji: "❔", portrait: null, winPortrait: null, pos: null }; } function renderSeat(seat, domId, isSelf) { const p = game.players[seat]; const wrap = document.getElementById(domId); wrap.innerHTML = ""; wrap.classList.toggle("seat-active", seat === highlightSeat); const head = el("div", "seat-head"); const cl = charLabel(seat); let headHtml = `${avatarHTML(cl.portrait, cl.pos, cl.emoji)}${cl.name}`; if (game.dealerSeat === seat) headHtml += ``; if (p.riichi) headHtml += `リーチ`; headHtml += `${p.score}点`; head.innerHTML = headHtml; wrap.appendChild(head); const melds = el("div", "meld-row"); for (const m of p.melds) { const g = el("div", "meld-group"); for (const t of m.tiles) g.appendChild(tileEl(t)); melds.appendChild(g); } wrap.appendChild(melds); const discs = el("div", "discard-grid"); p.discards.forEach((d, i) => { const isNewest = seat === newestDiscardSeat && i === p.discards.length - 1; discs.appendChild(tileEl(tileTypeOf(d.tile), { red: isRedId(d.tile), isNew: isNewest })); }); wrap.appendChild(discs); const hand = el("div", "hand-row"); if (isSelf) { const isMyDiscardPhase = pendingPhase && pendingPhase.seat === 0 && pendingPhase.phase === "discard-choice"; for (const id of p.hand) { const clickable = isMyDiscardPhase; hand.appendChild(tileEl(tileTypeOf(id), { red: isRedId(id), clickable, drawn: pendingPhase && pendingPhase.tile === id, onclick: clickable ? () => { respond({ tile: id, declareRiichi: riichiArmed }); riichiArmed = false; } : null, })); } } else { for (let i = 0; i < p.hand.length; i++) hand.appendChild(el("div", "tileback")); } wrap.appendChild(hand); } /* ---------- アクションバー ---------- */ function renderActionBar() { const bar = document.getElementById("mj-actionBar"); bar.innerHTML = ""; if (!pendingPhase || pendingPhase.seat !== 0) { bar.appendChild(el("span", "hint", "CPUの手番です…")); return; } const ph = pendingPhase; if (ph.phase === "tsumo-choice") { bar.appendChild(actionButton("ツモ上がる!", true, () => respond({ action: "tsumo" }))); bar.appendChild(actionButton("見送る", false, () => respond({ action: "skip" }))); } else if (ph.phase === "kyuushu-choice") { bar.appendChild(el("span", "hint", "九種九牌(么九牌9種以上)が成立しています")); bar.appendChild(actionButton("九種九牌で流す", true, () => respond({ action: "kyuushu" }))); bar.appendChild(actionButton("続ける", false, () => respond({ action: "skip" }))); } else if (ph.phase === "ron-choice") { bar.appendChild(el("span", "hint", `${Yaku.calcScore ? "" : ""}ロン可能: ${ph.result.totalHan}翻${ph.result.fu}符`)); bar.appendChild(actionButton("ロン!", true, () => respond({ action: "ron" }))); bar.appendChild(actionButton("見逃す", false, () => respond({ action: "skip" }))); } else if (ph.phase === "pon-choice") { bar.appendChild(el("span", "hint", "ポンできます:")); const ponType = tileTypeOf(ph.tile); const ponOpt = el("div", "chi-opt"); for (let i = 0; i < 3; i++) ponOpt.appendChild(tileEl(ponType, { red: i === 0 && isRedId(ph.tile) })); ponOpt.addEventListener("click", () => respond({ action: "pon" })); bar.appendChild(ponOpt); bar.appendChild(actionButton("スキップ", false, () => respond({ action: "skip" }))); } else if (ph.phase === "chi-choice") { bar.appendChild(el("span", "hint", "チーできます:")); for (const opt of ph.options) { const o = el("div", "chi-opt"); for (const t of opt) o.appendChild(tileEl(t)); o.addEventListener("click", () => respond({ action: "chi" })); bar.appendChild(o); } bar.appendChild(actionButton("スキップ", false, () => respond({ action: "skip" }))); } else if (ph.phase === "discard-choice") { bar.appendChild(el("span", "hint", "捨てる牌を選んでください")); if (ph.canRiichi) { const btn = actionButton("リーチ", riichiArmed, () => { riichiArmed = !riichiArmed; renderActionBar(); }); if (riichiArmed) btn.classList.add("riichi-armed"); bar.appendChild(btn); } } } function actionButton(label, primary, onclick) { const b = el("button", "actionbtn" + (primary ? " primary" : ""), label); b.addEventListener("click", onclick); return b; } /* ---------- 局終了 ---------- */ function onHandEnd(summary) { pendingPhase = null; render(); const modal = document.getElementById("mj-modal-overlay"); const content = document.getElementById("mj-modal-content"); content.style.height = ""; modalOutsideClosable = false; let html = ""; if (summary.type === "win") { html += `
和了!
`; } else if (summary.type === "exhaustive") { html += `
流局
聴牌: ${summary.tenpaiSeats.map((s) => charLabel(s).name).join("・") || "なし"}
`; } else if (summary.type === "abortive") { html += `
途中流局
理由: ${summary.reason}
`; } if (summary.dealerChanged) { html += `
親交代: ${charLabel(summary.dealerBefore).name} → ${charLabel(summary.dealerAfter).name}
`; } else { html += `
親継続: ${charLabel(summary.dealerBefore).name}(${game.honba}本場)
`; } html += `
点数の内訳
`; for (const sc of (summary.scoreChanges || [])) { const cl = charLabel(sc.seat); const deltaStr = sc.delta > 0 ? `+${sc.delta}` : `${sc.delta}`; const deltaClass = sc.delta > 0 ? "delta-plus" : (sc.delta < 0 ? "delta-minus" : "delta-zero"); html += `
${cl.emoji} ${cl.name}${sc.before}→${sc.after}点 (${deltaStr})
`; if (sc.reason) html += `
${sc.reason}
`; } content.innerHTML = html; if (summary.type === "win") { const rec = loadRecords(); for (const w of summary.winners) { if (w.seat === 0) rec.human.agariCount++; else { const ch = game.players[w.seat].character; if (ch && rec.chars[ch.id]) rec.chars[ch.id].agariCount++; } } saveRecords(); const revealWrap = document.getElementById("mj-win-reveal"); for (const w of summary.winners) { const cl = charLabel(w.seat); revealWrap.appendChild(el("div", "result-row", `${cl.emoji} ${cl.name}${w.han}翻${w.fu}符 ${w.label || ""}`)); revealWrap.appendChild(el("div", "yakulist", w.yaku.map((y) => y.name + (y.han ? "(" + y.han + "翻)" : "")).join("・"))); // 和了を証明するため、手牌をすべて公開して表示する(裏返しのままにしない) const revealRow = el("div", "hand-row reveal-hand"); for (const m of (w.melds || [])) { const g = el("div", "meld-group"); for (const t of m.tiles) g.appendChild(tileEl(t)); revealRow.appendChild(g); } let handIds = (w.hand || []).slice(); if (!w.isTsumo && w.winTile != null && handIds.indexOf(w.winTile) === -1) handIds.push(w.winTile); handIds.sort((a, b) => tileTypeOf(a) - tileTypeOf(b)); for (const id of handIds) { revealRow.appendChild(tileEl(tileTypeOf(id), { red: isRedId(id), drawn: id === w.winTile })); } revealWrap.appendChild(revealRow); } } const nextBtn = actionButton(Ctl.isGameOver(game) ? "最終結果を見る" : "次の局へ", true, () => { modal.hidden = true; beginNextHand(); }); const btnWrap = el("div"); btnWrap.style.textAlign = "center"; btnWrap.style.marginTop = "14px"; btnWrap.appendChild(nextBtn); content.appendChild(btnWrap); modal.hidden = false; } function showFinalResult() { const modal = document.getElementById("mj-modal-overlay"); const content = document.getElementById("mj-modal-content"); content.style.height = ""; modalOutsideClosable = false; const ranked = game.players.slice().sort((a, b) => b.score - a.score); recordGameResult(ranked); const winnerCl = charLabel(ranked[0].seat); const winnerPortrait = winnerCl.winPortrait || winnerCl.portrait; const winnerPosStyle = winnerCl.pos ? ` style="object-position:${winnerCl.pos};"` : ""; const winnerPhotoHtml = winnerPortrait ? `
` : `
${winnerCl.emoji}
`; let html = `
対局終了
`; html += `
${winnerPhotoHtml}
優勝
${winnerCl.name}
`; ranked.forEach((pl, i) => { const cl = charLabel(pl.seat); html += `
${i + 1}位 ${cl.emoji} ${cl.name}${pl.score}点
`; }); content.innerHTML = html; const d = el("div"); d.style.textAlign = "center"; d.style.marginTop = "14px"; d.appendChild(actionButton("タイトルへ戻る", true, () => { modal.hidden = true; document.getElementById("mj-screen-game").hidden = true; document.getElementById("mj-screen-intro").hidden = false; })); content.appendChild(d); modal.hidden = false; } /* ---------- 戦績・ランキング画面 ---------- */ function bindStatsButton() { const btn = document.getElementById("mj-statsBtn"); if (btn) btn.addEventListener("click", showStatsScreen); } function showStatsScreen() { const modal = document.getElementById("mj-modal-overlay"); const content = document.getElementById("mj-modal-content"); content.style.height = "600px"; modalOutsideClosable = true; renderStatsContent(content, modal, false); modal.hidden = false; } function bindModalOverlayOutsideClick() { const overlay = document.getElementById("mj-modal-overlay"); overlay.addEventListener("click", (e) => { if (e.target === overlay && modalOutsideClosable) overlay.hidden = true; }); } function renderStatsContent(content, modal, confirmingReset) { const rec = loadRecords(); let html = `
戦績・ランキング
`; html += `
あなたの通算成績
`; html += `
対局数${rec.human.gamesPlayed}回
`; const rankLine = rec.human.rankCounts.map((c, i) => `${i + 1}位:${c}`).join(" / "); html += `
${rankLine}
`; const humanWinRate = rec.human.gamesPlayed ? Math.round((rec.human.rankCounts[0] / rec.human.gamesPlayed) * 100) : 0; html += `
優勝率${humanWinRate}%
`; html += `
通算和了回数${rec.human.agariCount}回
`; html += `
アバター別成績
`; for (const key of ["male", "female"]) { const av = rec.avatars[key] || { games: 0, wins: 0 }; const label = (Cpu.PLAYER_AVATARS[key] && Cpu.PLAYER_AVATARS[key].label) || key; const wr = av.games ? Math.round((av.wins / av.games) * 100) : 0; html += `
${label}使用${av.games}回・優勝率${wr}%
`; } html += `
CPU戦績ランキング(優勝率順)
`; const charRows = Cpu.CHARACTERS.map((c) => { const st = rec.chars[c.id] || { games: 0, wins: 0, agariCount: 0 }; const wr = st.games ? st.wins / st.games : -1; return { c, st, wr }; }).sort((a, b) => b.wr - a.wr); charRows.forEach((row, i) => { const wrPct = row.st.games ? Math.round(row.wr * 100) : 0; html += `
${i + 1}位 ${row.c.emoji} ${row.c.name}優勝率${wrPct}%
`; html += `
対局数${row.st.games}・優勝${row.st.wins}・和了${row.st.agariCount}
`; }); const btnWrap = el("div"); btnWrap.style.textAlign = "center"; btnWrap.style.marginTop = "14px"; btnWrap.style.display = "flex"; btnWrap.style.gap = "10px"; btnWrap.style.justifyContent = "center"; if (confirmingReset) { html += `
本当に記録をリセットしますか?この操作は元に戻せません。
`; content.innerHTML = html; const yesBtn = actionButton("リセットする", false, () => { resetRecords(); renderStatsContent(content, modal, false); }); const cancelBtn = actionButton("キャンセル", true, () => { renderStatsContent(content, modal, false); }); btnWrap.appendChild(yesBtn); btnWrap.appendChild(cancelBtn); } else { content.innerHTML = html; const resetBtn = actionButton("記録をリセット", false, () => { renderStatsContent(content, modal, true); }); const closeBtn = actionButton("閉じる", true, () => { modal.hidden = true; }); btnWrap.appendChild(resetBtn); btnWrap.appendChild(closeBtn); } content.appendChild(btnWrap); } /* WordPressのカスタムHTML/CSS/JSを別々の欄に分けて設置する場合など、 このJSが本体HTMLより先に読み込まれる可能性があるため、 DOMの準備ができてから初期化するようにしている(どちらの設置方法でも安全に動く) */ function initMahjongGame() { if (!document.getElementById("mj-startBtn")) { // 対象のHTML(mj-appなど)がまだ読み込まれていない場合は少し待って再試行する setTimeout(initMahjongGame, 50); return; } bindStartButton(); bindStatsButton(); bindModalOverlayOutsideClick(); renderIntro(); } if (document.readyState === "loading") { document.addEventListener("DOMContentLoaded", initMahjongGame); } else { initMahjongGame(); } })();