2014/08/18

プログラミング講座(160) エアホッケー

2013年7月と2014年7月に今月のチャレンジで出題されたエアホッケー。丸1年で完成しました。プレイヤー1(左側)は[W]キーと[S]キー、プレイヤー2(右側)は[O][L]キーを使ってマレットを動かします。7点先取したほうが勝ちです。プログラムを KLB414-4 で発行しました。

図146 エアホッケー
【図146 エアホッケー】

メイン

メインループではゲームを繰り返します。変数 continue は常に "True" です。

' Air Hockey 0.5b
' Copyright (c) 2013-2014 Nonki Takahashi. MIT License.
'
' History:
' 0.5b 2014-07-09 Determined a winner is the first to win 7 points. (KLB414-4)
' 0.4b 2014-07-09 Supported collision between puck and mallet. (KLB414-3)
' 0.3a 2014-07-03 Added mallet control. (KLB414-2)
' 0.2a 2013-07-30 Changed field design. (KLB414-1)
' 0.11a 2013-07-29 Modified for Silverlight. (KLB414-0)
' 0.1a 2013-07-29 Created as alpha version. (KLB414)
'
' Reference:
' LitDev, Small Basic: Dynamic Graphics, TechNet Wiki, 2013-2014.
'
GraphicsWindow.Title = "Air Hockey 0.5b - W,S for P1; O,L for P2"
gw = 598
gh = 428
GraphicsWindow.Width = gw
GraphicsWindow.Height = gh
GraphicsWindow.BackgroundColor = "DimGray"
Field_Init()
continue = "True"
While continue
 Game_Init()
 Game_Start()
 Game_End()
EndWhile

フィールドの初期化

エアホッケーのフィールドを描画します。またパックと両プレイヤーのマレットを Shapes (図形)として作成します。

Sub Field_Init
 fh = 30 ' font height
 GraphicsWindow.FontName = "Trebuchet MS"
 GraphicsWindow.BrushColor = "White"
 GraphicsWindow.FontSize = fh
 score[1]["obj"] = Shapes.AddText(0)
 Shapes.Move(score[1]["obj"], gw / 2 - 100, 10)
 score[2]["obj"] = Shapes.AddText(0)
 Shapes.Move(score[2]["obj"], gw / 2 + 100, 10)
 field["width"] = 580
 field["height"] = 360
 field["x"] = (gw - field["width"]) / 2
 field["y"] = (gh - field["height"] + fh) / 2
 field["x2"] = field["x"] + field["width"]
 field["y2"] = field["y"] + field["height"]
 param["x"] = field["x"] - 10
 param["y"] = (field["y"] + field["y2"]) / 2 - 70
 param["width"] = 20
 param["height"] = 140
 param["border-radius"] = 10
 goal["y"] = param["y"]
 goal["y2"] = param["y"] + param["height"]
 GraphicsWindow.BrushColor = "Black"
 FillRoundRectangle()
 param["x"] = field["x2"] - 10
 FillRoundRectangle()
 GraphicsWindow.BrushColor = "Blue"
 GraphicsWindow.FillRectangle(field["x"], field["y"], field["width"], field["height"])
 GraphicsWindow.PenWidth = 5
 GraphicsWindow.PenColor = "LightGray"
 param["x"] = field["x"] + 20
 param["y"] = field["y"] + 20
 param["width"] = field["width"] - 40
 param["height"] = field["height"] - 40
 param["border-radius"] = 100
 DrawRoundRectangle()
 x = param["x"] + param["width"] / 2
 GraphicsWindow.DrawLine(x, param["y"], x, param["y"] + param["height"])
 GraphicsWindow.BrushColor = "Black"
 GraphicsWindow.PenWidth = 0
 For y = field["y"] + 20 To field["y"] + field["height"] - 20 Step 20
  For x = field["x"] + 20 To field["x"] + field["width"] - 20 Step 20
   GraphicsWindow.FillEllipse(x - 1, y - 1, 2, 2)
  EndFor
 EndFor
 GraphicsWindow.BrushColor = "Yellow"
 puck["size"] = 34
 puck["r"] = puck["size"] / 2
 puck["obj"] = Shapes.AddEllipse(puck["size"], puck["size"])
 GraphicsWindow.BrushColor = "White"
 mallet[1]["size"] = 34
 mallet[1]["r"] = mallet[1]["size"] / 2
 mallet[1]["obj"] = Shapes.AddEllipse(mallet[1]["size"], mallet[1]["size"])
 mallet[2]["size"] = 34
 mallet[2]["r"] = mallet[2]["size"] / 2
 mallet[2]["obj"] = Shapes.AddEllipse(mallet[2]["size"], mallet[2]["size"])
 GraphicsWindow.BrushColor = "DimGray"
 screen = Shapes.AddRectangle(gw, gh)
 Shapes.SetOpacity(screen, 0)
EndSub

ゲームの初期化

パックの位置と速度、マレットの位置を初期化します。

Sub Game_Init
 x = field["x"]
 y = field["y"] + field["height"] / 2
 mallet[1]["cx"] = x + 20
 mallet[1]["cy"] = y
 Shapes.Move(mallet[1]["obj"], mallet[1]["cx"] - mallet[1]["r"], mallet[1]["cy"] - mallet[1]["r"])
 mallet[2]["cx"] = x + field["width"] - 20
 mallet[2]["cy"] = y
 Shapes.Move(mallet[2]["obj"], mallet[2]["cx"] - mallet[2]["r"], mallet[2]["cy"] - mallet[2]["r"])
 puck["cx"] = x + (field["width"] / 2)
 puck["cy"] = y
 Shapes.Move(puck["obj"], puck["cx"] - puck["r"], puck["cy"] - puck["r"])
 v0 = 400
 puck["vx"] = 100
 puck["vy"] = 80
 AdjustV0()
 score[1]["value"] = 0
 Shapes.SetText(score[1]["obj"], score[1]["value"])
 score[2]["value"] = 0
 Shapes.SetText(score[2]["obj"], score[2]["value"])
 deltaY = puck["size"]
 GraphicsWindow.KeyDown = OnKeyDown
EndSub

ゲームのプレイ

一方のプレイヤーが7点先取するまでループし、ゲームを続けます。ループの中で1秒間に24回パックの位置と速度を更新します。

