Cocoa Break Logo Top Soft Develop  脱力空間 Logo
Apple Web Badge
made by mi

概要 翻訳 ソース リンク がらくた

概要 Examples ADC Samples 3rd Parties etc CBOriginals

Worm

以下は、Mac OS X 10.4.10 上の Xcode 2.5 で説明しています。

目次:
1 README.rtf の日本語訳
2 アプリケーション
3 ファイル構成
4 Worm.nib
5 WormController クラス
6 WormView クラス
7 GoodWormView クラス
8 BetterWormView クラス
9 EvenBetterWormView クラス
10 ActualWormView クラス
11 まとめと機能追加のアイデア

1 README.rtf の日本語訳

ドットを飲み込ませるようにワームを動かします。ドットが飲み込まれるたびにワームは増大し、動かしにくくなります。ワームを方向転換させるには、左と右の矢印キーを使います。

Worm は本格的なゲームではなく、むしろ実際には NSView のパフォーマンス実演ツールとして設計されています。これは、Apple's 2001 World Wide Developers' Conference において、「Using Cocoa」セッションにおけるサンプルとして使われました。

Worm に対する Mac OS X v10.4 (Tiger) における注意

Tiger において、アプリケーションのそのままの描画パフォーマンスを見るには、(/Developer/Applications/Performance Tools にある)Quartz Debug アプリケーションを起動し、「Beam Sync Tools」パネルを前面に出し、ビーム同期 (beam sync) を無効にする必要があるでしょう。そうしないと、ほとんどのモードが 60 fps(訳者註:システム環境設定のディスプレイの設定により異なる)に固定されるのを見ることはありそうなことです。

別の興味深い Tiger に対する注意は、NSString の文字列描画パフォーマンスの最近の向上により、BetterWormView のパフォーマンスは、EvenBetterWormView と同じぐらい速いか、またはより速いかもしれないということです。それでも、実際のパフォーマンスの違いは、描画される文字列の種類に大きく依存していることに注意してください。しかし、これは、特定の場合には、最適な全体の描画パフォーマンスのために、アプリケーションが EvenBetterWormView で説明されている余分な手順に進む必要がないことを意味しています。

Worm は、コントローラークラスである WormController と、互いの上に作り上げられる 5 つのビュークラスを含んでいます。

WormView(ワームビュー)は、ゲームに対する基本的なビュークラスを実装します。drawRect: メソッドがビューを再描画します。そして、performAnimation メソッドが、アニメーションの各フレームごとに呼び出されます。WormView は、自動で走ることができるように、ワームの自動方向転換を有効にします。また、さまざまな最適化のテクニックの効果を見ることができるようにするため、可能なかぎり速く進むように、アニメーションレートを設定します。

WormView のサブクラスである GoodWormView(良いワームビュー)は、単に isOpaqueYES を返すようにオーバーライドします。これはビューがその境界内におけるすべてを描画することを示しています。これにより、NSView の再描画メカニズムがより効率的になり、ビュー階層を追跡しないようにできます。

GoodWormView のサブクラスである BetterWormView(より良いワームビュー)は、渡された長方形に注意をはらうように、 drawRect: をオーバーライドします。そのため、このビューは、ビュー全体よりもはるかに小さい長方形を再描画します。performAnimation も、再描画される最小領域を計算するためにオーバーライドされます。

BetterWormView のサブクラスである EvenBetterWormView(さらに良いワームビュー)は、より速い文字列描画を実行するために、NSString ではなく Cocoa のテキストシステムの構成要素を使います。使われるテキストシステムの断片を初期化するために setString: をオーバーライドし、それをビューの再描画において使うために drawRect: をオーバーライドします。NSString の描画ではなく、テキストシステムを使うことによって、EvenBetterWormView は、文字列を描画するとき NSString の描画が通常支払っているセットアップとテキストレイアウトのコストを除去します。

最後に EvenBetterWormView のサブクラスである ActualWormView(実際のワームビュー)は、ゲームをプレイ可能にするために、2 つのメソッドをオーバライドします。autoturnAtWalls は、ワームの自動方向転換を無効にするため NO を返し、desiredFrameRate は、フレームレートを適度な値に制限するために固定値を返します。

2 アプリケーション

まず、Xcode 上でビルドして実行してみます。アプリケーションを起動すると、次のようなウインドウが表示されます。

右側にゲームボード、左側にスコアやボタンが表示されます。左の下のチェックボックスは、どのビューが使われるかを切り換えます。起動してすぐは「play!」になっていて、上の「Go」ボタンを押すとワームとドットが出現し、ワームが進み始めます。左と右の矢印キーで左右に方向転換します。ふたたび「Go」を押せば、ワームは停止し、もう一度「Go」を押せば再び進みます。「New Game」をクリックした後で「Go」をクリックするか、または、ワームがゲーム端にぶつかって終了した後に「Go」をクリックすれば、新規ゲームになって新しくワームとドットが出現します。

「Performance」ボックス内の一番上にフレームレートが表示されます。下のチェックボックスを切り換えると、使われるクラスが変わります。クラスを変えながら、「Go」をクリックして実行し、フレームレートやワームの動きを観察します。Mac OS X v10.4 以降では、上の日本語訳にある注意に気をつけてください。Quartz Debug アプリケーションを使ってビーム同期を無効にすれば、かなり速くなるはずです。Quartz Debug については、Performace Conceptual 内の『描画パフォーマンスガイドライン』の「描画パフォーマンスの測定」に説明があります。

3 ファイル構成

Worm.pbproj を開くと、次のようになっています。

Classes のうち、WormController は、アプリケーションを動かすコントローラーです。WormViewBetterWormViewEvenBetterWormViewGoodWormViewActualWormView は、ウインドウ内のビューを管理する NSView のサブクラスです。WormGuts.h と .c は、ワームの状態をチェックする C 関数を定義しています。

Resources のうち、Worm.nib はインターフェイスを定義している nib ファイルです。Localizable.strings は、ワームの本体を構成する文字列を格納しています。このファイルは、UTF-16 エンコーディングなので、Xcode で開いた時に表示がおかしいことがありますが、「形式」>「ファイルエンコーディング」から UTF-16 を選べば表示がふつうになります。

4 Worm.nib

Worm.nib を開くと、次のようになっています。

Window は、ActualWormView がはりつけられたウインドウです。Panel は左側のさまざまな設定やゲームの開始を行なうコントローラーパネルです。WormController がインスタンス化されています。これはアプリケーションの委任にはされていません。

WormController にはアウトレットがあり、それがウインドウやパネル内のコントロールやビューに接続されている。scoreTextField(スコア用テキストフィールド)は左側のパネルの一番上のスコアを表示するテキストフィールドに、startStopButton(開始停止ボタン)はその下の Go と表示されているボタンに、actualFramRateTextField(実フレームレートテキストフィールド)は、Performance のすぐ下の実際のフレームレートを表示するテキストフィールドに、wormView は右側のウインドウ内のビューに、それぞれ接続されています。

また、WormController のアクションに対して、Go と表示されたボタンからは toggleGame:(ゲーム切り替え)に、New Game と表示されたボタンからは resetGame:(ゲームリセット)に、Performance の囲みのなかにあるラジオボタンのグループからは changeViewType:(ビュータイプ変更)に、それぞれ接続されています。

