AtCoderマスターズ選手権のためにビジュアライザ実装のAgent Skillsを作った件

はじめに

こんにちは。競技プログラミングのコンテストに参加しているyunixです。今年もそろそろマスターズ選手権の季節がやってきました。

マスターズ選手権は他のコンテストと異なり、公式のビジュアライザが提供されず、各チームが独自に開発する必要があります。過去2年間、この課題に対応するため、ビジュアライザ開発を簡単にするテンプレートと解説記事を作成してきました。

これまでのアプローチは以下のような構成でした:

  • Reactを使って公式ビジュアライザのような見た目のUIを用意
  • Rustで問題固有のビジュアライザ機能を実装
    • 公式のテスタやテストケース生成コードを流用
    • 見た目部分のみ実装すればOK
  • Vercelでデプロイし、チームメンバーがBASIC認証でアクセス可能に

この方法は、公式ツールを再利用することで正確性を担保しつつ、実装範囲を限定できるため効率的でした。

しかし、この2年間での生成AIの急速な発展は著しく、今では問題文をコピペして貼り付けるだけで、ほぼ問題なく高機能なビジュアライザを自動生成してくれるようになっています。(正直なところ、この状況なら公式で提供してくれてもいいんじゃないかと思わなくもないですが…)

このテンプレートは一見すると使命を終えた感がありますが、それでも公式ツールのスコア計算を流用できるのでミスが少ないデプロイの方法をあらかじめ準備しておけるなど、実用的なメリットは残っています。

そこで今年は、これまで以上にAIに任せることをテーマに、Agent Skillsを実装しました。リポジトリに問題文と公式ツールを配置して、たった1コマンドで実行するだけ。Claude CodeやCodexが数分程度でビジュアライザを完成させてくれます。

注意: Agent Skills はローカルでコマンド実行やファイル操作を行うことがあります。第三者が作成した Skills を含め、実行前に SKILL.md とスクリプト内容を確認し、意図しない操作(ファイル削除・外部送信など)がないことを確かめてから使うのを推奨します。

実装したAgent Skills

Agent Skillsとは

Claude CodeやCodexには、Agent Skillsというカスタムコマンドを登録できる機能があります。.agents/skills/ディレクトリにSKILL.mdというファイルを置くだけで定義でき*1/コマンド名を実行するとAIがそこに書いた手順に従って自動でタスクを進めてくれます。「こういう順番でやってね」という指示書をあらかじめ用意しておけるイメージです。

make-visualizer スキル

このリポジトリに実装した /make-visualizer は、ビジュアライザの実装を丸ごと自動化するスキルです。問題文と公式ツール(テスターコード)さえ配置すれば、AIが以下の流れで進めてくれます:

  • 問題文と公式コードを読んで、ビジュアライザの設計案を提案
  • 承認後、Rustでビジュアライザを実装(状態管理・SVG描画)
  • Wasmにビルドして、ブラウザで動作確認できる状態に

実装したSKILL.mdはこちら。 使用方法の詳細はREADMEを確認してください。

実際にやってみる

AHC061を題材にして、実際にAgent SkillsのコマンドをCodexで実行してビジュアライザを実装してもらいましょう。 また、Claude Codeでも同様のやり方で実行できます。ビジュアライザ実装はそこまで難しいタスクではないと思われるので、モデルは速度重視の方がストレスがないかもしれません。

ファイルの準備・コマンドの実行

まずは問題の情報をエージェントに渡すために、問題文をproblem_description.txtにコピペをする & 公式から配られるtoolsフォルダをプロジェクトのルートフォルダに配置します*2。 この後にmake-visualizerコマンドを実行するとCodexが実装を開始してくれます。

コマンドを実行するとCodexがファイルを確認して作業を進めてくれる

設計案の確認

公式のテスタツールや問題文を読んで、ビジュアライザの設計案を出してくれます。 これを確認して問題なさそうだったら次に進みます*3

Codexが出してきた設計案を確認する

実装およびwasmのビルド

公式のテスタのコードをコピーした上でビジュアライザの描画のための関数の実装を行ってwasmのビルドまで行ってくれます。 これで完成です。合計で5分弱くらいの作業でした。

実装の作業の後にビルドをして、yarn devをすればビジュアライザが動く状態まで仕上げてくれる

完成したもの

まあよさそうかな...?

最後に

今年は最近(でもないか...)流行りのAgent Skillsを使ってビジュアライザをCoding Agentに実装させてみました。 ぶっつけ本番だと何かとトラブルはつきものですので、事前に試してみるといいかもしれません。また、Coding Agentの使用は自己責任でお願いします。 

今年のマスターズも対戦よろしくお願いいたします!

*1:これはCodexの場合です。Claude Codeは.claude/commands/コマンド名.md

*2:なおAHC061ではtools内にビジュアライザ用の関数が含まれていたので手動で削除しています

*3:私はいつも読まずに承認しています... 聞く意味ないような気もします

ビジュアライザ筋トレ2025年 chatGPT活用編

はじめに

こんにちは。AtCoderのコンテストに参加しているyunixです。 そろそろマスターズ選手権が近づいてきましたね。 マスターズ選手権では公式からビジュアライザが与えられないことが特徴的で、自前でビジュアライザを用意しないとかなり戦いにくいです。

スムーズにビジュアライザ開発をできるようにするため、昨年度に https://yunix-kyopro.hatenablog.com/entry/2023/12/17/150534 このような記事を書いていました。

大まかな内容としては、

  • AHCで公式から提供されるっぽいビジュアライザを作りやすくするために
  • 共通部分(入力欄・出力欄・スライダーなど)はReactによって記述
  • 問題固有の入力生成やビジュアライズ部分はRust(wasm)によって記述して、ここだけを問題に合わせて変えればビジュアライザが完成するようにする

というようなものでした。 問題固有の部分だけを変えればビジュアライザとして動作するので、そこそこ使いやすいものができたと思っています。

ただ、去年と比べて、生成AIによるコード生成の実力がだいぶ上がっていて、問題固有の部分を書くところは生成AIにやらせればかなりサボれるのではないかという気がしてきました。 そこで、上記のビジュアライザテンプレートをもとにして、問題固有の部分だけを変更するようにchatGPT o1にお願いして、どれだけいい感じにビジュアライザを作ってくれるかを検証してみました。