Sub Game_Start
 inGame = "True"
 dt = 1 / 24 ' [second]
 While inGame
  start = Clock.ElapsedMilliseconds
  UpdatePuck()
  delay = dt * 1000 - (Clock.ElapsedMilliseconds - start)
  If 0 < delay Then
   Program.Delay(delay)
  EndIf
 EndWhile
EndSub

ゲームの終了

勝利したプレイヤーを表示します。

Sub Game_End
 Shapes.SetOpacity(screen, 40)
 GraphicsWindow.FontSize = 40
 GraphicsWindow.BrushColor = "White"
 result = Shapes.AddText("PLAYER " + winner + " WON")
 x = (gw - 283) / 2
 y = (gh - 40) / 2
 Shapes.Move(result, x, y)
 Sound.PlayBellRingAndWait()
 Program.Delay(5000)
 Shapes.SetOpacity(screen, 0)
 Shapes.Remove(result)
EndSub

パックの速度調整

パックの速度 puck["vx"], puck["vy"] のスカラー値が v0 になるよう調整します。

Sub AdjustV0
 v = Math.SquareRoot(Math.Power(puck["vx"], 2) + Math.Power(puck["vy"], 2))
 puck["vx"] = puck["vx"] * v0 / v
 puck["vy"] = puck["vy"] * v0 / v
EndSub

衝突の検出

パックとマレットの衝突を検出し、衝突した場合はパックの速度を更新します。このサブルーチンは LitDev 氏による TechNet Wiki の記事 Dynamic Graphics の同名のサブルーチンを参考にしています。オリジナルはボール同士の衝突ですが、ここではマレットはプレイヤーによって保持されるため、パックだけが跳ね返るように変更しました。

Sub CollisionCheck
 For i = 1 To 2
  dx = mallet[i]["cx"] - puck["cx"]
  dy = mallet[i]["cy"] - puck["cy"]
  distance = Math.SquareRoot(dx * dx + dy * dy)
  If distance < puck["size"] Then
   Sound.PlayClick()
   relativeVx = puck["vx"]
   relativeVy = puck["vy"]
   nx = dx / distance
   ny = dy / distance
   l = nx * relativeVx + ny * relativeVy
   relativeVx = relativeVx - (2 * l * nx)
   relativeVy = relativeVy - (2 * l * ny)
   puck["vx"] = relativeVx
   puck["vy"] = relativeVy
   puck["cx"] = puck["cx"] - nx * (puck["size"] - distance)
   puck["cy"] = puck["cy"] - ny * (puck["size"] - distance)
  EndIf
 EndFor
EndSub

キー入力イベントハンドラ

キー入力時、マレットを動かします。

Sub OnKeyDown
 key = GraphicsWindow.LastKey
 If key = "W" Then ' player 1 up
  If goal["y"] <= mallet[1]["cy"] - deltaY Then
   mallet[1]["cy"] = mallet[1]["cy"] - deltaY
   Shapes.Move(mallet[1]["obj"], mallet[1]["cx"] - mallet[1]["r"], mallet[1]["cy"] - mallet[1]["r"])
  EndIf
 ElseIf key = "S" Then ' player 1 down
  If mallet[1]["cy"] + deltaY <= goal["y2"] Then
   mallet[1]["cy"] = mallet[1]["cy"] + deltaY
   Shapes.Move(mallet[1]["obj"], mallet[1]["cx"] - mallet[1]["r"], mallet[1]["cy"] - mallet[1]["r"])
  EndIf
 ElseIf key = "O" Then ' player 2 up
  If goal["y"] <= mallet[2]["cy"] - deltaY Then
   mallet[2]["cy"] = mallet[2]["cy"] - deltaY
   Shapes.Move(mallet[2]["obj"], mallet[2]["cx"] - mallet[2]["r"], mallet[2]["cy"] - mallet[2]["r"])
  EndIf
 ElseIf key = "L" Then ' player 2 down
  If mallet[2]["cy"] + deltaY <= goal["y2"] Then
   mallet[2]["cy"] = mallet[2]["cy"] + deltaY
   Shapes.Move(mallet[2]["obj"], mallet[2]["cx"] - mallet[2]["r"], mallet[2]["cy"] - mallet[2]["r"])
  EndIf
 EndIf
EndSub

パックの速度と位置の更新

パックは慣性の法則にしたがって同じ速度で移動するので、それをシミュレートします。ゴールの判定、フィールドの枠との衝突の検出も行います。最後に CollisionCheck() を呼び出してマレットとの衝突の検出も行います。ゴールが決まった場合はパックの位置を中央に戻します。一方が7点先取した場合は、inGame フラグに "False" を設定し、Game_Start() 内のループを抜けるようにします。

Sub UpdatePuck
 isGoal = "False"
 x = puck["cx"] + dt * puck["vx"]
 If x < field["x"] + puck["r"] Then
  y = puck["cy"] + dt * (field["x"] - puck["cx"]) * puck["vy"] / puck["vx"]
  If (goal["y"] < y) And (y < goal["y2"]) Then
   score[2]["value"] = score[2]["value"] + 1
   Shapes.SetText(score[2]["obj"], score[2]["value"])
   isGoal = "True"
   If score[2]["value"] = 7 Then
    inGame = "False"
    winner = 2
   EndIf
  Else
   puck["cx"] = field["x"] + puck["r"] + (field["x"] + puck["r"] - x)
   puck["vx"] = -puck["vx"]
   Sound.PlayClick()
  EndIf
 ElseIf field["x2"] - puck["r"] < x Then
  y = puck["cy"] + dt * (field["x2"] - puck["cx"]) * puck["vy"] / puck["vx"]
  If (goal["y"] < y) And (y < goal["y2"]) Then
   score[1]["value"] = score[1]["value"] + 1
   Shapes.SetText(score[1]["obj"], score[1]["value"])
   isGoal = "True"
   If score[1]["value"] = 7 Then
    inGame = "False"
    winner = 1
   EndIf
  Else
   puck["cx"] = field["x2"] - puck["r"] - (x - (field["x2"] - puck["r"]))
   puck["vx"] = -puck["vx"]
   Sound.PlayClick()
  EndIf
 Else
  puck["cx"] = x
 EndIf
 If isGoal Then
  If y < goal ["y"] + puck["r"] Then
   y = goal["y"] + puck["r"]
  ElseIf goal["y2"] - puck["r"] < y Then
   y = goal["y2"] - puck["r"]
  EndIf
  Shapes.Move(puck["obj"], x - puck["r"], y - puck["r"])
  Sound.PlayChimeAndWait()
  puck["cx"] = gw / 2
  puck["cy"] = (field["y"] + field["y2"]) / 2
  AdjustV0()
 Else
  y = puck["cy"] + dt * puck["vy"]
  If y < field["y"] + puck["r"] Then
   puck["cy"] = field["y"] + puck["r"] + (field["y"] + puck["r"] - y)
   puck["vy"] = -puck["vy"]
   Sound.PlayClick()
  ElseIf field["y2"] - puck["r"] < y Then
   puck["cy"] = field["y2"] - puck["r"] - (y - (field["y2"] - puck["r"]))
   puck["vy"] = -puck["vy"]
   Sound.PlayClick()
  Else
   puck["cy"] = y
  EndIf
  CollisionCheck()
  Shapes.Move(puck["obj"], puck["cx"] - puck["r"], puck["cy"] - puck["r"])
 EndIf
