Skip to content
Go back

【Deadlock】日本語化設定ファイルを作って5万ユーザーに使ってもらった体験記

公開:

deadlock-japanese.devkey.jp という、Deadlock のローカライズ(日本語化)用の設定ファイルを配布するサイトを公開したときの体験記です。
想像以上に多くの人に使ってもらえてうれしかった話や、なかなかに大変だったという話

目次

Open 目次

嬉しかったこと

REJECT の YamatoN 先生や、ZETA DIVISION の XQQ さんに紹介していただけたこと。
シンプルにモチベが爆上がりしました。

XQQ さん:


YamatoN 先生:

多くの利用者に使っていただけた

生の英語を浴びるほど触れられたこと

この設定ファイルを作った当初の理由は「私自身が Deadlock をより楽しむため」なんですが、
ついでに英語の勉強にもなったらいいなと思って挑戦した、というのもあります。

職業柄、英語の勉強はちょいちょいやるんですが、結局必死になれる何かがないと続かないんですよね。
英語の勉強を始めた回数は二桁超えてます。(つまり……やめた回数も……)

公開することで「英語に触れざるを得ない」強制力も働きますからね。

こだわったポイント

要望フォームを作る

個人で作っているので、全キャラ・全アイテムの翻訳(漏れや誤訳など)を全部チェックするのは物理的に厳しいです。
なので、誤訳修正や改善提案は利用者を巻き込んでやっていく運用にしたくて、Google フォームで要望を送れるようにしました。

更新履歴を公開する

更新履歴 のように、何を変えたか見える化しました。

自分が利用者だったら「最新バージョンに追従してるの?」って気になるし、そこがブラックボックスな設定ファイルは使いたくないな、と思ったからです。

それに、せっかく修正・改善の提案を送ってくれた人が「反映されたの?されてないの?」って分からない状態にしたくなかったんですよね。
そういう状態だと、継続的に協力してくれる人が減っちゃう可能性もあるので。

大変だったこと

更新頻度が高く、内容変更も多い

Deadlock 自体が experimental gameplay なだけあって、とにかく更新頻度多い。しかも、文言の変更量が多い。
正直、1人で更新し続けるのは普通に無理でした。…と言いつつ、1人で無理やりやりきりましたが。

挙動理解が大変

特に自分は ShivMo & Krill しか使ってない民だったので、他キャラの細かい挙動理解や、アップデートでの変更点の把握が大変でした。
挙動が分からないと「何を言っとるんだ?」となる文章も多かったんですよね。

例えばMirageのパッシブスキルの説明文で、

Passive: Your shots apply an increasing multiplier on the target. When the multiplier on a target expires or you reach the max, it’s consumed and the target suffers Spirit Damage and is briefly revealed on the map. The final damage is the base damage times the multiplier.

というのがあったんですが、直訳寄りにすると、

パッシブ:あなたの射撃は対象に“増加していく倍率”を付与する。対象に付いている倍率が期限切れになるか最大に達すると、それは消費され、対象はスピリットダメージを受け、短時間マップ上で明らかになる。最終ダメージは基本ダメージ×倍率である。

みたいになって、日本語としては読めるけど、Mirageを使ったことない人は「で、結局なにが起きるの?」となる(私がそう)。

実際にMirageをプレイして挙動を確認しつつ意図を噛み砕くと、こういう説明になります

パッシブ:同じ敵に弾を当てるたび、その敵にダメージ倍率(スタック)が蓄積していく。 倍率が最大まで溜まるか、一定時間当てられずに効果が切れる瞬間に、その蓄積はリセットされ、敵にスピリットダメージを与え、短時間ミニマップ上にその敵の位置が表示される。 消費時に発生するダメージは基礎ダメージ × 蓄積した倍率

みたいな感じで、実際の挙動に合わせた意訳をすることが多かったです。
上記の例だと、スタックや蓄積という言葉で意味を補足したり、誰がマップに映るのかとか、主語・対象・タイミング を補って書いていました。