5 WormController クラス

まず、アプリケーションのおおまかな動きをつかむために、コントローラーである WormController クラスを調べてみます。継承とインスタンス宣言は簡単で次のようになります。

WormController.h
#import @class WormView; @interface WormController : NSObject { NSTextField *actualFrameRateTextField; // 実フレームレート表示テキストフィールド NSTextField *scoreTextField; // スコア表示テキストフィールド id wormView; // ウインドウにはりつけられたビュー NSButton *startStopButton;// GO ボタン NSTimer *updateTimer; // 1 タイマー保持用 } - (void)toggleGame:(id)sender; // GO ボタンのアクション - (void)resetGame:(id)sender; // New Game ボタンのアクション - (void)changeWormString:(id)sender; - (void)changeFrameRate:(id)sender; - (void)changeViewType:(id)sender; // ラジオボタングループのアクション - (void)scoreChanged:(WormView *)view; - (void)gameStatusChanged:(WormView *)view; @end

インスタンス変数は、タイマーを保持しておくための updateTimer(更新タイマー)(1)を除いて、すべて nib ファイルでアウトレットとしてコントロールに接続されています。

toggleGame:resetGame:changeViewType: の3つは nib ファイルで接続されているアクションです。他のものは別メソッドから呼び出されるものです。

次に実装ファイルのメソッドを見てみます。

ヘッダにはありませんが、awakeFromNib によって nib ファイルから読み込まれた後の初期化を行っています。このメソッドは、アプリケーションが起動され、nib ファイル内にあった WormController インスタンスが読み込まれた後で呼び出されます。nib ファイルでインスタンス化されていないクラスでは、これは呼び出されません。