EndSub

ラウンド長方形の描画

角が丸い長方形の枠を描画します。

Sub DrawRoundRectangle
 Stack.PushValue("local", param)
 Stack.PushValue("local", local)
 local = param
 param = ""
 param["r"] = local["border-radius"]
 If (local["width"] / 2 < param["r"]) Or (local["height"] / 2 < param["r"]) Then
  param["r"] = Math.Min(local["width"] / 2, local["height"] / 2)
 EndIf
 param["da"] = 5
 param["x"] = local["x"] + param["r"]
 param["y"] = local["y"] + param["r"]
 param["a1"] = 180
 param["a2"] = 270
 DrawArc()
 GraphicsWindow.DrawLine(local["x"] + param["r"], local["y"], local["x"] + local["width"] - param["r"], local["y"])
 param["x"] = local["x"] + local["width"] - param["r"]
 param["y"] = local["y"] + param["r"]
 param["a1"] = 270
 param["a2"] = 360
 DrawArc()
 GraphicsWindow.DrawLine(local["x"] + local["width"], local["y"] + param["r"], local["x"] + local["width"], local["y"] + local["height"] - param["r"])
 param["x"] = local["x"] + local["width"] - param["r"]
 param["y"] = local["y"] + local["height"] - param["r"]
 param["a1"] = 0
 param["a2"] = 90
 DrawArc()
 GraphicsWindow.DrawLine(local["x"] + param["r"], local["y"] + local["height"], local["x"] + local["width"] - param["r"], local["y"] + local["height"])
 param["x"] = local["x"] + param["r"]
 param["y"] = local["y"] + local["height"] - param["r"]
 param["a1"] = 90
 param["a2"] = 180
 DrawArc()
 GraphicsWindow.DrawLine(local["x"], local["y"] + param["r"], local["x"], local["y"] + local["height"] - param["r"])
 local = Stack.PopValue("local")
 param = Stack.PopValue("local")
EndSub

ラウンド長方形の塗りつぶし

角が丸い長方形の内部を塗りつぶします。

Sub FillRoundRectangle
 Stack.PushValue("local", param)
 If (param["width"] / 2 < param["border-radius"]) Or (param["height"] / 2 < param["border-radius"]) Then
  param["border-radius"] = Math.Min(param["width"] / 2, param["height"] / 2)
 EndIf
 GraphicsWindow.FillEllipse(param["x"], param["y"], param["border-radius"] * 2, param["border-radius"] * 2)
 GraphicsWindow.FillRectangle(param["x"] + param["border-radius"], param["y"], param["width"] - param["border-radius"] * 2, param["height"])
 GraphicsWindow.FillEllipse(param["x"] + param["width"] - param["border-radius"] * 2, param["y"], param["border-radius"] * 2, param["border-radius"] * 2)
 GraphicsWindow.FillRectangle(param["x"], param["y"] + param["border-radius"], param["width"], param["height"] - param["border-radius"] * 2)
 GraphicsWindow.FillEllipse(param["x"], param["y"] + param["height"] - param["border-radius"] * 2, param["border-radius"] * 2, param["border-radius"] * 2)
 GraphicsWindow.FillEllipse(param["x"] + param["width"] - param["border-radius"] * 2, param["y"] + param["height"] - param["border-radius"] * 2, param["border-radius"] * 2, param["border-radius"] * 2)
 param = Stack.PopValue("local")
EndSub

弧の描画

弧を描画します。

Sub DrawArc
 Stack.PushValue("local", param)
 Stack.PushValue("local", local)
 Stack.PushValue("local", a)
 local = param
 param = ""
 local["pw"] = GraphicsWindow.PenWidth
 local["pc"] = GraphicsWindow.PenColor
 local["bc"] = GraphicsWindow.BrushColor
 GraphicsWindow.BrushColor = local["pc"]
 local["r1"] = local["r"] - local["pw"] / 2
 local["r2"] = local["r"] + local["pw"] / 2
 For a = local["a1"] To local["a2"] Step local["da"]
  local["rad"] = Math.GetRadians(a)
  param["x1"] = local["x"] + local["r1"] * Math.Cos(local["rad"])
  param["y1"] = local["y"] + local["r1"] * Math.Sin(local["rad"])
  param["x2"] = local["x"] + local["r2"] * Math.Cos(local["rad"])
  param["y2"] = local["y"] + local["r2"] * Math.Sin(local["rad"])
  If local["a1"] < a Then
   FillQuadrangle()
  EndIf
  param["x4"] = param["x1"]
  param["y4"] = param["y1"]
  param["x3"] = param["x2"]
  param["y3"] = param["y2"]
 EndFor
 GraphicsWindow.BrushColor = local["bc"]
 a = Stack.PopValue("local")
 local = Stack.PopValue("local")
 param = Stack.PopValue("local")
EndSub

四角形の塗りつぶし

四角形の内部を塗りつぶします。

Sub FillQuadrangle
 GraphicsWindow.FillTriangle(param["x1"], param["y1"], param["x2"], param["y2"], param["x3"], param["y3"])
 GraphicsWindow.FillTriangle(param["x3"], param["y3"], param["x4"], param["y4"], param["x1"], param["y1"])
EndSub

このゲームのように物理現象を扱うゲームでは、物理の知識を活用します。このエアホッケーでは摩擦も重力も影響しませんが、ゲームによってはそれらの知識も必要になります。

今回でゲームプログラミングチュートリアルは一旦終了とします。紹介したゲームは Small Basic に関する MSDNフォーラムで誕生しました。特に今月のチャレンジには毎回ゲームの問題が出題されています。是非挑戦してみてください。