前提

  • 公式からRustのテスターや入力ジェネレーターは提供されるものとして考えます。
    • 問題文を読み込ませてスコア計算や入力ジェネレーターを自動で書いてもらうこともでき、そこそこまともに動く感じではありました
    • ただ、公式のツールの処理を貼った方がchatGPTが処理をすることが減って間違えにくくなりそうなので、公式のtools以下のソースコードをchatGPTに読み込ませます
  • chatGPTには(サンプルの)util.rs(こちらなど)を出力させます
    • 基本的にlib.rsなどはそのまま使用する想定ですが、parse_outputの引数や最大ターン数を取得する箇所などは変える必要があるかもしれません
  • chatGPTのバージョンはo1を使っています(課金版)

検証

過去のコンテストについて、以下のようなプロンプトを与えて、問題固有の部分をchatGPTに生成させてみました:

あなたにAtCoder Heuristic Contestのビジュアライザ・入力ジェネレーターの作成をお願いしたいです。
システムはReact + Rustによるwasmで構成されていて、概ね以下のような担当分けになっています:
React側: seed値・outputをtextareaから受け付けて、Rustに送る・Rustから受け取った入力ファイルをTextAreaに表示・Rustから受け取ったsvgを表示
Rust側: Reactから渡されたものに対して処理を行う: 
具体的には、
- seedの値に基づいて入力ファイルの作成
- 与えられた出力に基づいてビジュアライザの作成(svgの描画)、ターンごと
- 入力・出力を受け取って、最大のターン数を返す
ことを行なっています。
以下のコードはRust側の例で、インターフェースを変えずに(つまり、lib.rsの内容をほぼ変えずに)、別のコンテスト用のビジュアライザシステムの開発を行いたいです:

[lib.rs]
use wasm_bindgen::prelude::*;
mod util;

#[wasm_bindgen]
pub fn gen(seed: i32) -> String {
    util::gen(seed as u64).to_string()
}

#[wasm_bindgen(getter_with_clone)]
pub struct Ret {
    pub score: i64,
    pub err: String,
    pub svg: String,
}

#[wasm_bindgen]
pub fn vis(_input: String, _output: String, turn: usize) -> Ret {
    let input = util::parse_input(&_input);
    let output = util::parse_output(&_output);
    let (score, err, svg) = util::vis(&input, &output, turn);
    Ret {
        score: score as i64,
        err,
        svg,
    }
}

#[wasm_bindgen]
pub fn get_max_turn(_input: String, _output: String) -> usize {
    let output = util::parse_output(&_output);
    output.q
}

[util.rs]
#![allow(non_snake_case, unused_macros)]
use proconio::input;
use rand::prelude::*;
use std::collections::VecDeque;
use svg::node::element::{Rectangle, Style};
use web_sys::console::log_1;

pub trait SetMinMax {
    fn setmin(&mut self, v: Self) -> bool;
    fn setmax(&mut self, v: Self) -> bool;
}
impl<T> SetMinMax for T
where
    T: PartialOrd,
{
    fn setmin(&mut self, v: T) -> bool {
        *self > v && {
            *self = v;
            true
        }
    }
    fn setmax(&mut self, v: T) -> bool {
        *self < v && {
            *self = v;
            true
        }
    }
}

#[derive(Clone, Debug)]
pub struct Input {
    pub id: usize,
    pub n: usize,
    pub k: usize,
    pub s: Vec<String>,
}

impl std::fmt::Display for Input {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        writeln!(f, "{} {} {}", self.id, self.n, self.k)?;
        for i in 0..self.n {
            writeln!(f, "{}", self.s[i])?;
        }
        Ok(())
    }
}

pub fn parse_input(f: &str) -> Input {
    let f = proconio::source::once::OnceSource::from(f);
    input! {
        from f,
        id:usize,
        n: usize,
        k: usize,
        s: [String; n]
    }
    Input { id, n, k, s }
}

pub struct Output {
    pub q: usize,
    pub yxc: Vec<(usize, usize, usize)>,
}

pub fn parse_output(f: &str) -> Output {
    let f = proconio::source::once::OnceSource::from(f);
    input! {
        from f,
        q: usize,
        yxc: [(usize, usize, usize); q]
    }
    Output { q, yxc }
}

pub fn gen(seed: u64) -> Input {
    let mut rng = rand_chacha::ChaCha20Rng::seed_from_u64(seed);
    let id = seed;
    let n = 100;
    let k = 9;
    let s = (0..n)
        .map(|_| {
            (0..n)
                .map(|_| rng.gen_range(1..k + 1).to_string())
                .collect::<String>()
        })
        .collect::<Vec<_>>();
    Input { id: 0, n, k, s }
}

fn calculate_score(input: &Input, yxc: &Vec<(usize, usize, usize)>) -> (usize, Vec<Vec<usize>>) {
    let mut state = vec![vec![0; input.n]; input.n];
    input.s.iter().enumerate().for_each(|(y, s)| {
        s.chars()
            .enumerate()
            .for_each(|(x, c)| state[y][x] = c.to_digit(10).unwrap() as usize)
    });

    let x_vec: Vec<i32> = vec![0, 1, 0, -1];
    let y_vec: Vec<i32> = vec![-1, 0, 1, 0];

    for (y, x, c) in yxc {
        // state[*y][*x] = *c;
        let selected_color = state[*y - 1][*x - 1];

        let mut visited = vec![vec![false; input.n]; input.n];
        let mut queue: VecDeque<(usize, usize)> = VecDeque::new();
        queue.push_back((*y - 1, *x - 1));

        let mut count = 0;

        while queue.len() > 0 {
            let (ypos, xpos) = queue.pop_front().unwrap();
            if visited[ypos][xpos] {
                continue;
            }
            visited[ypos][xpos] = true;
            state[ypos][xpos] = *c;

            count = count + 1;
            for i in 0..4 {
                let nx = xpos as i32 + x_vec[i];
                let ny = ypos as i32 + y_vec[i];
                if nx < 0 || ny < 0 || nx >= input.n as i32 || ny >= input.n as i32 {
                    continue;
                }
                let nx = nx as usize;
                let ny = ny as usize;
                if visited[ny][nx] {
                    continue;
                }

                if state[ny][nx] != selected_color {
                    continue;
                }
                queue.push_back((ny, nx));
            }
        }
    }

    let mut score = 0;
    for color in 1..(input.k + 1) {
        let mut tmp_score = 0;
        for y in 0..input.n {
            for x in 0..input.n {
                if state[y][x] == color {
                    tmp_score += 100;
                }
            }
        }
        score = score.max(tmp_score);
    }
    score -= yxc.len();

    return (score, state);
}