WormController.m > awakeFromNib
- (void)awakeFromNib { [[wormView window] makeKeyAndOrderFront:nil]; // ウインドウを前面にしてキーに [[wormView window] makeFirstResponder:wormView]; // ビューを一次レスポンダに [[wormView window] setShowsResizeIndicator:NO]; // サイズ変更コントロール非表示 [(NSPanel *)[actualFrameRateTextField window] setBecomesKeyOnlyIfNeeded:YES]; 1 [self scoreChanged:wormView]; // スコアをリセット(独自メソッド) }

ここで 1setBecomesKeyOnlyIfNeeded: に注意してください。こうすることで、パネル上のコントロールを利用可能につつ、パネルがキーになるのを防ぎます。パネルがキーになると、ユーザーがゲーム画面ウインドウを再びクリックするか、プログラム内で戻す必要があるので、特定のウインドウがつねにキー入力を受けとりたいソフトでは、入力テキストフィールドをできるだけ避けて、こうするほうがいいでしょう。

最後の scoreChanged: は単純で、ビューが保持しているスコアをテキストフィールドに設定するだけです。

WormController.m > scoreChanged:
- (void)scoreChanged:(WormView *)view { [scoreTextField setIntValue:[view score]]; }

さて、これで起動後の初期化などが終了しました。何らかのボタンをクリックしたり、ユーザーが操作を行わないかぎり、アプリケーションは待ちの状態に入っています。

次に、ゲームを開始・停止する「Go」ボタンから呼ばれるアクションメソッドである toggleGame: を見てみましょう。

WormController.m > toggleGame:
- (void)toggleGame:(id)sender { [self startStop:(updateTimer ? NO : YES)]; }

ここでは、WormController のメソッド startStop: を呼び出しています。() 内は 3 項 if 演算子で、タイマー保持用のインスタンス変数 updateTimer を調べて、真(nil でない-すなわちタイマー実行中)なら NO の引数で、偽 (nil のとき-すなわちタイマー無効化後)なら YES の引数で呼び出しています。タイマーが無効化されても updateTimer は勝手に nil になりませんが、無効化した後すぐに startStop: 内で updateTimernil を代入しているので、この if 演算子が機能します。

startStop: メソッドはゲームを開始・終了するメソッドで、これは以下のように実装されています。

WormController.m > startStop:
- (void)startStop:(BOOL)startFlag { if (startFlag) { // 開始されていない場合 if (!updateTimer) { updateTimer = [NSTimer scheduledTimerWithTimeInterval:1 target:self selector:@selector(updateInfo:) userInfo:nil repeats:YES]; 1 [[NSRunLoop currentRunLoop] addTimer:updateTimer forMode:NSModalPanelRunLoopMode]; 2 [[NSRunLoop currentRunLoop] addTimer:updateTimer forMode:NSEventTrackingRunLoopMode]; 2 } [(WormView *)wormView start]; // ビュークラスの開始メソッドを呼び出す [startStopButton setStringValue: NSLocalizedString(@"Pause", "Button title to pause the game")]; 3 } else if (!startFlag) { //すでに 開始されている場合 [(WormView *)wormView stop:NO]; if (updateTimer) { [updateTimer invalidate]; 4 タイマー無効化 updateTimer = nil; 4 nil チェック用 } [startStopButton setStringValue:[wormView gameIsOver] ? NSLocalizedString(@"Start", "Button title to start the game") : NSLocalizedString(@"Continue", "Button title to unpause the game")]; } }

開始する時は YES、終了する時は NOstartFlag に渡され、それによって処理を分けています。注意するのは、ここでのタイマーはゲーム自体を進めるものではないことです。ゲームは、ビューによって進められます。1 秒ごとにタイマーで指定されたメソッドが呼ばれ、それが情報を更新します。

開始される場合、まず updateTimer があるかどうかチェックされます。toggleGame: でこのチェックは行われていますが、ゲーム停止時など、ボタン以外から呼ばれた時のためです。余談ですが (!updateTimer) の形はよく使われます。updateTimernil なら、nil 以外となり、真となります。否定演算子と単項条件が速いので、この形が使われます。ただ、それを知っている人以外には、焦点がぼかされる欠点があります。よく使われるイディオムなので心配することもないですが、最適化が必要でない場合、(updateTimer == nil) のようにして、何を知りたいのかをはっきりさせるほうが可読性は上がります。コードの可読性を上げるためには、このような最適化のためだけの表現や、副作用を利用した条件チェックをなるべく避けるのが良いです。

upadateTimer がなければそれを作成します。1scheduledTimerWithTimeInterval: target: selector: userInfo: repeats: は、タイマーを作成して、それを実行ループのデフォルトモード(NSDefaultRunLoopMode)に追加します。したがって、以下の 2 番の実行ループに対する追加がなくても、これだけでタイマーは機能します。これにより、[self updateInfo:updateTimer] が 1 秒ごとに繰り返し呼び続けられることになります。userInfo を設定していたら、それは引数で渡されるのではなく、渡された updateTimer に問い合わせて取得することになります。

さて、タイマーが作成され機能するようになりましたが、ここでは、2 でタイマーをさらに 2 つのモードに追加しています。NSModalPanelRunLoopMode は、NSSavePanelNSOpenPanel のようなモーダルパネルからの入力を待っているときのモードです。NSEventTrackingRunLoopMode は、マウスドラッグループのように、イベントをモーダルで追跡しているときのモードです。実行モードは、これ以外に NSConnectionReplyMode があり、これは NSConnection オブジェクトが返答を待っているモードです。通常のソフトでは、デフォルトモードだけで十分かもしれませんが、ここではモーダル操作やマウス追跡時もメソッド呼び出しが行われるように、2 つのモードが追加されています。

タイマーの作成と使用には、scheduledTimerWithTimeInterval:invocation:repeats: というメソッドもあります。また、timerWithTimeInterval:target:selector:userInfo:repeats: を使ってタイマーを作成し、それから addTimer:forMode: で追加するやり方もあります。この場合、タイマーが作られた時点では、まだ機能しません。addTimer:forMode: が適切なモードに追加された時点からそのモード内で機能するようになります。

それから、ビュークラスの start メソッドが呼ばれ、ワームの移動が始まります。

つぎに、3 で、ボタンタイトルを変更しています。ただし、このサンプルのコードのままでは、タイトルは変わりません。setStringValue:setTitle: にすれば、タイトルが設定されます。また、NSLocalizedString(@"Pause", "Button title to pause the game") では、キー "Pause" で、バンドルの Localizable.strings からローカル化文字列を引き出しています。プロジェクトの Localizable.strings を見ても、このキーはありません。コメントがありますが、これはコマンド行から genstrings ツールを使って strings ファイルを自動作成するときに拾ってもらうためのもので、実行時には関係ありません。"Pause" が見つからなければ、キー "Pause" が返されます。きちんとしたローカル化を行うなら、このキーと、次の "Start"、"Continue" に対応する項目を Localizable.strings に追加しておくべきでしょう。setTitle: に変更してビルドした後で、タイトルが "Go" に戻らないのは、次で "Go" が設定されていないからです。

さて、停止される場合には、まず updateTimer がチェックされます。あるなら、4 で、無効化(invalidate)されます。タイマーを実行ループから外すメソッドがないので、追加した後は、これで無効化するしかありません。作成した時に repeats:NO としていたら、一度発動した後で、自動的に無効化されるのでこれを呼び出す必要はありません。また、次で updateTimernil が入れられています。無効化されたタイマーはいずれ解放されますが、updateTimer に即座に nil が入るわけではないので、ここで代入することで、updateTimer のチェックがきちんと機能するようにしています。

タイマー無効化するか、もともとないなら、タイトルが変更されます。ビューにゲーム終了かを問い合わせ、その結果によって継続と再ゲームとを分けています。タイマーを一時停止して保持しておく手段がないので、タイトル変更以外はどちらの場合も同じです。継続の場合もタイマーが再び新規作成されることになります。

ここで設定されたタイマーから 1 秒ごとに呼び出されるのが、updateInfo: です。

WormController.m > updateInfo:
- (void)updateInfo:(NSTimer *)timer { [actualFrameRateTextField setIntValue:(int)[wormView actualFrameRate]]; }

ここでは、wormView から現在のフレームレートを取得して、それを表示しているだけです。

これで、「Go」ボタンを押した場合に起こる出来事がだいたい理解できました。実際のゲーム進行はビューが行っていて、タイマーでフレームレートを得て取得しているだけだということがわかります。

つぎに、「New Game」ボタンを押した場合に呼ばれるメソッドを見てみます。

WormController.m > resetGame:
- (void)resetGame:(id)sender { [self startStop:NO]; [(WormView *)wormView reset]; }

このメソッドは上で調べた tartStop: を使って、タイマーを停止しています。また、ビューにゲームをリセットするように指示しています。

残りのアクションメソッドは、ビューを変更するラジオボタンから呼ばれる次のメソッドです。

WormController.m > changeViewType:
- (void)changeViewType:(id)sender { NSRect frame = [wormView frame]; // 新規作成時に利用するためフレーム取得 WormView *newWormView; Class viewClass; // ユーザーの Performance での選択にもとづいて適切なビューを選ぶ switch ([[sender selectedCell] tag]) { 1 選択されているセルのタグを調べる case 3: viewClass = [EvenBetterWormView class]; break; case 2: viewClass = [BetterWormView class]; break; case 1: viewClass = [GoodWormView class]; break; case 0: viewClass = [WormView class]; break; default: viewClass = [ActualWormView class]; break; } newWormView = [[viewClass alloc] initWithFrame:frame]; // 同フレームで新規作成 [newWormView setAutoresizingMask:[wormView autoresizingMask]]; // 設定を同じに [newWormView setController:[wormView controller]]; // コントローラーの設定 (self) [self resetGame:sender]; // 進行中なら終らせておく [[wormView superview] addSubview:newWormView]; 2 ビューの入替を開始 [[wormView window] makeFirstResponder:newWormView]; [newWormView release]; [wormView removeFromSuperview]; wormView = newWormView; }

まず 1 では、ラジオボタンの設定にしたがって、Class 型の変数に適切なクラスを入れています。Worm.nib を見れば、タグは上から 0 〜 4 まで割り当てられています。ここで、case 4: がありませんが、ないので default: になることになります。余談ですが、プログラムはこれで問題なく機能しますが、可読性という点では、case 4: も作ったほうがいいでしょう。そのうえで、default には、NSLog で警告を出力するか Exception を発生させるようにして、予想以外の値が渡された時にチェックできるようにするのが無難です。

開発過程において、さらなる選択肢を増やしたり、何らかの理由で最初の作成時に予想していたの違う範囲の値が渡るようになってしまう場合があります。そのような時に対応コードの書き忘れを防ぐためにも、default: は、警告や例外出力を行うようにしておくクセをつければ、安全なコーディングが行えます。一番問題になるのが、予想外の値が default: に渡っていて、それが通常の場合ではたまたまうまく機能して気づかない事です。まれに問題のある値が渡った時だけクラッシュなど重大なエラーを起こすなら、再現性が難しいものであれば気づくのが難しくなります。たとえば、この場合でも、タグ 5 の新しい最適化手法のテストクラスを作り、この部分を修正し忘れていても、プログラムは機能してしまうので、新しい最適化がたいして役に立たないという間違った結論をしてしまうおそれがあります。

次に、新しいクラスのインスタンスを作り、それを現在と同じ設定にしておきます。次にコントローラーを設定します。最初のビューでは、nib ファイル内の WormController に接続されています。これは結局のところ、self です。そして現在のビューが進行中なら終らせておきます。

最後に、2 で、新しく作成したビューと古いものを入れ替えます。上位ビューの addSubview: で、下位ビューとして追加します。この時点で、上位ビューがこのビューを保持します。そのため、2 つ後で release しても、上位ビューが所有者として残っています。次に新しいビューを一次レスポンダにします。これで古いビューは一次レスポンダ状態を放棄します。そして、古いビューを上位ビューから除去します。このメソッド内で新規作成して retain されていたものを release しているので、上位ビューだけが、このビューの所有者です。よって除去された時点でこれは解放されます。最後にインスタンス変数を再設定しておきます。

まだ、説明していない、いくつかのメソッドが残っていますが、それらはビュークラスのインスタンス変数 controller を通じて、必要な時に呼び出されるものです。これでコントローラーの動作は、理解できたはずです。

6 WormView クラス

起動すると、ActualWormView が表示されている状態ですが、クラスの継承関係で一番上位の WormView クラスから順番に説明していきます。このクラスは基本的なクラスで他のクラスが継承するインターフェイスを宣言しています。他のクラスはこのクラス(またはスーパークラスの NSView)から継承されたメソッドをオーバーライドして動作することになります。

このクラスの継承とインスタンス変数は以下のようになっています。

WormView.h > 継承とインスタンス変数
@interface WormView : NSView { WormController *controller; // コントローラー unsigned initialWormLength; // 最初のワームの長さ unsigned wormLength; // 現在のワームの長さ unsigned score; // スコア NSString *wormString; // ワームを構成する文字列 NSMutableDictionary *wormTextAttributes; // ワームのテキスト属性 float desiredFrameRate; // 期待するフレームレート float actualFrameRate; // 実際のフレームレート CFRunLoopTimerRef wormTimer; // 実際のフレームレート CFAbsoluteTime timeStamp; // 実際のフレームレート計算のための時間保存 GamePosition *wormPositions; // ワームの位置(配列) GameDirection wormDirection; // ワームの向き GameHeading wormHeading; // ワームの方向転換の方向 GamePosition targetPosition; // ドットの位置 NSColor *backgroundColor; // 背景色 BOOL gameOver; // ゲーム終了かどうか }

追加したコメントでほぼわかると思いますが、timeStamp などどう使われるかわかりにくいものもあります。それらは各メソッドの説明でわかると思います。

インスタンスの初期化は NSView で宣言されている initWithFrame: で行われます。これは IB におけるカスタムビューに対しても呼び出されるので、nib 読み込み時の初期化もこれで行えます。NSOpenGLView など、パレットとして提供されている NSView のサブクラスでは、nib でインスタンス化していた場合、この初期化メソッドが呼び出されないことがあるので注意してください。

WormView.m > initWithFrame:
- (id)initWithFrame:(NSRect)frame { self = [super initWithFrame:frame]; // スーパークラスの初期化を呼び出す if (self) { // スーパークラスでの初期化が成功したら srandom((unsigned)time(NULL)); // 1 乱数初期化 wormPositions = malloc(MAX_WORM_LENGTH * sizeof(GamePosition)); // 2 wormTextAttributes = [[NSDictionary alloc] initWithObjectsAndKeys: [NSFont boldSystemFontOfSize:16.0], NSFontAttributeName, nil]; // 3 ワーム表示用の属性を作成 backgroundColor = [[NSColor colorWithPatternImage: [NSImage imageNamed:@"Background Pattern"]] copy]; // 4 背景パターンを作成 [self setString:NSLocalizedString(@"Default Worm String", "The string to be used for the worm's body")]; // 本体文字列を設定 [self setInitialLength:DEFAULT_LENGTH]; // 最初の長さを設定 [self setDesiredFrameRate:1000.0]; // 望むフレームレートを設定(1/1000 秒になる) [self reset]; // ビューをリセット } return self; }

たいていのカスタムビューと同様に、スーパークラスの初期化を呼び出し、インスタンスが作成されたことを確認した後で、このクラス独自の初期化に移ります。

1 で、乱数初期化が行われています。srandom は、C 言語ライブラリ関数です。このサンプルでは、乱数はたいした用途に使われてないですが、乱数のランダム性が大事な場合には、決してライブラリ関数は使わず、メルセンヌツイスターなどのきちんとした乱数ルーチンを使うようにしてください。現在のライブラリの実装は知らないですが、昔の実装はとんでもないものでした。

2 では、ワームの位置を入れるための配列のメモリが確保されています。ワームは複数の場所を占めるので、配列が使われています。個々の場所は GamePosition という構造体に格納されていて、これは WormGuts.h で宣言されています。

WormGuts.h > GamePosition
typedef struct _GamePosition { int x; int y; } GamePosition;

次に、3 では、表示用の文字列属性が作成されています。initWithObjectsAndKeys: は、一気に複数のキーとオブジェクトでディクショナリを初期化します。配列にも同様なメソッドがあります。これらは、不特定の数の引数をとり、最後は nil で示します。ここでは、1 つのキーと 1 つのオブジェクトだけをもつディクショナリができることになります。

4 では、背景パターンが作成されています。このように、NSColor は画像をパターンとして持つことができるようになっています。

あとは、最初の長さ(19)と、望むフレームレート(1000)が設定され、その後で reset で、ビューがリセットされています。

WormView.m > reset
- (void)reset { unsigned i; [self stop:NO]; // 停止する wormLength = initialWormLength - 1; // ワームの現在長を設定 for (i = 0; i < wormLength; i++) { // ワームの位置を無効にする wormPositions[i].x = -1; wormPositions[i].y = 0; } targetPosition.x = -1; // ドット位置を無効にする targetPosition.y = 0; wormDirection = kGameDirectionEast; // ワームの向きを東(画面右向き)に wormHeading = kGameHeadingStraight; // ワームの方向転換の向きを直進に actualFrameRate = 0.0; // 実際のフレームレートをリセット [self setScore:0]; // スコアをリセット [self setNeedsDisplay:YES]; // ビューを再表示するようにマーク }

ここでは、それほど難しいことはなされていません。初期化の続きですが、ゲームを新規開始するたびに初期化する必要があるものをここで行っています。このように、クラスの初期化と、何らかの操作の結果として何度も使われる初期化コードを分離しておくとすっきりします。

また、dealloc において、保持されていたオブジェクトや確保されたメモリが解放されます。

WormView.m > dealloc
- (void)dealloc { [self stop:YES]; [wormString release]; [wormTextAttributes release]; [backgroundColor release]; [super dealloc]; }

これで、初期化関連のメソッドの説明は終わりました。アプリケーションが起動され、nib ファイルからビューが読み込まれた時に、ここまでが行われます。とはいえ、最初に設定されているのは ActualWormView なので、このクラスの初期化が行われるわけではありません。残りのメソッドは、「Go」ボタンによる開始など、ユーザーの操作の結果と、ゲームの進行にしたがって呼び出されることになります。

さて、「Go」ボタンが押されると、WormController クラスの toggleGame: が呼び出され、それが startStop: を呼び出します。開始の場合は、ビューの startメソッドが呼び出されます。

WormView.m > start
- (void)start { if (gameOver) [self reset]; // ゲーム状態を初期値に gameOver = NO; // ゲーム実行中に設定 if (!wormTimer) { // 1 タイマーがないなら作成 CFRunLoopTimerContext context = {0, self, NULL, NULL, NULL}; timeStamp = CFAbsoluteTimeGetCurrent(); wormTimer = CFRunLoopTimerCreate(NULL, timeStamp, 1.0/[self desiredFrameRate], 0, 0, wormTimerCallBack, &context); CFRunLoopAddTimer(CFRunLoopGetCurrent(), wormTimer, kCFRunLoopCommonModes); [controller gameStatusChanged:self]; } }

まずリセットが行われ、ゲーム実行中に設定します。そしてタイマーがないなら、ゲームの毎回の進行を行うタイマーを作成して、実行ループに追加し、それが機能するようにします。

1 以降の if 文内で、タイマーが作られて設定されます。まず、CFRunLoopTimerContext が設定されています。これは、CFRunLoop.h で定義されている、以下のような構造体です。

CFRunLoop.h > CFRunLoopTimerContext
typedef struct { CFIndex version; void * info; const void *(*retain)(const void *info); void (*release)(const void *info); CFStringRef (*copyDescription)(const void *info); } CFRunLoopTimerContext;

最初はバージョンでこれは現在のところ常に 0 です。2 番目はタイマーが保持しておくユーザー情報です。ここでは、self が設定されています。他はユーザー情報を保持、解放、記述するためのコールバック関数ですが、ここでは独自の情報を設定していないのですべて nil になっています。

次に現在時刻が timeStamp に入れられています。これは後で実際のフレームレートを計算するために使われます。

それからタイマーが作成されています。NSTimer クラスしか使ったことがない人には見慣れない関数ですが、使い方はそれほど違うわけではありません。上のコンテキスト情報などを設定しておく必要があるなど、手順が増えるだけです。CFRunLoopTimerCreate は、アロケーター(オブジェクトのメモリ割り当てに使われるもの-ここでは NULL つまりデフォルト)、最初の発動時刻(直前で取得した現時刻)、間隔(初期化で 1000 なので、1/1000 秒)、現在は無視されるオプションフラグ(つねに 0)、現在無視されるタイマーが処理される優先順位(つねに 0)、発動したときに呼ばれるコールバック関数(このクラスで定義している wormTimerCallBack)、その関数に渡されるコンテキスト情報(上で設定したもの)を引数としてとります。そして、作成したタイマーを指すポインタを返します。それを wormTimer に入れています。この関数は作成だけを行うので、WormControllerstartStop: で使われていた NSTimer のメソッドと違って、この関数だけではタイマーは発動しません。発動させるためには実行ループに追加する必要があります。また、NSTimer であった繰り返しの指定がありませんが、CFRunLoopTimer では毎回一度しか発動しません。繰り返しを行う場合は、呼ばれたコールバック内で次の発動を設定しなければなりません。

次の CFRunLoopAddTimer によって、実行ループに追加されます。この関数は、追加する実行ループ(現在の実行ループ)、タイマーオブジェクト(直前で作成した wormTimer)、追加するモード(kCFRunLoopCommonModes -これは共通モードのすべてのモードから監視される)を引数としてとります。

これでタイマーが発動されます。発動されたとき、wormTimerCallBack が呼ばれることになります。

WormView.m > wormTimerCallBack
void wormTimerCallBack(CFRunLoopTimerRef timer, void *info) { WormView *self = (WormView *)info; // 作成した時 self が入れられていた CFAbsoluteTime startTime = CFAbsoluteTimeGetCurrent(); // 1 BOOL gameOverFlag = [self performAnimation]; // 各フレームごとの実行を行う [self displayIfNeeded]; // performAnimation で設定された状態を描画 [self stampTime]; // 実際のフレームレートを計算 if (gameOverFlag) { // ゲームオーバーなら停止 [self stop:YES]; } else { // ゲームオーバーでないなら次の発動を設定 CFRunLoopTimerSetNextFireDate (timer, startTime + CFRunLoopTimerGetInterval(timer)); } }

まず、コンテキスト情報を取得しますが、これは最初に作成された時に self が入れられていました。CFRunLoopTimer は、メソッドではなく、 C 言語関数としてコールバックを呼び出すので、関数内から現在のイスンタンスのメソッドが呼び出せるように、これを設定しています。

1 では、現在時刻が保存されています。これは、状態計算などの処理の時間が次の発動時刻の計算に影響しないようにするためです。状態処理の長短にかかわらず、この時刻から最初に設定された間隔後に次の発動が起こるよう最後で設定されることになります。

以下は、このクラスのメソッドを呼び出して処理を実行します。これらのメソッドの詳細は以下で説明します。コメントを見ればだいたいの処理がわかると思います。最後にゲームオーバーなら停止し、そうでなければ次の発動を行い、タイマーによってこのサイクルが繰り返されることになります。もちろん、ゲームオーバー以外でも、繰り返しの最中に、ボタンなどが押されれば、コントローラークラスによって、このクラスの stop: などが呼び出され、そこでタイマーが無効化されることで、この繰り返しが中断されることになります。

各繰り返しごとの処理の詳細を見る前に、これと対になる停止のためのメソッドを見ておきます。

WormView.m > stop:
- (void)stop:(BOOL)flag { if (!gameOver && flag) gameOver = YES; // ゲームオーバーにする if (wormTimer) { // タイマーがあれば CFRunLoopTimerInvalidate(wormTimer); // タイマーを無効化 CFRelease(wormTimer); // タイマーを解放 wormTimer = NULL; // チェックのためタイマー保有用変数を NULL に [controller gameStatusChanged:self]; // コントローラーに状態変更を通知 } }

このメソッドは、コメントを見ればわかると思います。基本的にタイマーを無効化して繰り返しを停止しています。

さて、各フレームごとの処理を説明する前に、まず実際のフレームレート計算を見ておきます。

WormView.m > stampTime
- (void)stampTime { CFAbsoluteTime time = CFAbsoluteTimeGetCurrent(); // 現在時刻取得 if (time > timeStamp) { // インスタンス変数より後なら actualFrameRate = (actualFrameRate + 1 / (time - timeStamp)) / 2; // 1 } timeStamp = time; // インスタンス変数を再設定 }

1 では、今回かかった秒を計算し、1 秒をそれで割ることで、今回と同じ時間経過なら 1 秒間に何回かというフレームレートが出ます。これを以前のフレームレートと足して 2 で割ることで平均をとっています。

さて、各フレームごとの状態チェックや再表示用の設定など、主要な処理を行う performAnimation を見てみましょう。このメソッド内では、主にワームの状態などがチェックされ、更新されます。また、再表示が必要な場合、それが指定されます。実際の再描画自体は、このメソッドを抜けた後で、wormTimerCallBack[self displayIfNeeded]; の結果として呼び出されることになる drawRect: 内で行われます。ここでは、display... メソッドが使われていますが、これは、画面のリフレッシュレートと関係なく、即座に描画を要求するためです。ゲームなどを除く通常のアプリケーションでは、再描画が必要であると設定しておくだけで、実行ループによる自動再描画機能を使うことが推奨されています。

WormView.m > performAnimation
- (BOOL)performAnimation { BOOL gameOverFlag; gameOverFlag = [self updateState]; // 状態更新 [self setNeedsDisplay:YES]; // 再描画が必要なようにマーク return gameOverFlag; }

このメソッド自体はたいした事はしていません。実際の更新は updateState 内で行われ、ゲームオーバーかどうかが返ってくるので、それをそのまま返しているだけです。再描画が必要であるとマークしていることに注意してください。

WormView.m > updateState
- (BOOL)updateState { NSSize size = [self bounds].size; unsigned width = size.width / WIDTH_QUANTUM, height = size.height / HEIGHT_QUANTUM; // グリッド数を設定 GameState state = worm_guts(wormPositions, &wormLength, &wormDirection, &wormHeading, &targetPosition, width > 0 ? width : 1, height > 0 ? height : 1, [self autoturnAtWalls] ? 0 : 1); // 状態チェック if (state == kGameStateScore) [self setScore:score + 1]; // スコア獲得なら得点 return (state == kGameStateCrash); // クラッシュならゲームオーバー }

基本的にグリッド数を計算した後で、位置など現在の状態を引数として渡して worm_guts を呼び出し、戻ってきた状態によって、必要な処理を行っているだけです。ここで、worm_guts には参照で変数を渡していて、関数内で現在の位置などが再設定されていることがわかります。また引数に対する 3 項 if 演算子でグリッド数が 0 の場合に 0 が渡されないようにしています。

さて、状態チェック関数を見る前に、ここまでの説明で疑問に思ったことはないでしょうか。ワームが動いている時に、矢印キーを押して向きを変えますが、その処理はどこで行われているのでしょうか。それは、このクラスの keyDown: メソッドで行なわれています。

WormView.m > keyDown:
- (void)keyDown:(NSEvent *)event { NSString *keys = [event charactersIgnoringModifiers]; // キーを取得 wormHeading = kGameHeadingStraight; // 直進に設定 if (keys && [keys length] > 0) { // キーが存在し、長さが 0 でなければ unichar c = [keys characterAtIndex:0]; // キーの最初の文字を取得 if (c == NSLeftArrowFunctionKey) { // 左矢印キーなら wormHeading = kGameHeadingLeft; //左向きに } else if (c == NSRightArrowFunctionKey) { // 右矢印キーなら wormHeading = kGameHeadingRight; // 右向きに } } }

コメントでほとんどわかると思いますが、イベントの扱いに慣れてない人が気になるのは、文字列を取得してから文字を取得している部分でしょうか。ユニコードキーボードでは、1 つのキーが複数の文字で表されるものを入力する可能性があります。そのため、イベントがもつキー情報はユニコード文字列となっています。通常の 1 キー 1 文字では、文字列の最初に入れられていますので、これを取得します。

これで処理の繰り返しと別に、キー入力による割り込みによって、インスタンス変数の wormHeading が変更されます。このため、状態チェック時には、その時のインタンス変数を調べるだけでよく、イベント処理を切り離して考えられます。論理的には、この方法だと、複数キーを押し下げの場合、最後のキーのみが反映されると考えられますが、実際のフレームレートは、1 秒間に数回(ゲーム用に遅くした場合)〜数十回になるので問題にはなりません。必要ならキーの効果による状態変更が累積するように変えることも簡単でしょう。

さて、実際の状態チェックを行っている関数 worm_guts を見てみます。これは、WormGuts.h で宣言されている唯一の関数で、WormGuts.c で実装されています。この関数の構造は、おおまかに次のようになります。

WormGuts.c > worm_guts の構造
局所変数の宣言と初期化 ドットに重なっているかチェック 先頭以外を 1 つずつ前に進める 向きと方向転換で先頭の位置を計算 壁にぶつかったかチェック 自分にぶつかったかチェック

それでは、順番に見ていきます。

WormGuts.c > worm_guts > 局所変数の宣言と初期化
GameState worm_guts(GamePosition *wormPositions, unsigned *wormLength, GameDirection *wormDirection, GameHeading *wormHeading, GamePosition *targetPosition, unsigned width, unsigned height, int noBounce) { int retval = kGameStateContinue; unsigned i;

最初に戻り値を、継続にしています。この戻り値は GameState 型で、これは以下のように定義されています。

WormGuts.h > GameState
typedef enum _GameState { kGameStateContinue = 0, // 継続 kGameStateScore = 1, // 得点 kGameStateCrash = 2 // クラッシュ } GameState;

得点は、ゲーム継続を行うので、クラッシュ以外なら継続されることになります。

WormGuts.c > worm_guts > ドットに重なっているかチェック
if (wormPositions[0].x == targetPosition->x && wormPositions[0].y == targetPosition->y) { /* ターゲットをチェック */ unsigned collisions; if (targetPosition->x >= 0 && targetPosition->y >= 0) retval = kGameStateScore; /* 初期状態を考慮に入れる */ 1 do { /* ターゲットの新しい位置を計算 */ targetPosition->x = random() % width; // ランダムで設定 targetPosition->y = random() % height; // ランダムで設定 for (i = 0, collisions = 0; collisions == 0 && i < *wormLength; i++) { 2 if (targetPosition->x == wormPositions[i].x && targetPosition->y == wormPositions[i].y) collisions++; } } while (collisions > 0); (*wormLength)++; }

最初にターゲットの位置と [0] の先頭位置 の x、y 座標が一致しているか調べます。1 では、リセットした直後に、位置が無効であることを示すために、-1 が入れられるため、これにより一致した場合は無視します。そうでなければ、得点となります。どちらにしても、ターゲットの新しい位置を設定しないといけません。2 の if 文とそのなかでは、ターゲットの新しい位置がワーム内かどうかをチェックしています。ワーム内なら繰り返し、大丈夫ならループを抜けます。その後、得点によりワームの長さを 1 つ増加させます。とはいえ、この順番では、初期状態による一致によっても増大が起こるようです。

WormGuts.c > worm_guts > 先頭以外を 1 つずつ前に進める
for (i = *wormLength; i > 0; i--) { /* ワームを前進させる */ wormPositions[i] = wormPositions[i-1]; }

これは簡単です。先頭以外の位置を 1 つ前のパーツがあった場所に移しているだけです。

WormGuts.c > worm_guts > 向きと方向転換で先頭の位置を計算
*wormDirection = (*wormDirection + *wormHeading) % 4; wormPositions[0].x += (*wormDirection == kGameDirectionEast) - (*wormDirection == kGameDirectionWest); wormPositions[0].y += (*wormDirection == kGameDirectionSouth) - (*wormDirection == kGameDirectionNorth); if (*wormHeading != kGameHeadingStraight && (wormPositions[0].x < 0 || wormPositions[0].x >= width || wormPositions[0].y < 0 || wormPositions[0].y >= height)) { wormPositions[0].x -= (*wormDirection == kGameDirectionEast) - (*wormDirection == kGameDirectionWest); wormPositions[0].y -= (*wormDirection == kGameDirectionSouth) - (*wormDirection == kGameDirectionNorth); *wormDirection = (*wormDirection - *wormHeading) % 4; wormPositions[0].x += (*wormDirection == kGameDirectionEast) - (*wormDirection == kGameDirectionWest); wormPositions[0].y += (*wormDirection == kGameDirectionSouth) - (*wormDirection == kGameDirectionNorth); } *wormHeading = kGameHeadingStraight;

これは特に説明しません。現在の向きと、方向転換によって、ワームの次の場所を決めているだけです。最後に位置を決定したので、不要になった方向転換を直進に戻しています。

WormGuts.c > worm_guts > 壁にぶつかったかチェック
if (wormPositions[0].x < 0 || wormPositions[0].x >= width) { /* 壁にぶつかった、方向転換かゲーム終了 */ if (noBounce) { /* 終了 */ retval = kGameStateCrash; } else { /* 方向転換 */ wormPositions[0].x = (wormPositions[0].x < 0) ? 0 : width - 1; *wormDirection = (wormPositions[0].y < height / 2) ? kGameDirectionSouth : kGameDirectionNorth; wormPositions[0].y += (*wormDirection == kGameDirectionSouth) - (*wormDirection == kGameDirectionNorth); } } else if (wormPositions[0].y < 0 || wormPositions[0].y >= height) { /* 壁にぶつかった、方向転換かゲーム終了 */ if (noBounce) { /* 終了 */ retval = kGameStateCrash; } else { /* 方向転換 */ wormPositions[0].y = (wormPositions[0].y < 0) ? 0 : height - 1; *wormDirection = (wormPositions[0].x < width / 2) ? kGameDirectionEast : kGameDirectionWest; wormPositions[0].x += (*wormDirection == kGameDirectionEast) - (*wormDirection == kGameDirectionWest); } }

これもたいして説明する必要はないでしょう。最初に x 方向、次に y 方向で範囲外かどうかをチェックしているだけです。ぶっかった場合、この関数に渡された noBounce の値によって処理を分けています。これは実際のゲーム以外のビューでは、自動でワームが進み、クラッシュがないからです。自動の場合は、先頭の位置と方向を調整しています。

WormGuts.c > worm_guts > 自分にぶつかったかチェック
for (i = 1; retval != kGameStateCrash && i < *wormLength; i++) { if (wormPositions[0].x == wormPositions[i].x && wormPositions[0].y == wormPositions[i].y) retval = kGameStateCrash; } return retval;

これもたいして説明の必要はないでしょう。本体の位置を調べて、先頭と重なっているかをチェックしています。重なっているならクラッシュでゲーム終了です。最後に戻り値を返します。

これで各フレームごとの処理がわかりました。開始されるとタイマーによる繰り返しが始まります。各フレームごとに、C 言語関数により状態更新がなされ、再描画が行われ、次の発動を設定します。まだ説明していないのは、[controller gameStatusChanged:self]; ぐらいです。これは単に、WormView が実行中かどうかにしたがって、コントローラーのタイマーを開始停止するだけのメソッドです。

さて、他にもメソッドがありますが、ほとんどはすぐわかるようなメソッドなので、ここでは説明しません。このクラスであと説明していないのは、このサンプルの中心である描画部分です。これは、wormTimerCallBack[self displayIfNeeded]; の結果として呼び出されることになる drawRect: 内で行われます。再描画がすべてこのメソッド内に集約されています。drawRect: を見る前に、isFlippedYES を返すように実装されていることに注意してください。これにより座標原点は、通常の左下ではなく、左上になります。

WormView.m > drawRect:
- (void)drawRect:(NSRect)rect { unsigned i; NSRect tRect; [backgroundColor set]; // 背景色をセット NSRectFill([self bounds]); // セットした背景色でバウンズ内を塗りつぶし for (i = 0; i < wormLength; i++) { // ワームを 1 文字ずつ書いていく NSRect wRect = [self rectForPosition:wormPositions[i]]; // 文字位置を取得 unsigned characterIndex = i % [wormString length]; // 文字番号を取得 NSString *string = [wormString substringWithRange:NSMakeRange(characterIndex, 1)]; // 描画する文字を取得 [string drawInRect:wRect withAttributes: wormTextAttributes]; // 文字を描画 } tRect = [self rectForPosition:targetPosition]; // ターゲット位置を得る [[NSColor blackColor] set]; // 黒色をセット [[NSBezierPath bezierPathWithOvalInRect:NSInsetRect(tRect, 2, 2)] fill]; // ターゲットを描画 }

かなり単純なので、コメントだけで十分わかると思います。わからないメソッドはリファレンスを見てください。rectForPosition: はグリッド位置から実際の位置を計算する、このクラスのメソッドです。

これまでの説明で、このクラスについては十分に理解できたと思います。AppKit の NSString 追加機能を使って、文字列を描画しているだけです。再描画が必要な長方形なども考慮せず、背景から全部描き直していることに注意してください。使っている PC にもよりますが、ある程度以上の能力がある PC なら、このままでもかなりの速度を得られます。リフレッシュレート等を考慮するなら、最適化をつきつめないでも、通常のアプリケーションなら Cocoa の機能でも十分なことがわかると思います。ただ、このサンプルでは、毎回の状態更新などの計算がそれほど重くなりません。実際のゲームなどでは、やはり最適化は必要でしょう。

7 GoodWormView クラス

次に、WormView のサブクラスの GoodWormView クラスを見てみましょう。このクラスのヘッダは非常に単純です。

GoodWormView.h
@interface GoodWormView : WormView - (BOOL)isOpaque; @end

実装ファイルでも、このメソッドが実装されているだけです。

GoodWormView.m > isOpaque
- (BOOL)isOpaque { return YES; }

この isOpaque は、そのビューが不透過 (opaque) であるかどうかを返します。デフォルトでは、NO を返すようになっています。Mac OS X では、ウインドウやビューに透明度が設定できるため、ビューを描画するとき、背後の上位ビューやウインドウなどが透けて見えている場合、そこから描画しなければなりません。このメソッドが YES を返すようにすれば、そのような無駄な作業を省略できます。透明なビュー以外では、このメソッドを実装しておく習慣をつけておくといいでしょう。『Cocoa のためのビュープログラミングガイド』>「ビューの描画の最適化」>「ビューの不透明度の指定」で説明されています。

8 BetterWormView クラス

GoodWormView のサブクラスの BetterWormView では、drawRect: が再描画が必要な長方形だけに描画を限定するようにオーバーライドされています。そのために、performAnimation もオーバーライドして、実際に再描画が必要な部分だけをマークするように修正されています。ヘッダファイルは継承関係が宣言されているだけで、インスタンス変数も新たなメソッド宣言もありません。

GoodWormView.m > performAnimation
- (BOOL)performAnimation { BOOL done; NSRect rect = NSZeroRect; GamePosition oldTargetPosition = targetPosition; unsigned int i; // ワームの元の場所をおおう長方形を計算 for (i = 0; i < wormLength; i++) { rect = NSUnionRect(rect, [self rectForPosition:wormPositions[i]]); } // ゲーム状態を更新 done = [self updateState]; // (ワームが動いたので)新しい先頭の場所を統合 rect = NSUnionRect(rect, [self rectForPosition:wormPositions[0]]); // ターゲットが位置を変えていたら、それも含める if (oldTargetPosition.x != targetPosition.x || oldTargetPosition.y != targetPosition.y) { rect = NSUnionRect(rect, [self rectForPosition:targetPosition]); } // さて、計算された長方形だけが再表示が必要だとマークされる [self setNeedsDisplayInRect:rect]; return done; }

コメントだけでほとんど理解できると思います。ここで気になるのは、本体の場所すべてをおおう長方形がマークされていることです。本体 1 つ 1 つの場所の長方形をマークするやり方もあります。この方法だと、ワームが大きくなった時、オーバーヘッドがかなりになる可能性があります。それほど大きくならないなら、別々の長方形としてマークするのがいいかもしれません。特に、本体ではなく、ターゲットも含めているので、結果の長方形が全体に近くなる可能性が高いです。できれば、ターゲットだけでも分離して setNeedsDisplayInRect: を複数回呼び出したほうがいいと思われます。これについては、『Cocoa のためのビュープログラミングガイド』>「ビューの描画の最適化」>「パフォーマンス向上のための描画制限」を見てください。おそらく、このサンプルが作成された時期が古く、そこまで細かい長方形を設定していないのだと思われます。

このメソッドで、再表示が必要だとマークされて、しばらくして drawRect: が呼ばれることになります。

GoodWormView.m > drawRect:
- (void)drawRect:(NSRect)rect { unsigned i; NSRect tRect; [backgroundColor set]; NSRectFill(rect); // バウンズ全体ではなく、更新された長方形だけを塗りつぶし for (i = 0; i < wormLength; i++) { NSRect wRect = [self rectForPosition:wormPositions[i]]; if (NSIntersectsRect(wRect, rect)) { // 更新長方形内の場合だけ本体の部分を描画 unsigned characterIndex = i % [wormString length]; NSString *string = [wormString substringWithRange:NSMakeRange(characterIndex, 1)]; [string drawInRect:wRect withAttributes: wormTextAttributes]; } } tRect = [self rectForPosition:targetPosition]; if (NSIntersectsRect(tRect, rect)) { // 更新長方形内の場合だけターゲットを描画 [[NSColor blackColor] set]; [[NSBezierPath bezierPathWithOvalInRect:NSInsetRect(tRect, 2, 2)] fill]; } }

これもたいして複雑なことはしていないので、コメントを頼りにすれば十分わかると思います。

9 EvenBetterWormView クラス

次は、BetterWormView のサブクラスである EvenBetterWormView を見てみます。これは NSString ではなく、Cocoa のテキストシステムを使っています。このあたりについては、『Cocoa のための文字列プログラミングガイド』の「文字列の描画」、あるいは、『Cocoa 描画ガイド』の「テキスト」 を参照してください。README に述べられている Mac OS X v10.4 に対する注意も参照してください。

このクラスでは、テキストシステムを使うので、各構成要素を保持しています。

GoodWormView.h > 継承とインスタンス宣言
@interface EvenBetterWormView : BetterWormView { NSTextStorage *wormStorage; NSLayoutManager *wormLayout; NSTextContainer *wormContainer; }

さて、WormView を思い出してください。WormViewinitWithFrame: に以下の部分がありました。

WormView.m > initWithFrame:
... [self setString:NSLocalizedString(@"Default Worm String", "The string to be used for the worm's body")]; ...

このクラスでは、ここで呼び出されている setString: メソッドをオーバーライドしています。それを見る前に、WormView で説明しなかったので、WormView の実装を見ておきましょう。

WormView.m > setString:
- (void)setString:(NSString *)string { if (string != wormString) { [wormString release]; wormString = [[string uppercaseString] copy]; } }

非常に単純です。すでにあればそれを解放し、引数の文字列をコピーして大文字化してインスタンス変数に設定しているだけです。それでは、EvenBetterWormView の実装を見てみましょう。

EvenBetterWormView.m > setString:
- (void)setString:(NSString *)string { [super setString:string]; // スーパークラスの実装を呼ぶ if (!wormStorage) { // まだテキストシステムの構成要素が構築されていないなら作成 wormStorage = [[NSTextStorage alloc] init]; wormLayout = [[NSLayoutManager alloc] init]; wormContainer = [[NSTextContainer alloc] init]; [wormLayout addTextContainer:wormContainer]; [wormStorage addLayoutManager:wormLayout]; } [[wormStorage mutableString] setString:wormString]; // テキスト格納に文字を設定 [wormStorage setAttributes:wormTextAttributes range:NSMakeRange(0, [wormStorage length])]; // 描画時の文字属性を設定 }

かなり単純です。テキストシステム自体がわからない人は、『テキストシステム概説」を、特に「テキストシステムを自分で組み立てる」を見てください。NSString では、描画のたびに毎回属性など必要な描画設定が作られ消去されていたのに比べて、こうしてテキストシステム要素を作成しておいて保持することで、描画時にそれを使うことができます。

これに対して、dealloc メソッドで追加で保持することになったオブジェクトが解放されています。

EvenBetterWormView.m > dealloc
- (void)dealloc { [wormContainer release]; [wormLayout release]; [wormStorage release]; [super dealloc]; }

drawRect: では、こうして保持したテキストシステムを使って描画が行われます。このメソッドのほとんどは、BetterWormView のものと同じです。文字列描画の部分だけを示します。

EvenBetterWormView.m > drawRect:
... unsigned glyphIndex = i % [wormLayout numberOfGlyphs]; NSPoint glyphLocation = [wormLayout locationForGlyphAtIndex:glyphIndex]; NSPoint origin = NSMakePoint(wRect.origin.x - glyphLocation.x, wRect.origin.y); [wormLayout drawGlyphsForGlyphRange:NSMakeRange(glyphIndex, 1) atPoint:origin]; ...

くわしいことは『Cocoa のためのテキストレイアウトプログラミングガイド』の「文字列の描画」>「NSLayoutManager によるテキストの描画」を見てください。グリフの先頭からの順番を求めて、それからグリフ位置を求め、それから原点を計算し、レイアウトマネージャーにグリフを描かせています。

10 ActualWormView クラス

最後に、EvenBetterWormView のサブクラスである ActualWormView では、実際のプレイができるようにします。このクラスは、新しいインスタンス変数がなく、たった 2 つのメソッドをオーバーライドしています。

ActualWormView.m > autoturnAtWalls
- (BOOL)autoturnAtWalls { return NO; }

このメソッドの戻り値は、WormViewupdateState 内で、worm_guts 関数に渡す引数の 1 つとして使われています。すなわち、自動方向変換を行うかどうかです。クラッシュ判定の説明を見てください。このメソッドをオーバーライドすることにより、ワームがクラッシュするようになります。

また、ゲームするのにあまりにも速すぎないよう、フレームレートを設定しています。

ActualWormView.m > desiredFrameRate
- (float)desiredFrameRate { return 7.0; }

これは WormView では、initWithFrame: 内で初期値として設定された値である 1000.0 を返します。

11 まとめと機能追加のアイデア

かなり長くなってしまい後半息切れしてかなり簡単な説明になっていますが、翻訳ガイド等を参考にされれば、十分理解できるものと思います。このサンプルでは、単に描画パフォーマンスについてだけでなく、オブジェクト指向のモジュール化の利点も体験できようになっています。基本の WormView クラスをもとにさまざまなクラスを作成し、それを実行時にインスタンス化して、古いビューと入れ替えることで簡単に複数のクラスを利用できます。うまく設計されていれば、このように数少ないメソッドのオーバーライドだけで機能を追加したり、動作を修正できます。

このサンプルをさらに深く学ぶには、最初に手頃なのが、BetterWormView クラスの修正です。更新長方形の計算はかなり大ざっぱなので、もう少し細かくしてやればいいでしょう。このサンプルは AppKit 内なので、Cocoa の範囲にとどまっていますが、Quartz などの他の API を使えば描画パフォーマンスがどうなるかも実験できます。

このサンプルのように、複数の描画ビューを作ることで、新しく学んでまだ慣れていない描画 API で実装を行うとき、すでによく知っている API でビューを作っておき、それを使って、ビューの描画以外の部分をチェックしておけます。こうすることで、描画コード以外の部分が問題なく動作することを確認でき、新しい API 利用による間違いだけを探すことができるでしょう。この方法は、ビューだけに限りません。たとえば、モデルで範囲外や異常値だけを返すようなテスト用のクラスを作ったり、逆にまず問題が起こらない値しか返さないモデルクラスを作ることで、問題の場所を制限することができます。もちろん、各バージョンごとのスナップショットに戻る手もありますが、他の部分も変更されているので、いつでもテスト用クラスに切り換えられるこの方法はけっこう有効です。こういう事が簡単に行えるようにするためにも、基本的なクラスのインターフェイス設計は大切です。


管理人:神吉 秀典 E-mail:puer@ba.wakwak.com