| | コメント (0) | トラックバック (0)

2014/08/03

プログラミング講座(159) 射撃ゲーム

2013年の8月のチャレンジ問題に(1)アヒルの射撃ゲームのオープニングを作れ、(2)アヒルの射撃ゲームを作れというものがありました。私はそのとき前半だけに挑戦しました。今回後半部分に着手し、プログラムID TLR995-2 として発行しました。

全体として425行あるのですが、図形エディタによって自動生成された部分が166行あるので、実際にコーディングしたのは259行です。

メイン

射撃部分を一応追加してありますが、アヒルが一羽しか出てきません。増やすとプログラムが複雑になって読みにくくなると考えたからです。

' DuckShoot 0.31b
' Copyright (c) 2013-2014 Nonki Takahashi. The MIT License.
'
' History:
' 0.31b 2014-08-01 Sorted subroutines. (TLR995-2)
' 0.3b 2014-07-26 Supported in remote. (TLR995-1)
' 0.2b 2014-07-13 Created a core of shooting. (TLR995-0)
' 0.1a 2013-08-03 Created as DuckShoot opening. (TLR995)
' 0.0 2013-08-03 14:38:49 Shapes generated by Shapes 1.5b.
'
GraphicsWindow.Title = "DuckShoot 0.31b"
SB_Workaround()
Opening()
GameInit()
GameLoop()
Ending()

エンディング

撃った数と当った数から最後の点数を計算して表示しています。

Sub Ending
 Shapes.Remove(stair)
 If hit[i] Then
  point = 1
 Else
  point = 0
 EndIf
 score = point * 110 - shoot * 10
 GraphicsWindow.BrushColor = "White"
 GraphicsWindow.FontSize = 40
 GraphicsWindow.DrawText(170, 180, "SCORE " + score)
 Program.Delay(500)
 GraphicsWindow.DrawText(170, 230, "SHOOT " + shoot)
 Program.Delay(500)
 GraphicsWindow.DrawText(170, 280, "HIT " + point)
 Program.Delay(2000)
 GraphicsWindow.FontSize = 50
 GraphicsWindow.DrawText(170, 80, "GAME OVER")
EndSub

初期化

アヒルは一羽しかいないのですが、将来のため、アヒルに関する変数を配列にしてあります。このゲームではプレイヤーはマウスで銃の操作することにしたので、マウスのクリックと移動のイベントを使います。

Sub GameInit
 ' Game start
 Shapes.ShowShape(duck[1])
 mouseDown = "False"
 GraphicsWindow.MouseDown = OnMouseDown
 GraphicsWindow.MouseMove = OnMouseMove
 Shapes.Animate(duck[1], gw, 150, 3000)
 Program.Delay(3000)
 i = 1
 hit[i] = "False"
 a[i] = 90
 x[i] = -dw
 yDuck = 150
 y[i] = yDuck
 Shapes.Move(duck[i], x[i], y[i])
 GraphicsWindow.PenWidth = 0
 GraphicsWindow.BrushColor = bgColor
 yRS = yStair - (yDuck + dh / 2)
 shoot = 0
EndSub

メインループ

前回はタイマーイベントの中で障害物を移動させましたが、今回はメインループの中でアヒルを4ドットずつ右に移動させています。

プレイヤーが銃を撃ったとき、それが当たったかどうかを判定するために、移動している Shapes のアヒルのイメージの裏にもう一枚のアヒルを描画しています。標的が円や長方形であればマウス座標と標的の座標から計算で当たったかどうかの判定ができます。今回のアヒルは複雑な図形なので、計算するのではなく、GraphicsWindow.GetPixel() で得られる色が背景の色かどうかという判定にしました。

ここでブラウザ上の問題が発生しました。GraphicsWindow.DrawImage() の後に GraphicsWindow.GetPixel() を呼び出すとプログラムがハングし(応答しなくなり)ます。IDE にインポートした場合は問題ありません。この問題を回避するために、DrawImage() を使うのではなく、図形エディタで作成したデータ shape を元に FillRectangle(), FillTriangle(), FillEllipse() を使うようにしました。これはアヒルのデータを図形エディタで作ったのでうまくいきました。

当たった場合、イメージを Shapes.Zoom() を使って倒します。このオペレーションは与える拡大レベルが 0.1 から 20 までの範囲なので注意が必要です。今回は 0.1 になるまで拡大レベルを少しずつ減らしています。

ここで今度はIDE にインポートしたプログラム上で問題が発生しました。アヒルのイメージを 0.7 より小さくするとプログラムが停止します。このアヒルのイメージは背景の部分が透明でした。透明の部分があるイメージを 0.7 より小さく縮小するとこの問題が発生します。ブラウザ上ではこの問題は起きません。この問題を回避するために、背景の色を不透明にしたイメージを使うことにしました。

Sub GameLoop
 While x[i] < gw
  Program.Delay(50)
  If mouseDown Then
   Sound.PlayClick()
   shoot = shoot + 1
   If silverlight Then
    shX = x[i]
    shY = y[i]
    iMin = 1
    iMax = 10
    Shapes_Draw()
   Else
    GraphicsWindow.DrawImage(img, x[i], y[i])
   EndIf
   color = GraphicsWindow.GetPixel(dx, dy)
   GraphicsWindow.PenWidth = 0
   GraphicsWindow.BrushColor = bgColor
   GraphicsWindow.FillRectangle(x[i], y[i], dw, dh)
   If color <> bgColor Then
    hit[i] = "True"
   EndIf
   mouseDown = "False"
  EndIf
  If hit[i] Then
   If 0 < a[i] Then
    a[i] = a[i] - 5
    cos = Math.Round(Math.Sin(Math.GetRadians(a[i])) * 100) / 100
    Shapes.Zoom(duck[i], 1, Math.Max(cos, 0.1))
    deltaY = yRS - yRS * cos
    y[i] = yDuck + deltaY
   EndIf
  EndIf
  x[i] = x[i] + 4
  Shapes.Move(duck[i], x[i], y[i])
 EndWhile
EndSub

マウスイベントハンドラ(クリック時)

フラグを立ててマウス座標を保存します。GameLoop() の中でこの座標を元に撃った弾がアヒルに当たるかどうかを判定します。

Sub OnMouseDown
 mouseDown = "True"
 dx = GraphicsWindow.MouseX
 dy = GraphicsWindow.MouseY