fn generate_dark_color(code: usize) -> String {
    // 入力値に基づいてHue(色相)を計算
    let hue = (code as f32 * 36.0) % 360.0;

    // Saturation(彩度)を低めに、Lightness(明度)を固定値で低く設定
    let saturation = 30.0;
    let lightness = 30.0;

    // HSL to RGB 変換
    let hue_normalized = hue / 360.0;
    let q = if lightness < 0.5 {
        lightness * (1.0 + saturation)
    } else {
        lightness + saturation - (lightness * saturation)
    };

    let p = 2.0 * lightness - q;

    let r = hue_to_rgb(p, q, hue_normalized + 1.0 / 3.0);
    let g = hue_to_rgb(p, q, hue_normalized);
    let b = hue_to_rgb(p, q, hue_normalized - 1.0 / 3.0);

    // RGB を 16 進数に変換して文字列を返す
    format!(
        "#{:02X}{:02X}{:02X}",
        (r * 255.0) as u8,
        (g * 255.0) as u8,
        (b * 255.0) as u8
    )
}

fn generate_color(code: usize) -> String {
    // 入力値に基づいてHue(色相)を計算
    let hue = (code as f32 * 36.0) % 360.0;

    // Saturation(彩度)とLightness(明度)を固定値で設定
    let saturation = 10.0;
    let lightness = 0.1;

    // HSL to RGB 変換
    let hue_normalized = hue / 360.0;
    let q = if lightness < 0.5 {
        lightness * (1.0 + saturation)
    } else {
        lightness + saturation - (lightness * saturation)
    };

    let p = 2.0 * lightness - q;

    let r = hue_to_rgb(p, q, hue_normalized + 1.0 / 3.0);
    let g = hue_to_rgb(p, q, hue_normalized);
    let b = hue_to_rgb(p, q, hue_normalized - 1.0 / 3.0);

    // RGB を 16 進数に変換して文字列を返す
    format!(
        "#{:02X}{:02X}{:02X}",
        (r * 255.0) as u8,
        (g * 255.0) as u8,
        (b * 255.0) as u8
    )
}

fn hue_to_rgb(p: f32, q: f32, t: f32) -> f32 {
    let t = if t < 0.0 {
        t + 1.0
    } else if t > 1.0 {
        t - 1.0
    } else {
        t
    };

    if t < 1.0 / 6.0 {
        p + (q - p) * 6.0 * t
    } else if t < 1.0 / 2.0 {
        q
    } else if t < 2.0 / 3.0 {
        p + (q - p) * (2.0 / 3.0 - t) * 6.0
    } else {
        p
    }
}

pub fn rect(x: usize, y: usize, w: usize, h: usize, fill: &str) -> Rectangle {
    Rectangle::new()
        .set("x", x)
        .set("y", y)
        .set("width", w)
        .set("height", h)
        .set("fill", fill)
}

pub fn vis(input: &Input, output: &Output, turn: usize) -> (i64, String, String) {
    let (score, state) =
        calculate_score(input, &output.yxc[0..turn].into_iter().cloned().collect());

    let W = 800;
    let H = 800;
    let w = 8;
    let h = 8;
    let mut doc = svg::Document::new()
        .set("id", "vis")
        .set("viewBox", (-5, -5, W + 10, H + 10))
        .set("width", W + 10)
        .set("height", H + 10)
        .set("style", "background-color:white");

    doc = doc.add(Style::new(format!(
        "text {{text-anchor: middle;dominant-baseline: central; font-size: {}}}",
        6
    )));
    for y in 0..input.n {
        for x in 0..input.n {
            doc = doc.add(
                rect(
                    x * w,
                    W - (y + 1) * h,
                    w,
                    h,
                    &generate_dark_color(state[y][x]),
                )
                .set("stroke", "black")
                .set("stroke-width", 1)
                .set("class", "box"),
            );
        }
    }

    (score as i64, "".to_string(), doc.to_string())
}


上記の情報を参考にして、この次に与えるAtCoder Heuristic Contestの問題のビジュアライザのためのutil.rsを書いてください。
ただし、元々のutil.rsの構造を大きく変えないで欲しいです:
- Input, Output構造体を作る
- Input,Outputに実装したトレイトは必ず実装する(特にDisplayを忘れがち)
- 適切にコメントを入れる
- 入力生成方法は簡易化せずに厳密に指定に従う必要があります
- 同じlib.rsを使うので、util.rsのインターフェースを変えることは禁止
- Rustのクレートは以下のバージョンのものを使用する:
wasm-bindgen = "0.2.89"
getrandom = {version="0.2", features=["js"]}
rand = { version = "=0.8.5", features = ["small_rng", "min_const_gen"] }
rand_chacha = "=0.3.1"
rand_distr = "=0.4.3"
itertools = "=0.11.0"
proconio = { version = "=0.4.5", features = ["derive"] }
clap = { version = "4.0.22", features = ["derive"] }
svg = "0.17.0"
delaunator = "1.0.1"
web-sys = {"version" = "0.3.44", features=['console']}

ただし、以下のコードを踏襲してInput, Output, genなどを書いてください。

[ツール類]
公式から配布されるtools/src/lib.rsをコピペする

[問題文]
AtCoderのサイトからコピペ

[ビジュアライザの仕様]
問題ごとにこのようにビジュアライザを作って欲しいという仕様を書く

上記のプロンプトのうち、

  • [ツール類] の箇所に公式から配布されるtools/src/lib.rsをそのままコピペする
  • [問題文] の箇所に問題文をそのままコピペする
  • [ビジュアライザの仕様]の箇所にこのようにビジュアライザを作って欲しいということを指定する

の3箇所を変更して、chatGPTに投げます。

結果

マスターズ2024 予選

やりとり: https://chatgpt.com/share/67738cff-2a28-8010-bc34-5a3865e6b773

[ビジュアライザの仕様]

  • グリッドの壁を太い黒線で表現する
  • セルの中には数値を書く、また数値に対して色のグラデーションをつけてセルを塗る(赤: 大きい、青: 小さい)
  • ターンは1回の行動(1~3)を表現する

色が見にくいですが、まあ普通に使えそうなものが出力されています。 ただし、微妙に文法エラーがあったり、少し手で直すところもありました。