シンプルに翻訳がむずい

そもそも自分が英語つよつよマンじゃない + MOBAガチ初心者だったので、口語・慣用句・MOBAスラングの理解にとても苦しみました。

一番最初、「Denyってなに?拒否する?」ってなったのは良い思い出。まあ、MOBAのことを何も分からなくて大変だったからこそ、この設定ファイルを作ったきっかけなんですけどね。

口語・慣用句あたりはまだ ChatGPT に聞けばある程度は理解できるんですが、ゲーム内コンテキストを含んだ文章だったり、 同じ画面に表示されている別の文脈を拾わないと意味が通らない文章がちらほらありました。
it で書かれてたりね。どの it やねんって。
つまり、文章を 1:1 で訳していくだけじゃダメなパターンもあったので、苦しい~~~ってなってました。

古いデータを使い続けてる人に文句を言われる

当たり前ですが、今回配布した設定ファイルは自動更新されません

ただ、一度ダウンロードして適用したら「Deadlock 側の更新にも自動で追従する」と思っている方が結構いらっしゃって、 フォームや DM、リプライで「データが古い、新キャラ・新アイテムの翻訳ができてない」とたくさんのお叱りを受けました。

もちろんシンプルに間に合ってないパターンもあったのですが、半分くらいは設定ファイルの更新漏れでした。
いやー……一人で運用って大変!!

全く関係ない問い合わせが多い

設定ファイルを入れたら「画質が荒くなった」「FPS が出なくなった」「クライアントがクラッシュした」などなど。
言語ファイルなので、基本的にはゲームの描画やパフォーマンスに直接作用するものではないです。

experimental gameplay なだけあってバグが多いゲームだったのは間違いないのですが、全てのバグを何でもかんでもこの設定ファイルのせいにされると、さすがに困りました。

「これが原因に違いない!」って思い込んでる状態の人にDMで説明するの、めちゃ大変だった。

どうやって更新し続けたの?

これは技術的な話。

ざっくり流れはこれです。

  • 公式言語リソースの変更を検知 → 通知
  • Python で追加・変更・削除の差分検出
  • 翻訳して更新
  • アナウンス

公式言語リソースの変更を検知 → 通知

SteamDatabase の GitHub repo の Watch を使って、内容に変更が入ったらメールで通知が来るようにしていました。

Python で追加・変更・削除の差分検出

  • 増えたプロパティがあれば新規翻訳
  • 変更があったプロパティは再翻訳
  • 消えたプロパティがあれば削除

具体的には、以下の Python ファイルを自作して、コツコツと翻訳と更新を繰り返していました。

import argparse
import re
import sys
from pathlib import Path

TOKEN_RE = re.compile(r'^\s*"([^"]+)"\s*"(.*)"\s*$')
COMMENT_RE = re.compile(r"^\s*//")


def read_text(path: Path) -> str:
    return path.read_text(encoding="utf-8-sig")


def extract_tokens(text: str) -> dict:
    tokens = {}
    for line in text.splitlines():
        if COMMENT_RE.match(line):
            continue
        match = TOKEN_RE.match(line)
        if match:
            tokens[match.group(1)] = match.group(2)
    return tokens


def compute_diff(old_tokens: dict, new_tokens: dict) -> tuple[dict, dict, dict]:
    old_keys = set(old_tokens.keys())
    new_keys = set(new_tokens.keys())
    added = {key: new_tokens[key] for key in new_keys - old_keys}
    removed = {key: old_tokens[key] for key in old_keys - new_keys}
    changed = {
        key: {"old": old_tokens[key], "new": new_tokens[key]}
        for key in old_keys & new_keys
        if old_tokens[key] != new_tokens[key]
    }
    return added, removed, changed


def build_category_paths(category_dir: Path) -> tuple[str, Path, Path]:
    name = category_dir.name
    old_file = category_dir / f"old_{name}_english.txt"
    new_file = category_dir / f"{name}_english.txt"
    return name, old_file, new_file