EndSub

マウスイベントハンドラ(移動時)

マウスの動きに合わせて照準を移動します。ただし、マウスポインタがウィンドウ内にあるときはマウスポインタ(矢印のマーク)を消し、ウィンドウ外ではマウスポインタを表示します。

Sub OnMouseMove
 mx = GraphicsWindow.MouseX
 my = GraphicsWindow.MouseY
 If 0 <= mx And mx < gw And 0 <= my And my < gh Then
  Mouse.HideCursor()
  Shapes.Move(sighter, mx - 40, my - 40)
 Else
  Mouse.ShowCursor()
 EndIf
EndSub

オープニング

ゲームタイトルとアヒル・照準のイメージを表示します。元々は図形エディタで作成した図形の組み合わせだったのですが、よりスムーズに移動させるためアヒルと照準のイメージは .png ファイルに変換しました。図形エディタで作成した図形を .png ファイルに変換する方法については、こちら(英語)をご覧ください。

ただし、アヒルに関しては2つの理由でイメージと図形の両方を使っています。一つはオープニングで瞬きさせるため、もう一つは前述したブラウザ上での問題を回避するためです。

Sub Opening
 bgColor = "#8B0000" ' DarkRed
 stColor = "#990000" ' for stair
 GraphicsWindow.BackgroundColor = bgColor
 gw = 598
 gh = 428
 GraphicsWindow.Width = gw
 GraphicsWindow.Height = gh
 GraphicsWindow.PenWidth = 0
 GraphicsWindow.BrushColor = bgColor
 GraphicsWindow.FillRectangle(0, 0, gw, gh)
 ' add duck image
 path = "http://gallery.technet.microsoft.com/site/view/file/119954/1/Duck2.png"
 img = ImageList.LoadImage(path)
 If silverlight Then
  dw = 246 + 1
  dh = 192 + 2
 Else
  dw = ImageList.GetWidthOfImage(img)
  dh = ImageList.GetHeightOfImage(img)
 EndIf
 duck[1] = Shapes.AddImage(img)
 Shapes.Move(duck[1], 194, 150)
 Shapes.HideShape(duck[1])
 ' add stair
 GraphicsWindow.BrushColor = stColor
 GraphicsWindow.PenWidth = 0
 stair = Shapes.AddRectangle(gw, gh - yStair)
 yStair = Math.Round(gh * 2 / 3)
 Shapes.Move(stair, 0, yStair)
 Shapes.HideShape(stair)
 ' initialize shapes
 GraphicsWindow.FontName = "Trebuchet MS"
 GraphicsWindow.FontSize = 50
 GraphicsWindow.BrushColor = "White"
 title = Shapes.AddText("DuckShoot")
 Shapes.Move(title, 170, 60)
 Shapes_Init()
 ' add shapes
 scale = 1
 angle = 0
 iMin = 1
 iMax = 10
 Shapes_Add()
 ' add sighter image
 path = "http://gallery.technet.microsoft.com/site/view/file/119955/1/Sighter.png"
 sighter = Shapes.AddImage(path)
 Shapes.Move(sighter, 250, 200)
 ' Blink start
 wait = "True"
 ems = Clock.ElapsedMilliseconds
 While wait
  Program.Delay(1000)
  x = 250 + (Math.GetRandomNumber(50) - 25)
  y = 200 + (Math.GetRandomNumber(50) - 25)
  Shapes.Move(sighter, x, y)
  Program.Delay(100)
  Shapes.HideShape(shape[4]["obj"])
  Program.Delay(100)
  Shapes.ShowShape(shape[4]["obj"])
  If 5000 < Clock.ElapsedMilliseconds - ems Then
   wait = "False"
  EndIf
 EndWhile
 Shapes.ShowShape(stair)
 iMin = 1
 iMax = 10
 Shapes_Remove()
 Shapes.Remove(title)
EndSub

図形の描画

撃った弾が当たったかどうかの判定のために作ったサブルーチンです。現在のところこのプログラムでしか利用していませんが、将来のためになるべく汎用的にしてあります。ただし、まだいくつか制限があります。図形の枠には対応していません。長方形と楕円の回転には対応していません。ブラウザ上で実行するときのみ呼び出されます。

Sub Shapes_Draw
 ' Shapes | draw shapes
 ' param iMin, iMax - shape indices to add
 ' param shape - array of shapes
 ' param scale - 1 if same scale
 ' TODO to draw border line for rectangle, triangle and ellipse
 ' TODO to rotate rectangle and ellipse (text?)
 Stack.PushValue("local", x)
 Stack.PushValue("local", y)
 Stack.PushValue("local", i)
 s = scale
 For i = iMin To iMax
  If shape[i]["pw"] > 0 Then
   GraphicsWindow.PenColor = shape[i]["pc"]
  EndIf
  If Text.IsSubText("rect|ell|tri|text", shape[i]["func"]) Then
   GraphicsWindow.BrushColor = shape[i]["bc"]
  EndIf
  x = shX + shape[i]["x"] * s
  y = shY + shape[i]["y"] * s
  If shape[i]["func"] = "rect" Then
   GraphicsWindow.FillRectangle(x, y, shape[i]["width"]* s, shape[i]["height"] * s)
  ElseIf shape[i]["func"] = "ell" Then
   GraphicsWindow.FillEllipse(x, y, shape[i]["width"]* s, shape[i]["height"] * s)
  ElseIf shape[i]["func"] = "tri" Then
   x[1] = shX + shape[i]["x"] * s + shape[i]["x1"] * s
   y[1] = shY + shape[i]["y"] * s + shape[i]["y1"] * s
   x[2] = shX + shape[i]["x"] * s + shape[i]["x2"] * s
   y[2] = shY + shape[i]["y"] * s + shape[i]["y2"] * s
   x[3] = shX + shape[i]["x"] * s + shape[i]["x3"] * s
   y[3] = shY + shape[i]["y"] * s + shape[i]["y3"] * s
   angle = shape[i]["angle"]
   If angle <> 0 Then
    n = 3
    ox = (x[2] + x[3]) / 2
    oy = (y[1] + y[2]) / 2
    Shapes_RotatePolyline()
   EndIf
   GraphicsWindow.FillTriangle(x[1], y[1], x[2], y[2], x[3], y[3])
  ElseIf shape[i]["func"] = "line" Then
   x[1] = shX + shape[i]["x"] * s + shape[i]["x1"] * s
   y[1] = shY + shape[i]["y"] * s + shape[i]["y1"] * s
   x[2] = shX + shape[i]["x"] * s + shape[i]["x2"] * s
   y[2] = shY + shape[i]["y"] * s + shape[i]["y2"] * s
   If angle <> 0 Then
    n = 3
    ox = (x[2] + x[3]) / 2
    oy = (y[1] + y[2]) / 2
    Shapes_RotatePolyline()
   EndIf
   GraphicsWindow.DrawLine(x[1], y[1], x[2], y[2])
  ElseIf shape[i]["func"] = "text" Then
   If silverlight Then
    fs = Math.Floor(shape[i]["fs"] * 0.9)
   Else
    fs = shape[i]["fs"]
   EndIf
   GraphicsWindow.FontSize = fs * s
   GraphicsWindow.FontName = shape[i]["fn"]
   GraphicsWindow.DrawText(x, y, shape[i]["text"])
  EndIf
 EndFor
 i = Stack.PopValue("local")
 y = Stack.PopValue("local")
 x = Stack.PopValue("local")