マスターズ2024 決勝

やりとり: https://chatgpt.com/share/67738da0-1c94-8010-92ec-9c6ee62589d9

[ビジュアライザの仕様]

  • 一回の行動ごとにターンとする
  • 各ターンの時点での位置を黒い点で表現する
  • 壁を黒いせんで表現する
  • 目的地を赤い丸で表現する
  • 位置や速度については問題で指定される誤差を計算の上 (※ ここでミスってエンターを押してしまったのですが、問題なくできました)

これもまあ必要最低限の機能はついているかなと思います。10分もかからずにできてしまいました。

雑多なメモ

  • ビジュアライザの仕様ではターンの考え方について教え込ませることが重要そう
    • 一つの解が1ターンなのか、解の中の進捗が1ターンなのか?
  • chatGPTはsvg::Text::newの使い方をよくミスるので手動で修正をしていた。プロンプトに使い方を入れた方がいいかもしれない。
  • ビジュアライザの仕様で指定していないことはほぼ何もやってくれないので、一旦作って直したいところを思いついたら、再度コードを書かせるのが良さそう

最後に

去年のマスターズの予選と決勝の例で試してみましたが、それぞれ10分弱でビジュアライザができてしまいました。 (細かい文法エラーなどを治していたら合計で10分くらいかかった) こういう限定的な内容を書けばいい場合には生成AIの強さがありそうな感じがありました。 今年のマスターズは圧倒的なスピードでビジュアライザを作ってライバルに差をつけましょう!

でぶアドベントカレンダー 23日目 でぶヒューリスティックコンテストの問題を考えた

はじめに

メリーでぶばんわ~(メリでぶ) この記事はでぶ Advent Calendar 2024 の23日目の記事です。

今年はchatGPTと相談しながらshojin_debuをテーマにして、ヒューリスティック風の問題を作ってみました。 ビジュアライザも作りました!

問題文 「でぶとダンジョン飯

shojin_debuはダンジョンに潜ってグルメを堪能することを思いついた。

このダンジョンは H×W の格子状の部屋からなり、各部屋同士は通路で繋がっている。各部屋には食事が置いてあり、食べると shojin_debu は太る(体重が増加する)。

ただし、ダンジョンを通る際には様々な注意点がある

  • 各ターンで「移動先の方向を決める」 → 「移動先にある食事を食べるか決める」という流れで行動する
  • ダンジョンの通路は狭いため、通路ごとに設定された体重制限を超えると、shojin_debuが通路につっかえてしまい、通過できない
  • shojin_debuが部屋に一度入ると、部屋が重みに耐えかねて床が抜けるため、もう一度入ることができない
  • 部屋にある食事は食べなくてもよいが、食事をスルーすると shojin_debuの不満が溜まり、最終スコアに影響する
  • shojin_debu は特殊能力「なんや😡😡😡」を K 回(K は入力で与えられる最大回数)まで使用することができ、通路の壁を破壊して体重制限を回避することができる。ただし、この能力には回数上限があるため、無闇に使用することはできない

床が抜けて落ちるでぶ

shojin_debu は (スタート部屋) から出発し、できるだけ多くの食事を食べながら (ゴール部屋) に到達したい。あなたの目的は、shojin_debu の最終的な体重を最大化しつつ、ゴールに到達した場合のスコアを高めることである。

入力

入力は以下の形式で与えられる

H W 
sx sy gx gy 
K 
c_{0,0} c_{0,1} ... c_{0,W-1} 
c_{1,0} c_{1,1} ... c_{1,W-1} 
... 
c_{H-1,0} c_{H-1,1} ... c_{H-1,W-1} 
h_{0,0} h_{0,1} ... h_{0,W-2} 
h_{1,0} h_{1,1} ... h_{1,W-2} 
... 
h_{H-1,0} h_{H-1,1} ... h_{H-1,W-2} 
v_{0,0} v_{0,1} ... v_{0,W-1} 
v_{1,0} v_{1,1} ... v_{1,W-1} 
...
v_{H-2,0} v_{H-2,1} ... v_{H-2,W-1}
  1. 1 行目:
    • ダンジョンの縦幅 (H) 、横幅 (W)
    • 10 <= H, W <= 20
  2. 2 行目:
    • スタート部屋の座標 (sy, sx) とゴール部屋の座標 (gy, gx)
    • 0 <= sx, gx < H, 0 <= sy, gy < W)
    • (sx, sy)と(gx, gy)は相異なる
    • なお、マップの左上の地点を(0, 0)と定め、下方向に行くとx、右方向に行くとyの座標の値が大きくなる
  3. 3 行目:
    • 「なんや😡」特殊能力を使用できる最大回数 (K)
    • 0 <= K <= 5。
  4. 続く (H) 行:
    • 各部屋に置いてある食事のカロリー (c_{i,j})
    • 100 <= c_{i,j} <= 10000
    • 食事を食べた場合には即座にカロリーの分だけshojin_debuの体重が増加する
    • カロリーの分布はλ=1/2000の指数分布に従い、上限値・下限値を外れたものはクリッピングされる
  5. 次に (H) 行:
    • (W-1) 個の整数 (h_{i,j}) (通路の横方向の制限体重)
    • (h_{i,j}) は (i, j) と (i, j+1) を結ぶ通路の最大通過体重(kg)
    • 10000 <=h_{i,j} <= 50000。
  6. さらに (H-1) 行:
    • W 個の整数 v_{i,j} (通路の縦方向の制限体重)
    • v_{i,j} は (i, j) と (i+1, j) を結ぶ通路の最大通過体重(kg)
    • 10000 <= v_{i,j} <= 50000

なお、記載がない場合には一様分布からサンプリングされる。

出力

以下の形式で shojin_debu の行動計画を出力せよ。

  1. 最初に「なんや😡」能力の使用回数 (X) を出力

    • 0 <= X <= K
  2. 続いて (X) 行にわたり、破壊する通路の情報を出力

    • 破壊する通路が結ぶ部屋の座標を出力する
    • 例: y1 x1 y2 x2(通路の座標を指定する形式)
  3. 次に、shojin_debu の合計行動回数 (M) を 1 行で出力

  4. その後、各行動を 1 行ずつ合計 (M) 行出力

    • 各行動は以下のいずれかを含む形式とする。
      • L / R / U / D(左・右・上・下への移動)
      • debu(その部屋の食事を食べる、食べたカロリーの数値分だけ即座に体重[kg]が増加する)
      • NANNYA!!!(食べない場合)

