麻雀 手牌入力 + 役・符・点数計算

状況









本場

鳴き面子

例:
ポン 5m5m5m
チー 3p4p5p
カン 7s7s7s7s

牌一覧

萬子:
筒子:
索子:
字牌:

入力モード

現在のモード:手牌

手牌(14枚)


ドラ表示牌

和了牌

裏ドラ表示牌

結果

from js import document from pyodide.ffi import create_proxy from collections import Counter hand = [] input_mode = "hand" dora_tile = None agari_tile = None ura_tile = None # -------------------------- # UI rendering # -------------------------- def render_hand(): div = document.getElementById("hand") div.innerHTML = "" for item in hand: span = document.createElement("span") span.className = "hand-tile" img = document.createElement("img") img.src = item["src"] # ← ここが重要! img.className = "tile-img" span.appendChild(img) div.appendChild(span) def on_tile_click(event): global hand, dora_tile, agari_tile, ura_tile, input_mode target = event.target if target.tagName.lower() == "img": target = target.parentElement tile = target.getAttribute("data-tile") src = target.getAttribute("data-src") if tile is None or src is None: return if input_mode == "hand": if len(hand) < 14: hand.append({"tile": tile, "src": src}) render_hand() elif input_mode == "dora": dora_tile = {"tile": tile, "src": src} render_dora() elif input_mode == "agari": agari_tile = {"tile": tile, "src": src} render_agari() elif input_mode == "ura": # ★★★ これが必要 ★★★ ura_tile = {"tile": tile, "src": src} render_ura() def on_clear(event): global hand hand = [] render_hand() document.getElementById("result-yaku").innerHTML = "" # -------------------------- # Tile parsing # -------------------------- def parse_tile(t): if t[-1] in ["m","p","s"]: suit = t[-1] num_char = t[0] is_red = (num_char == "0") num = 5 if is_red else int(num_char) return ("num", num, suit, is_red) else: return ("honor", t) def hand_to_internal(lst): return [parse_tile(item["tile"]) for item in lst] def normalize_for_meld(t): if t[0] == "num": # ("num", n, s, is_red) または ("num", n, s) if len(t) == 4: _, n, s, _ = t elif len(t) == 3: _, n, s = t else: raise ValueError(f"Unexpected tile format: {t}") return ("num", n, s) return t def tiles_to_counts(tiles): return Counter([normalize_for_meld(t) for t in tiles]) # -------------------------- # Naki parsing # -------------------------- def parse_naki_input(text): """ text: ポン 5m5m5m チー 3p4p5p カン 7s7s7s7s return: [ {"type":"pon","tiles":[...],"is_naki":True}, {"type":"chi","tiles":[...],"is_naki":True}, {"type":"kan","tiles":[...],"is_naki":True} ] """ lines = text.strip().split("\n") result = [] for line in lines: line = line.strip() if not line: continue parts = line.split() if len(parts) < 2: continue kind = parts[0] tiles_str = parts[1] # 2文字ずつ切る tiles = [tiles_str[i:i+2] for i in range(0, len(tiles_str), 2)] tiles = [parse_tile(t) for t in tiles] if kind in ["ポン","pon","Pon"]: result.append({"type":"pon","tiles":tiles,"is_naki":True}) elif kind in ["チー","chi","Chi"]: result.append({"type":"chi","tiles":tiles,"is_naki":True}) elif kind in ["カン","kan","Kan"]: result.append({"type":"kan","tiles":tiles,"is_naki":True}) return result # -------------------------- # Kokushi / Chiitoi # -------------------------- def is_kokushi(tiles): base = [] for t in tiles: if t[0] == "num": _,n,s,_ = t if n in [1,9]: base.append(("num",n,s,False)) else: base.append(t) if len(base) != 14: return False if len(set(base)) != 13: return False c = Counter(base) return any(v == 2 for v in c.values()) def is_chiitoi(tiles): if len(tiles) != 14: return False norm = [] for t in tiles: if t[0] == "num": _,n,s,_ = t norm.append(("num",n,s)) else: norm.append(t) c = Counter(norm) return len(c) == 7 and all(v == 2 for v in c.values()) # -------------------------- # Remove naki tiles from hand # -------------------------- def remove_naki_tiles(tiles, naki_melds): tiles_copy = tiles.copy() for meld in naki_melds: for t in meld["tiles"]: nt = normalize_for_meld(t) for i,orig in enumerate(tiles_copy): if normalize_for_meld(orig) == nt: tiles_copy.pop(i) break return tiles_copy # -------------------------- # Meld structure search (menzen part) # -------------------------- def find_meld_structure(tiles): counts = tiles_to_counts(tiles) for head in list(counts.keys()): if counts[head] >= 2: counts[head] -= 2 if counts[head] == 0: del counts[head] melds = [] if _search_melds(counts.copy(), melds): return {"head": head, "melds": melds} counts[head] = counts.get(head,0) + 2 return None def _search_melds(counts, melds): if not counts: return True tile = min(counts.keys()) cnt = counts[tile] # koutsu if cnt >= 3: counts[tile] -= 3 if counts[tile] == 0: del counts[tile] melds.append(("koutsu",[tile,tile,tile], False)) # False = menzen if _search_melds(counts, melds): return True melds.pop() counts[tile] = cnt # shuntsu if tile[0] == "num": _,n,s = tile t2 = ("num",n+1,s) t3 = ("num",n+2,s) if n <= 7 and counts.get(t2,0)>0 and counts.get(t3,0)>0: counts[tile] -= 1 counts[t2] -= 1 counts[t3] -= 1 if counts[tile] == 0: del counts[tile] if counts.get(t2,0)==0: counts.pop(t2,None) if counts.get(t3,0)==0: counts.pop(t3,None) melds.append(("shuntsu",[tile,t2,t3], False)) if _search_melds(counts, melds): return True melds.pop() counts[tile] = counts.get(tile,0) + 1 counts[t2] = counts.get(t2,0) + 1 counts[t3] = counts.get(t3,0) + 1 return False # -------------------------- # Meld structure with naki # -------------------------- def find_meld_structure_with_naki(tiles, naki_melds): # remove naki tiles remaining = remove_naki_tiles(tiles, naki_melds) # menzen melds base = find_meld_structure(remaining) if base is None: return None melds = base["melds"].copy() # add naki melds for nm in naki_melds: if nm["type"] == "pon": melds.append(("koutsu", nm["tiles"], True)) elif nm["type"] == "chi": melds.append(("shuntsu", nm["tiles"], True)) elif nm["type"] == "kan": melds.append(("kantsu", nm["tiles"], True)) return { "head": base["head"], "melds": melds } # -------------------------- # Machi detection # -------------------------- def detect_machi(structure, agari_tile): if agari_tile is None: return "other" head = structure["head"] melds = structure["melds"] norm_agari = normalize_for_meld(agari_tile) # tanki if norm_agari == head: return "tanki" for kind, tiles_m, is_naki in melds: norm_m = [normalize_for_meld(t) for t in tiles_m] if kind == "koutsu": if norm_agari in norm_m: return "other" elif kind == "shuntsu": if norm_agari not in norm_m: continue nums = sorted(t[1] for t in norm_m) n = norm_agari[1] # penchan if nums == [1,2,3] and n == 1: return "penchan" if nums == [7,8,9] and n == 9: return "penchan" # kanchan if n == nums[1]: return "kanchan" # ryanmen return "ryanmen" return "other" # -------------------------- # Yaku helpers # -------------------------- def is_tanyao(tiles): for t in tiles: if t[0] == "honor": return False _,n,s,_ = t if n == 1 or n == 9: return False return True def all_melds_are_shuntsu(counts): if not counts: return True tile = min(counts.keys()) if tile[0] != "num": return False _,n,s = tile t2 = ("num",n+1,s) t3 = ("num",n+2,s) if n > 7 or counts.get(t2,0)==0 or counts.get(t3,0)==0: return False counts[tile] -= 1 counts[t2] -= 1 counts[t3] -= 1 if counts[tile] == 0: del counts[tile] if counts.get(t2,0)==0: counts.pop(t2,None) if counts.get(t3,0)==0: counts.pop(t3,None) return all_melds_are_shuntsu(counts) def is_pinfu(tiles,jifu,bakaze,menzen): if not menzen: return False counts = tiles_to_counts(tiles) for tile in list(counts.keys()): if counts[tile] >= 2: if tile[0] == "honor": if tile[1] in ["P","F","C",jifu,bakaze]: continue counts[tile] -= 2 if counts[tile] == 0: del counts[tile] if all_melds_are_shuntsu(counts.copy()): return True counts[tile] = counts.get(tile,0) + 2 return False def is_toitoi(tiles): counts = tiles_to_counts(tiles) for tile in list(counts.keys()): if tile[0] == "num": _,n,s = tile t2 = ("num",n+1,s) t3 = ("num",n+2,s) if n <= 7 and counts.get(tile,0)>0 and counts.get(t2,0)>0 and counts.get(t3,0)>0: return False return True def is_sanankou(tiles): counts = tiles_to_counts(tiles) k = 0 for tile,c in counts.items(): if c >= 3: k += 1 return k >= 3 def is_ikkitsuukan(tiles): counts = tiles_to_counts(tiles) for suit in ["m","p","s"]: need = [("num",i,suit) for i in range(1,10)] if all(counts.get(t,0)>=1 for t in need): return True return False def is_sanshoku_doujun(tiles): counts = tiles_to_counts(tiles) for n in range(1,8): has_m = all(counts.get(("num",n+i,"m"),0)>=1 for i in [0,1,2]) has_p = all(counts.get(("num",n+i,"p"),0)>=1 for i in [0,1,2]) has_s = all(counts.get(("num",n+i,"s"),0)>=1 for i in [0,1,2]) if has_m and has_p and has_s: return True return False def is_chinitsu(tiles): suits = set() has_h = False for t in tiles: if t[0] == "honor": has_h = True else: _,n,s,_ = t suits.add(s) return len(suits)==1 and not has_h def is_honitsu(tiles): suits = set() has_h = False for t in tiles: if t[0] == "honor": has_h = True else: _,n,s,_ = t suits.add(s) return len(suits)==1 and has_h def yakuhai(tiles,jifu,bakaze): counts = tiles_to_counts(tiles) res = [] for code,name in [("P","白"),("F","發"),("C","中")]: if counts.get(("honor",code),0)>=3: res.append((f"役牌:{name}",1)) for code,name in [("E","東"),("S","南"),("W","西"),("N","北")]: if counts.get(("honor",code),0)>=3: fan = 0 label = "役牌:" if code == jifu: fan += 1 label += "自風" if code == bakaze: fan += 1 label += "場風" if fan > 0: res.append((label,fan)) return res def count_red_dora(tiles): return sum(1 for t in tiles if t[0]=="num" and t[3]) # -------------------------- # Dora indicator # -------------------------- def next_number_tile(num): return 1 if num == 9 else num + 1 def next_wind(code): order = ["E","S","W","N"] i = order.index(code) return order[(i+1) % 4] def next_dragon(code): order = ["P","F","C"] i = order.index(code) return order[(i+1) % 3] def parse_indicator(s): s = s.strip() if not s: return None if s[-1] in ["m","p","s"]: suit = s[-1] num_char = s[0] is_red = (num_char == "0") num = 5 if is_red else int(num_char) return ("num", num, suit, False) else: return ("honor", s) def calc_dora_from_indicator(indicator, tiles): if indicator is None: return 0 if indicator[0] == "num": _,n,s,_ = indicator dora_num = next_number_tile(n) return sum(1 for t in tiles if t[0]=="num" and t[2]==s and t[1]==dora_num) else: _,code = indicator if code in ["E","S","W","N"]: d = ("honor", next_wind(code)) else: d = ("honor", next_dragon(code)) return sum(1 for t in tiles if t == d) # -------------------------- # Fu calculation # -------------------------- def fu_head(head,jifu,bakaze): if head[0] != "honor": return 0 code = head[1] if code in ["P","F","C"]: return 2 if code == jifu: return 2 if code == bakaze: return 2 return 0 def fu_koutsu(meld): kind, tiles_m, is_naki = meld tile = tiles_m[0] is_yaochu = (tile[0]=="honor" or tile[1] in [1,9]) if is_naki: return 4 if is_yaochu else 2 else: return 8 if is_yaochu else 4 def fu_kantsu(meld): kind, tiles_m, is_naki = meld tile = tiles_m[0] is_yaochu = (tile[0]=="honor" or tile[1] in [1,9]) if is_naki: return 16 if is_yaochu else 8 else: return 32 if is_yaochu else 16 def fu_shuntsu(meld): return 0 def calc_fu(tiles, jifu, bakaze, menzen, agari_type, agari_tile, structure): if is_kokushi(tiles): return 0 if is_chiitoi(tiles): return 25 if structure is None: return 0 head = structure["head"] melds = structure["melds"] if is_pinfu(tiles,jifu,bakaze,menzen) and agari_type=="tsumo": return 20 fu = 20 if agari_type == "ron" and menzen: fu += 10 if agari_type == "tsumo": fu += 2 fu += fu_head(head,jifu,bakaze) for kind,tiles_m,is_naki in melds: if kind == "koutsu": fu += fu_koutsu((kind,tiles_m,is_naki)) elif kind == "kantsu": fu += fu_kantsu((kind,tiles_m,is_naki)) else: fu += fu_shuntsu((kind,tiles_m,is_naki)) machi = detect_machi(structure, agari_tile) if machi in ["tanki","kanchan","penchan"]: fu += 2 if fu % 10 != 0: fu = fu + (10 - fu % 10) return fu # -------------------------- # Score calculation # -------------------------- def calc_base_points(fu, han): if han >= 13: return 8000 if han >= 11: return 6000 if han >= 8: return 4000 if han >= 6: return 3000 if han >= 5: return 2000 base = fu * (2 ** (han + 2)) return min(base, 2000) def round_up_100(x): return ((x + 99) // 100) * 100 def calc_score(fu, han, oya_flag, agari_type, honba): base = calc_base_points(fu, han) if oya_flag: if agari_type == "tsumo": pay = round_up_100(base * 2) total = pay * 3 + honba * 300 return f"親ツモ:{pay}オール(本場 {honba})" else: pts = round_up_100(base * 6) return f"親ロン:{pts}点(本場 {honba})" else: if agari_type == "tsumo": child = round_up_100(base) parent = round_up_100(base * 2) return f"子ツモ:親 {parent}点・子 {child}点(本場 {honba})" else: pts = round_up_100(base * 4) return f"子ロン:{pts}点(本場 {honba})" # -------------------------- # Yaku judgement # -------------------------- def judge_yaku(tiles, jifu, bakaze, menzen, riichi, agari_type): yaku = [] # 国士無双 if is_kokushi(tiles): yaku.append(("国士無双",13)) return yaku # 七対子 if is_chiitoi(tiles): yaku.append(("七対子",2)) return yaku # 門前ツモ if menzen and agari_type=="tsumo": yaku.append(("門前ツモ",1)) # 立直 if riichi and menzen: yaku.append(("立直",1)) # 断么九 if is_tanyao(tiles): yaku.append(("断么九",1)) # 平和 if is_pinfu(tiles,jifu,bakaze,menzen): yaku.append(("平和",1)) # 役牌 yaku.extend(yakuhai(tiles,jifu,bakaze)) # 対々和 if is_toitoi(tiles): yaku.append(("対々和",2)) # 三暗刻 if is_sanankou(tiles): yaku.append(("三暗刻",2)) # 一気通貫 if is_ikkitsuukan(tiles): yaku.append(("一気通貫",2 if menzen else 1)) # 三色同順 if is_sanshoku_doujun(tiles): yaku.append(("三色同順",2 if menzen else 1)) # 清一色 / 混一色 if is_chinitsu(tiles): yaku.append(("清一色",6 if menzen else 5)) elif is_honitsu(tiles): yaku.append(("混一色",3 if menzen else 2)) # 赤ドラ red = count_red_dora(tiles) if red > 0: yaku.append((f"赤ドラ {red}", red)) # ★★★ ここを修正:画像クリックで選んだドラ表示牌を使う ★★★ if dora_tile: indicator = parse_tile(dora_tile["tile"]) dora_count = calc_dora_from_indicator(indicator, tiles) if dora_count > 0: yaku.append((f"ドラ {dora_count}", dora_count)) if riichi and ura_tile: ura_indicator = parse_tile(ura_tile["tile"]) ura_count = calc_dora_from_indicator(ura_indicator, tiles) if ura_count > 0: yaku.append((f"裏ドラ {ura_count}", ura_count)) return yaku # -------------------------- # Analyze button # -------------------------- def on_analyze(event): global dora_tile, agari_tile # -------------------------- # 手牌チェック # -------------------------- if len(hand) != 14: document.getElementById("result-yaku").innerHTML = "手牌は14枚必要です。" return # -------------------------- # 手牌(内部形式へ変換) # -------------------------- tiles = hand_to_internal(hand) # -------------------------- # ドラ表示牌(画像クリックで選択) # -------------------------- indicator = parse_tile(dora_tile["tile"]) if dora_tile else None # -------------------------- # 和了牌(画像クリックで選択) # -------------------------- agari_parsed = parse_tile(agari_tile["tile"]) if agari_tile else None # -------------------------- # UI から状況取得 # -------------------------- jifu = document.getElementById("jifu").value bakaze = document.getElementById("bakaze").value menzen = document.getElementById("menzen").checked riichi = document.getElementById("riichi").checked agari_type = document.getElementById("agari-type").value oya_flag = (document.getElementById("oya").value == "oya") honba = int(document.getElementById("honba").value or "0") # -------------------------- # 鳴き面子 # -------------------------- naki_text = document.getElementById("naki-input").value naki_melds = parse_naki_input(naki_text) # -------------------------- # 面子構造(鳴き込み) # -------------------------- structure = find_meld_structure_with_naki(tiles, naki_melds) if structure is None: document.getElementById("result-yaku").innerHTML = "和了形ではありません。" return # -------------------------- # 役判定 # -------------------------- yaku = judge_yaku(tiles, jifu, bakaze, menzen, riichi, agari_type) if not yaku: document.getElementById("result-yaku").innerHTML = "役なしです。" return total_han = sum(f for _, f in yaku) # -------------------------- # 符計算(和了牌を使用) # -------------------------- fu = calc_fu(tiles, jifu, bakaze, menzen, agari_type, agari_parsed, structure) # -------------------------- # 点数計算 # -------------------------- score_text = calc_score(fu, total_han, oya_flag, agari_type, honba) # -------------------------- # HTML 出力 # -------------------------- html = f'
{fu}符 {total_han}翻
' html += "
" for name, fan in yaku: html += f'
{name}: {fan}翻
' html += "
" html += f'
{score_text}
' document.getElementById("result-yaku").innerHTML = html # -------------------------- # ドラ・アガリ・裏ドラ出力切り替え # -------------------------- def render_dora(): div = document.getElementById("dora-display") div.innerHTML = "" if dora_tile: img = document.createElement("img") img.src = dora_tile["src"] img.className = "tile-img" div.appendChild(img) def render_agari(): div = document.getElementById("agari-display") div.innerHTML = "" if agari_tile: img = document.createElement("img") img.src = agari_tile["src"] img.className = "tile-img" div.appendChild(img) def render_ura(): div = document.getElementById("ura-display") div.innerHTML = "" if ura_tile: img = document.createElement("img") img.src = ura_tile["src"] img.className = "tile-img" div.appendChild(img) # -------------------------- # 入力切替処理 # -------------------------- def set_mode_hand(event): global input_mode input_mode = "hand" document.getElementById("current-mode").innerHTML = "手牌" def set_mode_dora(event): global input_mode input_mode = "dora" document.getElementById("current-mode").innerHTML = "ドラ表示牌" def set_mode_agari(event): global input_mode input_mode = "agari" document.getElementById("current-mode").innerHTML = "和了牌" def set_mode_ura(event): global input_mode input_mode = "ura" document.getElementById("current-mode").innerHTML = "裏ドラ表示牌" # -------------------------- # Event registration # -------------------------- tile_click_proxy = create_proxy(on_tile_click) clear_proxy = create_proxy(on_clear) analyze_proxy = create_proxy(on_analyze) # 牌一覧クリック buttons = document.getElementsByClassName("tile-btn") for b in buttons: b.addEventListener("click", tile_click_proxy) # 入力モード切り替え document.getElementById("mode-hand").addEventListener("click", create_proxy(set_mode_hand)) document.getElementById("mode-dora").addEventListener("click", create_proxy(set_mode_dora)) document.getElementById("mode-agari").addEventListener("click", create_proxy(set_mode_agari)) document.getElementById("mode-ura").addEventListener("click", create_proxy(set_mode_ura)) # クリア・判定 document.getElementById("clear-hand").addEventListener("click", clear_proxy) document.getElementById("analyze").addEventListener("click", analyze_proxy) document.getElementById("clear-hand").addEventListener("click", clear_proxy) document.getElementById("analyze").addEventListener("click", analyze_proxy)