EndSub

ポリラインの回転

三角形の頂点を回転させるために書きましたが、これも汎用性を持たせるため、ポリラインの頂点を回転できるようになっています。

Sub Shapes_RotatePolyline
 ' Shapes | rotate polyline
 ' param n - number of points
 ' param x, y - array of x and y co-ordinates
 ' param ox, oy, - center of rotation
 ' param angle - angle of rotation
 Stack.PushValue("local", i)
 _a = Math.GetRadians(angle)
 For i = 1 To n
  xi = (x[i] - ox) * Math.Cos(_a) + (y[i] - oy) * Math.Sin(_a)
  yi = - (x[i] - ox) * Math.Sin(_a) + (y[i] - oy) * Math.Cos(_a)
  x[i] = xi + ox
  y[i] = yi + oy
 EndFor
 i = Stack.PopValue("local")
EndSub

自動生成されたコード

これ以降のコードは図形エディタ Shapes 1.5b により生成されたコードです。各サブルーチンの働きについて簡単に説明するだけにしておきます。図形エディタではこの他にも図形を回転するサブルーチンもありますが、使用しないので削除してあります。

  • SB_Workaround - 回避策のためブラウザ上での実行かどうかを判断します。
  • Shapes_Add - 配列 shape のデータから Shapes オブジェクトによる図形を追加します。
  • Shapes_CalcWidthAndHeight - 配列 shape のデータ全体の幅と高さを求めます。
  • Shapes_Init - アヒルの図形データを配列 shape に格納します。
  • Shapes_Move - Shapes_Add で追加した Shapes 図形を移動します。
  • Shapes_Remove - Shapes_Add で追加した Shapes 図形を削除します。

アヒルの射撃ゲームとしては、アヒルをもう少し増やして難易度を上げると面白くなるでしょう。チャレンジしてみませんか?

| | コメント (0) | トラックバック (0)

2014/07/31

プログラミング講座(158) 垂直スクロールゲーム(解説その2)

垂直スクロールゲーム Turtle Dodger の解説を続けます。

キー入力イベントハンドラ

カメの動きは矢印キーを使うことにしました。マウス操作はちょっとやりずらいと考えました。イベントハンドラの中ではフラグを立てて入力されたキーを記憶するだけにしました。その後の処理は Game() のループの中で行います。また、フラグ moving が "True" の間(カメの移動が終わるまで)は次のキー入力を受け付けないようにしています。

Sub OnKeyDown
 If Not[moving] Then
  moving = "True"
  key = GraphicsWindow.LastKey
 EndIf
EndSub

タイマーイベントハンドラ

障害物が落ちてくる処理はタイマーを使いました。1秒間に24回(41ms毎に)スクロールするようにしています。また 500ms 毎に新しい障害物を発生させています。

Sub OnTick
 If Not[scrolling] Then
  scrolling = "True"
  ems = Clock.ElapsedMilliseconds
  If ems - lastems > 500 Then
   AddObject()
   lastems = ems
  EndIf
  ScrollObject()
  scrolling = "False"
 EndIf
 If debug Then
  x = Math.Floor(Turtle.X)
  y = Math.Floor(Turtle.Y)
  Shapes.SetText(pos, "(" + x + "," + y + ")")
  Shapes.Move(cross1, x, y)
  Shapes.Move(cross2, x, y)
 EndIf
EndSub

オープニング

ゲームとは直接関係ないのですが、オープニング画面があったほうがゲームらしいので、タイトルとカメのイメージを表示させています。イメージファイルをインターネット上のどこかに置かないとブラウザ上 (smallbasic.com) での実行ができません。私のこれまでのゲームでは個人のサーバー上にイメージを置いていました。これだと、このサーバーが利用できなくなった時に、ゲームのイメージもアクセスできなくなってしまいます。個人のサーバーより長く利用できることを期待して TechNet Gallery にイメージを置いてみました。

このオープニングではカメを動かしていないので、GraphicsWindow.DrawImage() を使ったほうがよりシンプルだったかもしれません。

Sub Opening
 url = "http://gallery.technet.microsoft.com/Turtle-PNG-Bitmap-for-582b449c/file/116666/1/Turtle.png"
 bigTurtle = Shapes.AddImage(url)
 Shapes.Move(bigTurtle, 180, 140)
 GraphicsWindow.BrushColor = "White"
 GraphicsWindow.FontName = "Trebuchet MS"
 GraphicsWindow.FontSize = 50
 x = (gw - 443) / 2
 y = 40
 GraphicsWindow.DrawText(x, y, title)
 Program.Delay(3000)
 GraphicsWindow.Clear()
EndSub

ゲーム開始直前の処理

オープニングのあと急にゲームが始まると慌ててしまうので、2秒ほど "Ready?" と表示するようにしました。

Sub Ready
 GraphicsWindow.FontSize = 40
 rdy = Shapes.AddText("Ready?")
 x = (gw - 130) / 2
 y = 100
 Shapes.Move(rdy, x, y)
 For opacity = 100 To 0 Step -10
  Shapes.SetOpacity(rdy, opacity)
  Program.Delay(200)
 EndFor
 Shapes.Remove(rdy)
EndSub

障害物のスクロール

すべての障害物を下に 5 ピクセル(ドット)だけ移動(スクロール)します。タイマーイベントハンドラの処理の中で呼び出していますが、Shapes に関する処理はイベント中でも問題なく動作します。