出力例は以下である:

2          // 「なんや😡」を実際に2回使用する
2 1 2 2    // (2,1)-(2,2) 間の通路を破壊
1 1 2 1    // (1,1)-(2,1) 間の通路を破壊
5          // 行動数は5回
R debu     // 右に移動してその部屋の食事を食べる
D debu     // 下に移動してその部屋の食事を食べる
L NANNYA!!!  // 左に移動して食べなかった
U debu     // 上に移動して食べる
R debu     // 右に移動して食べる

スコア

スコアは以下の式で与えられる。

max((最終体重) - 50000*(不満度)/(H*W), 0) (ゴールに到達した場合)
0 (ゴールに到達しなかった場合)

ここで、「不満度」は、部屋に入った際に食事をスルーした回数(=食事があるのに食べなかった回数)の合計とする。 また、最終体重は、食べた食事のカロリーの総和である。

なお、同じ部屋を2回以上訪問したり、通路の制限体重を超過した状態で移動をしようとした場合には不正解(WA)扱いとなり、スコアは 0 となる。

ビジュアライザ

こちらにビジュアライザを用意しました。

壁を破壊するshojin_debu
seed=2のサンプル出力を以下に貼っておきます

3
8 8 9 8
6 9 6 10
7 5 8 5
52
R debu
D NANNYA!!!
R NANNYA!!!
U NANNYA!!!
U debu
R debu
D debu
D NANNYA!!!
D debu
L NANNYA!!!
D debu
R debu
D debu
L debu
L debu
U NANNYA!!!
L debu
U debu
U NANNYA!!!
L NANNYA!!!
U NANNYA!!!
U debu
R NANNYA!!!
U NANNYA!!!
R NANNYA!!!
U debu
L NANNYA!!!
L NANNYA!!!
U NANNYA!!!
L NANNYA!!!
L NANNYA!!!
L debu
L NANNYA!!!
L debu
D NANNYA!!!
L NANNYA!!!
L debu
D NANNYA!!!
L NANNYA!!!
D debu
D debu
R debu
R debu
R NANNYA!!!
R NANNYA!!!
U debu
R debu
D NANNYA!!!
R debu
D debu
D debu
R debu

最後に

来年はAHCで作問できるといいなあ。いい原案を思いついたら頑張って調整をしてみようと思います。

没問題 「shojin_proの焼肉パーク計画」

shojin_proの焼き肉パーク計画

施設配置問題+ベイズ推定みたいな問題でした。

  • プレイヤーは焼き肉パークに焼き肉施設(焼き肉・トレーニングルーム・ダイエット食売り場)などを配置していく
  • shojin_proには空腹度・怒り度・体重などの内部パラメータが設定されており、内部パラメータや施設の位置関係に従って行動をする
  • shojin_proの行動から内部パラメータを推定し、できるだけ太らないように施設を配置していく

感じの問題でした。インタラクティブなビジュアライザを作るのがめんどくさすぎたので没になりました。 個人的には問題設定が気に入っているのですが、調整が難しそう。 あと推定系の問題は単純に作るとワンパターンな感じになってしまうので難しいですね。

AtCoderのジャッジ環境と同じEC2インスタンスを借りる件

はじめに

こんにちは。競技プログラミングのコンテストに参加しているyunixです。 AHC033楽しかったですね。盤面が小さなゲームなのにこんなに難しいなんて最初に見た時には思いもよらなかったです。組み合わせの偉大さを身に沁みて感じました。

さて、今回のコンテストではビームサーチを使用しました。ビームサーチは一般的に速度の調整が焼きなましのような局所探索系の手法より難しく、TLEしないか心配でした。 一応今までの処理にかかった時間からビーム幅を変更するような処理は入れていたのですが、念のためにAtCoderのジャッジ環境と同じ環境をAWSに借りて実行して時間を計測してみました。

今回と同じように問題の種類によってはジャッジ環境に近い環境を借りたくなることがあるので、AtCoder環境と同じCPUを持った環境を借りてVSCode上でリモートSSHによってEC2上で開発やテストの実行などを行う方法についてメモを残しておきます。(システスを待つ時間が暇なのでw)

※注意 クラウドを使用するには料金がかかります。また、キーペアの管理などを適当にするとセキュリティのリスクもあります。使用の際には十分に気をつけましょう。 また、コンソールのデザインや手順などは時代とともに変わりうるものです。今と違っている可能性があるので注意してください。

AtCoder環境を調べる

昔のあーだこーだーでchokudaiさん?がbashを使ってコードテストからジャッジ環境のCPU調べられるよー、って言っていたのを覚えています(記憶違いかな...?)。 具体的には、bash

cat /proc/cpuinfo

を実行します。 実行すると以下のような結果になります:

cpu情報の検索結果

これを見ると、

Intel(R) Xeon(R) Platinum 8375C CPU @ 2.90GHz

というCPUを使用していることがわかります。 AWSで該当のCPUを持つインスタンスを調べると、m6i.largeというインスタンスであるという情報がありました。*1 というわけでm6i.largeのインスタンスを借りて、sshで繋げば良さそうです。

m6iのインスタンスを立ち上げる

m6iのEC2インスタンスを借ります。ちなみにm6iは一日あたり3ドルかかります(円安のせいで500円くらい)。

  • EC2のコンソールに行きます
  • インスタンスを起動ボタンを押します

  • インスタンスに適当な名前をつけて、マシンイメージを選択します。Amazon Linuxで良いでしょう(料金も安いので...)

  • インスタンスタイプでm6i.largeを選択します

  • 新しいキーペアを作ります 作成ボタンを押すと、ssh秘密鍵が自動でダウンロードされます。この秘密鍵を使用してsshでリモートに接続するので、大事に保管しましょう。 他の人が見れるところにおいたり、無くさないようにしましょう。

  • セキュリティグループを作ります この際に、トラフィックを許可する場所を任意の場所ではなく、自分の使用しているIPアドレスを指定しておきましょう(無用のリスクは避けましょう)。

以上の設定でインスタンスの作成ボタンを押すと、インスタンスが作成されます。

VSCodeで接続する

インスタンスが起動されると以下のような画面が出てきます。このインスタンスに接続するのボタンを押すと参考になる情報が出てきます。

  • 先ほどダウンロードした秘密鍵を~/.ssh/に移動させます
  • ~/.sshで以下のコマンドを実行します