def list_categories(base_dir: Path) -> list:
    categories = []
    for child in sorted(base_dir.iterdir()):
        if not child.is_dir():
            continue
        name, old_file, new_file = build_category_paths(child)
        if old_file.exists() and new_file.exists():
            categories.append(child)
    return categories


def resolve_category_dir(base_dir: Path, category: str) -> Path:
    candidate = Path(category)
    if candidate.is_dir():
        return candidate
    return base_dir / category


def print_section(label: str, entries: dict, show_changed: bool) -> None:
    if not entries:
        print(f"{label}: (none)")
        return
    print(f"{label}:")
    for key in sorted(entries.keys()):
        value = entries[key]
        if show_changed:
            print(f"  {key}:")
            print(f'    "{value["old"]}"')
            print(f'    "{value["new"]}"')
        else:
            print(f'  {key}: "{value}"')


def process_category(category_dir: Path) -> int:
    name, old_path, new_path = build_category_paths(category_dir)
    if not old_path.exists():
        print(f"Old file not found: {old_path}", file=sys.stderr)
        return 1
    if not new_path.exists():
        print(f"New file not found: {new_path}", file=sys.stderr)
        return 1

    old_text = read_text(old_path)
    new_text = read_text(new_path)
    old_tokens = extract_tokens(old_text)
    new_tokens = extract_tokens(new_text)
    added, removed, changed = compute_diff(old_tokens, new_tokens)

    print(f"[{name}]")
    print(f"old: {old_path}")
    print(f"new: {new_path}")
    print_section("added", added, show_changed=False)
    print_section("removed", removed, show_changed=False)
    print_section("changed", changed, show_changed=True)
    print("")
    return 0


def parse_args() -> argparse.Namespace:
    parser = argparse.ArgumentParser(description="Compare Deadlock localization English files.")
    parser.add_argument(
        "--base-dir",
        default=Path(__file__).resolve().parent,
        type=Path,
        help="Base localization directory (default: script directory).",
    )
    parser.add_argument("--dir", help="Category directory name or path.")
    parser.add_argument("--all", action="store_true", help="Process all categories.")
    return parser.parse_args()


def main() -> int:
    args = parse_args()

    if args.dir and args.all:
        print("--dir cannot be combined with --all.", file=sys.stderr)
        return 2

    if not args.dir and not args.all:
        args.all = True

    if args.all:
        categories = list_categories(args.base_dir)
        if not categories:
            print(f"No categories found under {args.base_dir}", file=sys.stderr)
            return 2
    else:
        categories = [resolve_category_dir(args.base_dir, args.dir)]

    exit_code = 0
    for category_dir in categories:
        exit_code = max(exit_code, process_category(category_dir))

    return exit_code


if __name__ == "__main__":
    raise SystemExit(main())

増分の表示例

added:
  ActiveMoveSpeedPenalty_label: "Active Movespeed Penalty"
  ActiveMoveSpeedPenalty_postfix: "m/s"

削除の表示例

removed:
  JarDamage_label: "Jar Damage"
  WallToWallDistance_label: "Max Web Distance"

変更の表示例

Time on KillDuration On Kill に変更された時の例

changed:
  ShadowFormDurationOnKill_label:
    "Time on Kill"
    "Duration On Kill"

なるべく早く配布

とにかく更新が早いので、差分検知 → 翻訳 → 反映 → 配布をできるだけ早く回すようにしてました。

めっちゃ頑張ったし、めっちゃ大変だった…

さいごに

Deadlock 良いゲームだったね…

感謝するぜ お前と出会えた これまでの全てに!!!

P.S.
Mo & Krill ほぼ OTP でアジアの Overall Rating 315位になれたのは良い思い出だった


Share this post on:

Previous Post
【湿気・臭い】新築のコンクリート打ちっぱなしマンションに1年住んでみた感想
Next Post
賃貸でフロアタイルを敷いてみた話