ウィンドウより下に移動した障害物はここで削除しています。削除しないとウィンドウの下に入って見えなくなった障害物の処理も継続し、処理がどんどん重くなってしまいます。

また、この処理の中でカメと障害物の衝突検出を行っています。お互いの中心間の距離で衝突を判定しています。

Sub ScrollObject
 iMin = obj["iMin"]
 iMax = obj["iMax"]
 For i = iMin To iMax
  x = obj[i]["x"]
  y = obj[i]["y"] + 5
  tx = Math.Floor(Turtle.X)
  ty = Math.Floor(Turtle.Y)
  d = Math.SquareRoot(Math.Power(tx - x, 2) + Math.Power(ty - y, 2))
  If d < (size[obj[i]["type"]] + 16) / 2 Then
   cd = "True"   ' collision detected
   Goto break
  EndIf
  If y > gh Then
   passed = passed + 1
   Shapes.SetText(score, passed)
   Shapes.Remove(obj[i]["obj"])
   obj[i] = ""
   obj["iMin"] = i + 1
  Else
   Shapes.Move(obj[i]["obj"], x, y)
   obj[i]["x"] = x
   obj[i]["y"] = y
  EndIf
 EndFor
 break:
EndSub

オープニング画面なども付けたので全体で 200 行弱の大きさになっていますが、最初に挑戦するグラフィカルなゲームとしては垂直スクロールゲームはお勧めです。

| | コメント (0) | トラックバック (0)

2014/07/30

プログラミング講座(157) 垂直スクロールゲーム(解説その1)

今回から GraphicsWindow を使ったゲームを扱っていきます。

以前このブログでも紹介した垂直スクロールゲームをゲームプログラミング解説のために少し書き換えました。プログラムID QZN342-4 として公開しました。

メイン

4つのサブルーチンを呼んでいます。タイトルは毎回バージョンを変更するので、Init() の外に出しました。デバッグ時のみ呼び出す処理を有効にするには debug = "True" を指定します。

' Turtle Dodger 0.6b
' Copyright (c) 2014 Nonki Takahashi. The MIT License.
'
' History:
' 0.6b 2014-07-30 Changed image and sorted subroutines. (QZN342-4)
' 0.5b 2014-04-17 Changed to detect collision. (QZN342-3)
' 0.4a 2014-04-17 Added opening. (QZN342-2)
' 0.3a 2014-04-02 Avoided to hold while Turtle moving. (QZN342-1)
' 0.2a 2014-04-02 Changed for Silverlight. (QZN342-0)
' 0.1a 2014-04-02 Created. (QZN342)
'
title = "Turtle Dodger 0.6b"
GraphicsWindow.Title = title
debug = "False"
Init()
Opening()
Game()
Closing()

障害物の追加

今回サブルーチンをアルファベット順に並び替えました。プログラムが長くなってくるとプログラム編集時にサブルーチンを探すのに手間取るので並び替えをしています。A で始まるこのサブルーチンから紹介します。AddObject() では上から降ってくる障害物を1つ Shapes.AddRectangle() で作成します。このサブルーチンでは Math.GetRandomNumber() を3回呼んでいます。色と位置と角度を変えるためです。

Sub AddObject
 iMax = obj["iMax"] + 1
 obj["iMax"] = iMax
 GraphicsWindow.PenWidth = 1
 type = Math.GetRandomNumber(3)
 obj[iMax]["type"] = type
 GraphicsWindow.BrushColor = color[type]
 sz = size[type]
 obj[iMax]["obj"] = Shapes.AddRectangle(sz, sz)
 x = Math.GetRandomNumber(gw - 20) + 10
 y = -20
 obj[iMax]["x"] = x
 obj[iMax]["y"] = y
 Shapes.Move(obj[iMax]["obj"], x, y)
 Shapes.Rotate(obj[iMax]["obj"], Math.GetRandomNumber(360))
EndSub

終了時の処理

カメを3回転させて、"GAME OVER" と表示します。この文字列は左右の中央にくるように計算されています。詳細についてはこちら(英語)をご覧ください。

Sub Closing
 Timer.Pause()
 Turtle.Turn(720)
 GraphicsWindow.BrushColor = "White"
 GraphicsWindow.FontName = "Trebuchet MS"
 GraphicsWindow.FontSize = 40
 x = (gw - 217) / 2
 y = 100
 GraphicsWindow.DrawText(x, y, "GAME OVER")
 Program.Delay(3000)
EndSub

ゲーム本体

ゲームの主人公はカメ (Turtle) にしました。一般的には Shapes.AddImage() などを使うことが多いと思いますが、Turtle のほうがより操作が簡単になると考えました。メインのキャラクターと降ってくる障害物を同時に動かす必要があります。メインキャラクターはマウスやキーボードのイベントで、障害物はタイマーのイベントで動かすと全体をイベント駆動型のプログラムにできます。ただイベント処理が重くなり過ぎると同時の動作に影響が出ます。また、今回は Turtle を使っているのですが、Small Basic v1.0 の問題(英語)のために、イベント処理の中で Turtle を動かすことを断念しました。

障害物との衝突がない限り While ループを回り続けます。

Sub Game
 Turtle.Speed = 7
 Turtle.PenUp()
 x = gw / 2
 y = gh - 40
 GraphicsWindow.BrushColor = "White"
 GraphicsWindow.FontSize = 18
 score = Shapes.AddText("0")
 Shapes.Move(score, 20, 20)
 If debug Then
  GraphicsWindow.BrushColor = "White"
  GraphicsWindow.FontSize = 12
  pos = Shapes.AddText("(" + x + "," + y + ")")
  GraphicsWindow.PenWidth = 1
  cross1 = Shapes.AddLine(0, -8, 0, 8)
  cross2 = Shapes.AddLine(-8, 0, 8, 0)
  Shapes.Move(cross1, x, y)
  Shapes.Move(cross2, x, y)
  Shapes.Move(pos, gw - 100, 20)
 EndIf
 Turtle.MoveTo(x, y)
 Turtle.Angle = 0
 Not = "False=True;True=False;"
 moving = "False"
 scrolling = "False"
 Ready()
 GraphicsWindow.KeyDown = OnKeyDown
 tick = "False"
 Timer.Interval = 1000 / 24
 Timer.Tick = OnTick
 lastems = Clock.ElapsedMilliseconds
 obj["iMin"] = 1
 While Not[cd]
  If moving Then
   If key = "Left" Then
    Turtle.TurnLeft()
    Turtle.Move(30)
    Turtle.TurnRight()
   ElseIf key = "Right" Then
    Turtle.TurnRight()
    Turtle.Move(30)
    Turtle.TurnLeft()
   EndIf
   moving = "False"
  Else
   Program.Delay(100)
  EndIf
 EndWhile