chmod 400 "秘密鍵名"
  • ~/.ssh/configにホストの情報を登録
Host ホスト名(ec2-user@....ap-northeast-1.compute.amazonaws.comみたいなやつ)
  HostName ホスト名(上記と同じ)
  IdentityFile ~/.ssh/"秘密鍵名"
  User ec2-user

ホストに接続するを選択すると、上記で設定したホスト名が出てくると思います。それを選択すれば借りたEC2インスタンス上のファイルをVSCodeで触れるようになります。ターミナルも動くので自分でジャッジ環境に近い環境で色々できます(一日500円)。

色々インストールをする

最初では何も入っていないので色々入れましょう。

  • g++とclang
sudo yum install g++ clang 

これでC++の環境は整います

  • screen リモートログインする時にはscreenを使用しています(tumuxみたいなやつ)。 セッションが切れても実行中のプロセスを動かし続けてくれます。
sudo yum install screen

設定はいつもこれを使っています: .screenrcにこれだけは設定しとけっていうオススメ設定 #Bash - Qiita

あとはやりたい放題です。

片付けをする

AWSにリソースを借りたら料金が発生します。 使用し終わったら必ずリソースを削除しましょう。 今回の課金対象のものはEC2インスタンスだけですが、同時にセキュリティグループとキーペアも作成しているので、不要になったら消してしまいましょう。

最後に

AtCoderのジャッジ環境と同じようなインスタンスを用意する方法を個人的な備忘録を兼ねて書きました。 記述にまずいところがあってもAWSでの実行は自己責任でよろしくお願いします〜 (コメントでご指摘いただければ助かります) あと、EC2インスタンスを必ず消すようにしましょう。破産するほどではないですが、一月放置すると100ドル近く料金がかかり悲しいことになります。

でぶアドベントカレンダー2023 12月21日分 お絵描きでぶ体験入部

お絵描きでぶ体験入部

こんにちは。これはでぶアドベントカレンダー2023(https://adventar.org/calendars/8539 )の記事です。

今年はお絵描きでぶをやってみようと思います。 AHCのビジュアライザで(に?)shojinさんを書いて動かしてみました。

オフィスでおばけを捕まえる

おばけを捕まえる
(上位の方のソースコードを拝借して解を出しました。ありがとうございます。)

分裂するおばけ

分裂するおばけ
おばけっぽい行動をしている

並び替えをするおばけ

並び替えをする😡
色が同じでわかりにくい(よく目を凝らすと数字が徐々に揃っていくはず...)のと縦横比ミスりました。

連結するおばけ

連結するおばけ
酔いそうでかわいそう

震えるおばけ

震えるおばけ
どれが推しのshojinさんですか?

最後に

ビジュアライザを改造して遊ぶ方法の記事を最近書きました。マスターズ選手権に向けてビジュアライザ筋トレをしよう! yunix-kyopro.hatenablog.com

ヒューリスティックコンテストでビジュアライザを開発する方法に関するメモ

はじめに

AtCoderなどの競技プログラミングのコンテストに参加しているyunixです。 来年に開催されるマスターズ選手権、楽しみですね。マスターズ選手権は28歳以上の2~3人の団体戦で、時間が6時間のヒューリスティックコンテストになるそうです。

このコンテストの大きな特徴の一つはビジュアライザが提供されないことが明言されていることです。 ヒューリスティックコンテストではビジュアライザを使用して考察することがとても大事なステップになります。 従って、参加者自身でビジュアライザを開発することになると思うのですが、ビジュアライザの開発の準備をしていなければ

  • 開発に時間がかかってしまう
  • 使用感や機能がいまいち

などの点で辛い戦いになってしまいそうです。

そこで、

  • いつものAHCと同じような使用感でビジュアライザを使える
  • コンテストの問題に合わせて最低限のところだけを編集すれば使えるようになる

ようにするために、ReactとRustを使ってビジュアライザ開発用のテンプレートを作りました(Reactに書き換えてはいるもののほぼ公式のビジュアライザを移植しただけなので大したことはやっていないのですが...)。 基本的にReactの部分は触らずに、Rustで問題ごとのビジュアライザや入力作成機能を作ればいつもの感じでビジュアライザが使えるようになっています。

この記事では作成したテンプレートの解説をしようと思います。(使用方法はリポジトリのreadmeを参照して下さい)

github.com

追記(2024/3/2)

最近のAHC(AHC030など)では乱数生成などのクレートのバージョンが新しくなっていて、上記のリポジトリのCargo.tomlの内容では提供されたツールをコピペしても文法エラーになる可能性があります。 最近のAHCの配布ツールに合わせてクレートのバージョンを変更して使ってください。

AHC030では以下のもので配布されたツールをコンパイルすることができました。getrandom = { version = "0.2", features = ["js"] }の箇所が多分重要で、wasmにするのに新たにこの依存関係が必要です。

[package]
name = "rust"
version = "0.1.0"
edition = "2021"

# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[lib]
crate-type = ["cdylib"]

[dependencies]
wasm-bindgen = "0.2.89"
getrandom = { version = "0.2", features = ["js"] }
rand = { version = "=0.8.0", features = ["small_rng"] }
rand_chacha = "=0.3.1"
rand_distr = "=0.4.3"
itertools = "=0.11.0"
proconio = { version = "=0.3.6", features = ["derive"] }
clap = { version = "4.0.22", features = ["derive"] }
svg = "0.9.0"
delaunator = "1.0.1"
web-sys = {"version" = "0.3.44", features=['console']}

サポートしている機能

作成したビジュアライザのウェブアプリの画面
AHCのビジュアライザで提供している機能を参考に作りました(レイアウトも大体同じにした):

  • output欄に入力した出力に対応する画像・スコアを表示する機能
  • seedを指定して入力を作成する機能
  • 複数のアウトプットを含むフォルダをアップロードして、各出力の内容を表示する機能
  • 複数の入力を作成してzipでダウンロードする機能
  • png・gifで保存する機能
  • ターンがある場合にはターンごとにスライダーで表示する機能

いつものビジュアライザの機能をほぼそのままReactに持ってきたような感じです(そのまますぎて怒られないかな...?)。 特に画像や動画の保存機能などについては処理をかなり参考にしました。

ビジュアライザの機能の何がコンテストごとに固有のものか?

前述のような機能を実現しようと思ったときに、何が特定のコンテストに固有の処理で、何がコンテストによらずに共通で使えるかということを考えることは重要です。 コンテスト中にビジュアライザを作成しなければいけないため、極力使いまわせるところは使い回して、コンテストごとに固有の事情に集中できるようにしたいためです。 例えば、スライダーを動かしたり、フォルダのアップロード機能、入力のダウンロード機能などは使いまわせそうです。 一方で、入力を作成する機能や、出力に応じてスコアをつけたり画像を出力する機能はコンテストごとに作らなければいけません。

このように考えると、以下の3つの機能をコンテストごとに実装すれば良さそうだという結論に至りました(AHCで提供されているビジュアライザは大体そうなっているので踏襲しただけとも言えるのですが...):

  • seedの値が与えられたときに、入力ケースのstringを作成する機能
  • 入力・出力から最大のターン数 (スライダーを使う場合など)を返す機能
  • 入力・出力・スライダーのターンを指定したときにスコア・表示する画像を返す機能

これら3つの機能をRustの関数として記述して、WebAssemblyでJavaScriptから呼び出せるようにするだけで、大体いつものビジュアライザと同じような使用感で使えるようになります。 React側のフロントエンドについては、何か編集したい事情(ほかの画像を生やしたい、入力を増やしたいなど)がない場合には特に触る必要はありません。

使用している技術

フロントエンドをReact、コンテストごとに固有の部分をRustで作成してWebAssemblyでJavaScriptから使用できるようにしています。

このようになっている理由はいくつかあり、

  • AtCoderのコンテストではローカルテスタや入力作成機能がRustで提供されることが多いです。そのため、マスターズ選手権でこれらが提供される場合(されるか現時点では未定)、これらの処理をそのまま再利用できると大変有利になります。
  • AHCのビジュアライザがこの構成になっているので踏襲した
  • 個人的にRustとWebAssemblyを勉強したかった

従って、コンテストごとに固有の処理の部分を必ずしもRustで書く必要はなく、何で書いても大丈夫ではあります。 特に、インタラクティブな操作がビジュアライザに必要な場合にはReactで処理を書いた方が良い可能性もあります。必要に応じて書き換えましょう。

RustのWeb Assemblyについて

RustのWebAssemblyを扱うために、wasm-bindgenというモジュールを使っています。

Rust側で

#[wasm_bindgen]
pub fn gen(seed: i32) -> String {
    // 処理
    "結果".to_string()
}

このように"#[wasm_bindgen]"をつけて関数などを記述し(構造体とかでも良い)、wasm-packを使用してRustをwasm化

wasm-pack build --target web

すると、JavaScriptの方でこのように呼び出すことができる関数が生成されます。

    const inputText = gen(seed);

基本的には、 https://github.com/yunix-kyopro/visualizer-template-public/blob/main/wasm/src/lib.rs ここの3つの関数(gen, vis, get_max_turn)を編集して、JavaScriptから呼び出すことができるようにします。

ビジュアライザ開発のサンプル

前述したように、lib.rsの

  • gen関数: seed値を与えられたときに入力ケースのstringを返す関数
  • get_max_turn関数: 入力・出力のstringを与えられたときに最大のターン数を返す関数
  • vis関数: 入力・出力・ターン数を与えられたときに、スコア・エラー文・svgの画像のstringをRetという構造体で返す関数

をRustで実装すればいいです。 ここではyukicoder score contest 2を題材にしてこれらの関数を実装しました。

実装はこちらのブランチにあります: GitHub - yunix-kyopro/visualizer-template-public at yukicoder-score-contest-002

mainブランチから変更されたファイルは以下の2つです:

util.rsに問題の固有の処理(入力生成や画像生成など)を書き、lib.rsからそれを呼び出す形式で実装をしています。

util.rsの処理は、普段配布されているローカルテスタ(のlib.rs)の処理の内容をかなり踏襲していて、

  • Input, Outputの構造体、特にDisplayやproconioを使ってstringから構造体を作成するところなど
  • vis関数、特にsvgを作成する処理

などは記述方法をかなりそのまま持ってきています。svgの記述方法などは特に参考になり、作りたいビジュアライザに近い回のローカルテスタのvis関数の内容を参考にすると良いと思います。

これらだけを編集して、wasmをビルドすると、yukicoder score contest 2の内容のビジュアライザを使用できます。 こちらにseed=0のサンプル出力を用意したので、動かしてみましょう。

開発に関する注意など

場合によっては一部の処理を実装することをサボっても良いかもしれません。 例えばサンプルケースがファイルとして配布されるようなときには、gen関数の実装はサボることも十分に考えられます。 また、スコア計算も実装がめんどくさくなりがちなので、サボってビジュアライズ部分だけを作るなどのこともあり得ると思います。

ビジュアライザ筋トレをやろう

ビジュアライザを短期間のコンテストで開発するとなると、相応に準備と練習をしておいた方が良いです。 ここではこのツールを使う練習の方法について記述したいと思います。 動かすために必要な環境などはリポジトリのreadme.mdを参照してください。

ステップ1: サンプルの実装のブランチを動かす

yukicoder score contest 2の他にも、chokudai contest 005の実装を用意しました。 これらのブランチをチェックアウトして、

cd wasm && wasm-pack build --target web --out-dir ../public/wasm && cd ..

を実行してwasmをビルドし、

yarn dev

でビジュアライザを動かしてみましょう。

動いたらutil.rsのvis関数の内容を変えて、表示される内容を変えてみると良いかもしれません(その際にはwasmのビルドを忘れないようにしてください)。

ステップ2: 既存のAHCのビジュアライザを再現する(ローカルテスタを再利用)

次に、mainブランチのテンプレートから開発を始めて、既存のAHCのビジュアライザを再現してみましょう。 既存のAHCのビジュアライザを再現することで、どこで何を書けばいいのかを把握しましょう。

触るべき場所はlib.rsとそこから呼び出すモジュール(util.rsなど)です。 AHCのローカルテスタのlib.rsの中身を再利用するととても簡単にできます(引数などを調整しないといけないことはありますが、基本コピペをして、lib.rsから呼び出せばよい)。

ステップ3: ヒューリスティックコンテストのビジュアライザを一から実装する

コンテストで使用することを想定して、1からビジュアライザを作ってみましょう。 1からとは言っても処理を踏襲できるところは多いはずです。 これを素早くできるようになれば実践でも実用的になるはずです。

そのほかのアイデア

  • CICDと組み合わせてどこかにホスティングしてチームメンバーが使えるようにするのも良いでしょう。Github Pagesなど? (料金かかるけど...)
  • スコア計算を書くところは、Rustに慣れていないと結構バグらせてしまうかもしれません。ビジュアライズ部分は基本的にとても簡単なので、スコア計算を無視してビジュアライズだけしてもいいかもしれません。
  • reactを使っていますが、実はwasmを使うところのアイデアや関数の切り分けのアイデアはAHCのビジュアライザとほぼ同じです。従って、公式からビジュアライザのhtmlをダウンロードしてきて、wasm部分を同様に入れ替えることで、reactを使わずに同じことをやることもできます。
  • 何がターンとなるのかはコンテストごとにまちまちです。例えばAHC006だと複数ルートを出力して、それをターンごとに動かす機能の方がありがたいと思います。この辺りはRustの方の処理でターンが何かを解釈することで対応できるかなと思います。

ホスティングに関する追記

作ったビジュアライザはwebを経由してチームメンバーと共有できると楽です(ローカルにcloneしてきて動かすでも悪くはないと思うのですが...)。 この際に気をつけないといけないことは、

  • 認証機能が必要
    • 他のチームに情報が漏れるのはレギュレーション違反なので、最低限の認証は必要だと思います
    • とはいえ漏れたら重大な問題が起きる、というほどのことではないので、BASIC認証(ユーザー名+パスワード)をつけておけば、マスターズ選手権の用途には十分なのかなと思います
  • デプロイが素早くできる
    • デプロイは可能な限り素早くできると嬉しいです
    • コンテナをビルドするので、デプロイに数分かかる、みたいな事態は避けたいです

こう考えた時に良さそうなデプロイ先としてVercelがありました。 vercel.com

Vercelは

  • Github上でCICDの構築を勝手にやってくれる
    • ローカルで変更をしてGithubにpushしたら勝手にデプロイしてくれる
  • デプロイは素早い
  • BASIC認証をつけるのが簡単

などの点でマスターズ選手権向けにはとても良さそうでした。 以下ではこの記事のテンプレートをvercelにデプロイして、BASIC認証をつけるために必要なことを書こうと思います。

とはいえ、vercelはすごく簡単で、Githubと連携してアカウントを作り、指示に沿って作業をするとフロントエンドのページをデプロイできてしまいます。 vercel自体の使い方についてはtutorial等を参考にしてください。

設定はこのようにするとうまくいくと思います: Viteを選択して、ビルドはtsc & vite buildで実行してください。

以下ではテンプレートに対する変更に限って記述をしようと思います。 やるべきことは3つあり、

  • wasmをビルドして、public/wasm以下のファイルをGitの管理下に置く

テンプレートではwasmのビルド結果はgitignoreでリポジトリから除外されているので、あらかじめビルドしてリポジトリの中に入れておく必要があります。 (やろうと思えばvercelでwasmのビルドができる気がするのですが、ローカルでやってしまうのが簡単だと思います)

  • @vercel/edgeを追加する
yarn add @vercel/edge

コマンドを実行して、@vercel/edgeを追加しましょう。 これはBASIC認証をつけるために必要で、以下の記事を参考にしました。 zenn.dev

  • middleware.jsを追加する

リポジトリのルートにmiddleware.jsを追加します(これも上記の記事を参考にしました)。 userとpasswordは適切に変更してください。

import { next } from '@vercel/edge';

export const config = {
  matcher: '/',
};

export default function middleware(req) {
  const authorizationHeader = req.headers.get('authorization');

  if (authorizationHeader) {
    const basicAuth = authorizationHeader.split(' ')[1];
    const [user, password] = atob(basicAuth).toString().split(':');

    if (user === 'ここを変更する!!!!' && password === 'ここを変更する!!!!') {
      return next();
    }
  }

  return new Response('Basic Auth required', {
    status: 401,
    headers: {
      'WWW-Authenticate': 'Basic realm="Secure Area"',
    },
  });
}

これはEdge Middlewareという技術を使っているそうです。

Edge Middleware is code that executes before a request is processed on a site

こう書いてあるので、ページの前にIDとパスワードを要求しているような処理を実行していることになります。

以上の3つの事項を実装すると、BASIC認証がついたページをデプロイすることができました。(この範囲だと無料プランでできるはず...)

最後に

マスターズ選手権が楽しみすぎてもう準備を始めてしまいました。 この記事はビジュアライザをどう書けばいいか悩んでいる方へのヒントになればいいかなと思います。

個人的にもRustとWebAssemblyを触る良い機会になったと思います。3月までにRustをたくさん練習しておきます。 (チームメンバーも探さなきゃ... 声をかけていただけると嬉しいです)

質問などがありましたらコメント欄やtwitterにご連絡いただければと思います。

マラソン系コンテストでソースコードを分割して書く方法のメモ(C++)

はじめに

こんにちは。競技プログラミングのコンテストに参加しているyunixです。

ラソン系のコンテストに参加していると実装が複雑になりがちで、ソースコードが数千行くらいになることもあります。 一方でAtCoderをはじめとしたコンテストでは提出を一つのソースコードにしなければいけません。

いきおい一つのファイルにたくさんのクラスや関数を定義することになるのですが、

  • 実装箇所を探すときにファイルの上下移動が多く発生してややこしい
  • 使わなくなったんだけれど参考までに残しておきたい箇所が放置され消していいのかわからなくなる

などの点で認知負荷が高くなっている気がしてストレスを感じていました*1。 マラソンでは気力が切れたら負けなので、コーディングの際のストレスは極力減らしたいです。

そこで最近は

  • 開発時にはソースファイルを分割して記述
    • ローカルでビルドするときにはg++ main.C -I . などとしてビルドできる
  • 提出時にはPythonのコマンドを実行してファイルを結合して1つのファイルにする

というやり方を採用することにしました。 いまいちこのテーマで記事を見かけなかったので、やり方に関するメモを残そうと思います。 個人的には満足しているのですが、ベストな方法だとは思っていません。他の記事や良いやり方がありましたらコメントに書いていただければ嬉しいです。

*1:個人差はあると思います。chokudaiさんはこの前のあーだこーだーで適切にクラス分け等がされていれば一つのファイルでもそんなに苦じゃないと言っていました。

続きを読む