EndSub

初期化

ウィンドウのサイズや使用する変数を初期化しています。

Sub Init
 gw = 598
 gh = 428
 GraphicsWindow.BackgroundColor = "DodgerBlue"
 GraphicsWindow.Width = gw
 GraphicsWindow.Height = gh
 color = "1=Orange;2=Cyan;3=Lime;"
 size = "1=20;2=16;3=12;"
 passed = 0
 cd = "False" ' collision detected
EndSub

(つづく)

| | コメント (0) | トラックバック (0)

2014/07/16

プログラミング講座(155) 手続きとデータの分離

前回のテキストアドベンチャーゲームをあの方式で拡張していくと、プログラムは If 文と Goto 文が延々と続くものになります。そこで、ゲームのシナリオをデータ化し、プログラムの手続きはそのデータを扱う共通のものにしてはどうかと考え、書き直してみました。その改訂版を FCD758-1 として発行しました。

メイン

メイン部はとてもシンプルになりました。

' Text Adventure 0.2
' Program ID FCD758-1
Init()
Game()
' end of program

シナリオデータの初期化

シナリオデータは前回のメイン部をそのままデータにしたようなものになりました。このデータは一種のスクリプト言語になっています。空白で始まる行はテキストとして表示され、":" (コロン)で終わる行は飛び先ラベルとして扱われ、"->" で始まる行は Goto のような制御文として処理され、それ以外の "," (カンマ)を含む行は選択肢としてユーザー(プレイヤー)の入力を促すようになっています。

本来シナリオは21行目で終了ですが、テストのために22行目を追加し、ゲームの先頭に戻るようにしてあります。

Sub Init
 scenario[1] = "stage_0:"
 scenario[2] = " You're at a fork in the road."
 scenario[3] = " Which way do you go?"
 scenario[4] = "LEFT,RIGHT,STAY"
 scenario[5] = "-> stage_1_1,stage_1_2,stage_1_3,stage_0"

 scenario[6] = "stage_1_1:"
 scenario[7] = " Good choice, you find some money. :)"
 scenario[8] = " Have a nice day."
 scenario[9] = "-> end"

 scenario[10] = "stage_1_2:"
 scenario[11] = " You're at a stairs."
 scenario[12] = " Which way do you go?"
 scenario[13] = "UP,BACK"
 scenario[14] = "-> stage_2,stage_0,stage_1_2"

 scenario[15] = "stage_1_3:"
 scenario[16] = " Nothing happend."
 scenario[17] = " "
 scenario[18] = "-> stage_0"

 scenario[19] = "stage_2:"
 scenario[20] = " Hard choice. But, good luck!"

 scenario[21] = "end:"
 scenario[22] = "-> stage_0"
 nScenario = Array.GetItemCount(scenario)
 pScenario = 1
EndSub

シナリオデータの解析と実行

サブルーチン Game では配列 scenario にあるシナリオを解析し実行します。

Sub Game
 While pScenario <= nScenario
  line = scenario[pScenario]
  If Text.StartsWith(line, " ") Then
   TextWindow.WriteLine(Text.GetSubTextToEnd(line, 2))
   pScenario = pScenario + 1
  ElseIf Text.EndsWith(line, ":") Then
   pScenario = pScenario + 1
  ElseIf Text.StartsWith(line, "->") Then
   id = 1
   Jump()
  ElseIf Text.IsSubText(line, ",") Then
   choices = line
   Choose()
   TextWindow.WriteLine("")
   If id = 0 Then
    id = n + 1
   EndIf
   pScenario = pScenario + 1
   line = scenario[pScenario]
   Jump()
  Else
   msg = "Unknown scenario: line " + pScenario
   Error()
  EndIf
 EndWhile
EndSub

"->" 制御文の処理

"->" は単独の場合は Goto 文と同じ働きをしますが、直前に選択肢がある場合は、BASIC の ON GOTO 文のような働きをします。直前の n 番目の選択肢が入力されたときは n 番目のラベルに制御を移します。選択肢以外が入力されたときは、最後のラベルに制御を移します。

Sub Jump
 ' param id - choice
 ' param line
 ' work label - destination
 len = Text.GetLength(line)
 p = 3
 While p <= len And Text.GetSubText(line, p, 1) = " "
  p = p + 1
 EndWhile
 label = ""
 For i = 1 To id
  c = Text.GetIndexOf(Text.GetSubTextToEnd(line, p), ",")
  If c = 0 Then
   c = len - p + 2
  EndIf
  If i = id Then
   label = Text.GetSubText(line, p, c - 1) + ":"
  Else
   p = p + c
   If len < p Then
    msg = "Label shortage: line " + pScenario
    Error()
   EndIf
  EndIf
 EndFor
 For p = 1 To nScenario
  If scenario[p] = label Then
   pScenario = p
   Goto break
  EndIf
 EndFor
 msg = "Label " + label + " not found: line " + pScenario
 Error()
 break:
EndSub

エラー表示

シナリオが間違っているとプログラムが続行できないので、その場合はエラーを表示してプログラムを終了するようにしました。

Sub Error
 TextWindow.ForegroundColor = "Red"
 TextWindow.WriteLine(msg)
 TextWindow.WriteLine("")
 TextWindow.ForegroundColor = "Gray"
 pScenario = nScenario + 1
EndSub

この他に選択肢を入力する Choice というサブルーチンがありますが、これは前回と同じものを使用しています。

今回の変更によりゲームのシナリオ自体は配列データとなり、プログラムをちょっと変更すればファイルから読み込むことも可能になりました。同じプログラムで複数のシナリオを実行するようにすることもできますね。ただ、プログラムで書かなくて済んだのですが、データのほうはある種のプログラムになってしまったので、データ側をテストして正しいものにする必要があります。シナリオデータの誤りを減らすために工夫をする必要もあるかもしれません。

テキストアドベンチャーゲームはまだまだ改善の余地がありそうですが、今回までとして、次回は TextWindow で動きのあるゲームについて紹介しようと思います。

(つづく)

| | コメント (0) | トラックバック (0)