OpenSiv3Dから導入されたEmojiを使用したサンプルコードを紹介いたします。
https://twitter.com/hat_a2c/status/1330147985745993729
https://twitter.com/hat_a2c/status/1332840479440867330
#前振り
2020年時点のアラサーはガラケー世代かと思います。かく言う私もその世代で当時ガラケーで絵文字を使用したり、キャリアごとで絵文字が微妙に違っていたり、幼いころは父親のPHSからPCメール宛てに絵文字を送ってみたら文字化けすることを知ったりという具合でした。今ではEmojiが世界標準になりPCでも使えるということで、絵文字に限っては“ガラパゴス”と呼ばれていたことも過去の話となりました。
https://siv3d.github.io/ja-jp/reference/emojis/
ガラケー世代は同時に初代スマブラ世代でもあるかと思います。私はゲーム下手勢だったのでマスターハンドに非常に苦戦してました。単純な手のグラフィックだけれども、あの独特の動きと世界観で子供心にワクワクと恐怖をいい具合に植え付けられていたように思います。シンプルだけれどもアイデアによってはユーザーにキャラクタとしての認識を与えることができるという点は改めて勉強になります。
参考:https://www.nintendo.co.jp/n01/n64/software/nus_p_nalj/smash/flash/0205/index.html最近のスマブラキャラではMr.ゲーム&ウォッチもシンプルなキャラクタデザインです。こちらは敢えてモーションの連続性はないけれど、それが同一キャラであるという認識を十分に与えることができているように思います。こちらも統一された世界観やコンテキストがあればシンプルなデザインやモーションでもキャラクタは作れるというのが非常に参考になります。
参考:https://smashwiki.info/Mr.ゲーム&ウォッチ_(SP)
https://www.nintendo.co.jp/hardware/amiibo/lineup/aabx/
#本題
ゲーム作りにおいてキャラメイクというのは魅力的な部分です。しかし、キャラメイクのハードルが高く肝心のゲームのシステムが完成しない、すでにゲームのシステムのアイデアはあるけれどもキャラクタを表現するのが難しいくて挫折する、というのは往々にしてあることかと思います。
そこで今回考えたのが『Emoji/絵文字』という共通認識をキャラクタにうまいこと利用できないか?ということです。
例えば、こことか
あるいは、こことか
どうでしょう?何か頭の中にキャラクタのアイデアが芽生えてきませんか?あなたの中の創造欲の化身がうずいていませんか?その仮説が実現可能なものなのか試してました、というのが今回のサンプルです。
#サンプルコード1
まずは、こちらのサンプルから。人の絵文字を切り替えることでモーションを与えることができるか?ということを検証してみました。コードは殴り書きなのでご了承ください。
https://twitter.com/hat_a2c/status/1330147985745993729
# include <Siv3D.hpp> // OpenSiv3D v0.4.3
void Main()
{
Scene::SetBackground(ColorF(0.8, 0.9, 1.0));
//使用絵文字
const Texture textInit(Emoji(U"🚶♂"));
const Texture textMove(Emoji(U"🏃♂"));
const Texture textJump(Emoji(U"🤸♂"));
const Texture textAttack(Emoji(U"🤺"));
const Texture textDrop(Emoji(U"🤾♂"));
const Texture textHit(Emoji(U"💥"));
const Texture textBom(Emoji(U"💣"));
const Texture textCharge(Emoji(U"💪"));
const Texture textArm(Emoji(U"🤜"));
Texture textOut = textInit;
int countMove = 0;
double countJump = 0;
double countAttack = 0;
double countDrop = 0;
double countPunch = 0;
double countBom = 0;
double countArm = 0;
double countFoot = 0;
bool move = false;
bool jump = false;
bool attack = false;
bool drop = false;
bool punch = false;
bool bom = false;
bool arm = false;
bool foot = false;
Vec2 pos(400, 300);
Vec2 posInit = pos;
Vec2 posBom;
const Font font(60, Typeface::Black);
String text = U"";
//ここから更新内容
while (System::Update())
{
double delta = 200 * Scene::DeltaTime();
move = false;
text = U"";
//入力処理
if (!punch && !arm && !foot)
{
if (KeyLeft.pressed())
{
pos.x -= delta;
move = true;
text += U"⇦";
}
if (KeyRight.pressed())
{
pos.x += delta;
move = true;
text += U"⇨";
}
if (KeyUp.down())
{
jump = true;
}
}
if (!drop && !attack && !punch && !arm && !foot)
{
if (Key1.down())
{
attack = true;
}
if (Key2.down() && !bom)
{
drop = true;
}
if (Key3.down() && !jump)
{
foot = true;
}
if (Key4.down() && !jump)
{
punch = true;
}
}
if (drop)
{
if (Scene::Time() - countDrop > 0.2)
{
drop = false;
bom = true;
posBom = pos;
countBom = Scene::Time();
}
}
else
{
countDrop = Scene::Time();
}
if (attack)
{
if (Scene::Time() - countAttack > 0.2)
{
attack = false;
}
}
else
{
countAttack = Scene::Time();
}
if (punch)
{
if (Scene::Time() - countPunch > 0.5)
{
punch = false;
arm = true;
countArm = Scene::Time();
}
}
else
{
countPunch = Scene::Time();
}
if (foot)
{
if (Scene::Time() - countFoot > 0.5)
{
foot = false;
}
}
else
{
countFoot = Scene::Time();
}
if (jump)
{
if (Scene::Time() - countJump < 0.5)
{
pos.y = posInit.y - 100 * sin(Math::Pi * (Scene::Time() - countJump) / 0.5 );
}
else
{
jump = false;
pos.y = posInit.y;
}
}
else
{
countJump = Scene::Time();
}
countMove = (int)(Scene::Time() * 10) % 2;
//フィールド
Quad(Vec2(20, 400), Vec2(800-20, 400), Vec2(800-200, 300), Vec2(200, 300))
.draw(ColorF(1.0, 1.0), ColorF(1.0, 1.0), ColorF(1.0, 0.0), ColorF(1.0, 0.0));
//文字描画
if (jump)
{
if (text != U"") { text += U" + "; }
text += U"⇧";
}
if (attack)
{
if (text != U"") { text += U" + "; }
text += U"近距離攻撃";
}
if (drop || bom)
{
if (text != U"") { text += U" + "; }
text += U"遠距離攻撃";
}
if (foot)
{
if (text != U"") { text += U" + "; }
text += U"近距離連続攻撃";
}
if (punch || arm)
{
if (text != U"") { text += U" + "; }
text += U"遠距離連続攻撃";
}
font(text).draw(100, 50, Palette::Black);
//絵文字描画
textOut = textInit;
if (punch)
{
textOut = textDrop;
textOut.resized(100 + Periodic::Sine0_1(0.5s) * 5).mirrored().rotated(-30_deg).drawAt(pos);
textCharge.resized(100 + Periodic::Sine0_1(0.1s) * 20).drawAt(pos+Vec2(-55,-55));
}
else if (arm)
{
textOut = textInit;
textOut.resized(100 + Periodic::Sine0_1(0.5s) * 5).mirrored().rotated(20_deg).drawAt(pos);
}
else if (foot)
{
textOut = textJump;
if (Scene::Time() - countFoot < 0.05)
{
textOut.resized(100 + Periodic::Sine0_1(0.5s) * 5).drawAt(pos + Vec2(-5,0));
textHit.resized(30).mirrored().drawAt(pos + Vec2(50, -50));
}
else if (Scene::Time() - countFoot < 0.10)
{
textOut.resized(100 + Periodic::Sine0_1(0.5s) * 5).mirrored().drawAt(pos + Vec2(5,0));
textHit.resized(30).mirrored().drawAt(pos + Vec2(70, 30));
}
else if (Scene::Time() - countFoot < 0.15)
{
textOut.resized(100 + Periodic::Sine0_1(0.5s) * 5).drawAt(pos + Vec2(-5, 0));
textHit.resized(30).mirrored().drawAt(pos + Vec2(40, -10));
}
else if (Scene::Time() - countFoot < 0.20)
{
textOut.resized(100 + Periodic::Sine0_1(0.5s) * 5).mirrored().drawAt(pos + Vec2(5, 0));
textHit.resized(30).mirrored().drawAt(pos + Vec2(70, 20));
}
else if (Scene::Time() - countFoot < 0.25)
{
textOut.resized(100 + Periodic::Sine0_1(0.5s) * 5).drawAt(pos + Vec2(-5, 0));
textHit.resized(30).mirrored().drawAt(pos + Vec2(40, 0));
}
else if (Scene::Time() - countFoot < 0.30)
{
textOut.resized(100 + Periodic::Sine0_1(0.5s) * 5).mirrored().drawAt(pos + Vec2(5, 0));
textHit.resized(30).mirrored().drawAt(pos + Vec2(50, -20));
}
else if (Scene::Time() - countFoot < 0.35)
{
textOut.resized(100 + Periodic::Sine0_1(0.5s) * 5).drawAt(pos + Vec2(-5, 0));
textHit.resized(30).mirrored().drawAt(pos + Vec2(60, -10));
}
else if (Scene::Time() - countFoot < 0.40)
{
textOut.resized(100 + Periodic::Sine0_1(0.5s) * 5).mirrored().drawAt(pos + Vec2(5, 0));
textHit.resized(30).mirrored().drawAt(pos + Vec2(70, -30));
}
else if (Scene::Time() - countFoot < 0.45)
{
textOut.resized(100 + Periodic::Sine0_1(0.5s) * 5).drawAt(pos + Vec2(-5, 0));
textHit.resized(30).mirrored().drawAt(pos + Vec2(40, 20));
}
else if (Scene::Time() - countFoot < 0.45)
{
textOut.resized(100 + Periodic::Sine0_1(0.5s) * 5).mirrored().drawAt(pos + Vec2(5, 0));
textHit.resized(30).mirrored().drawAt(pos + Vec2(50, 0));
}
else
{
foot = false;
}
}
else if (drop)
{
textOut = textDrop;
textOut.resized(100 + Periodic::Sine0_1(0.5s) * 5).mirrored().drawAt(pos);
}
else if (attack)
{
textOut = textAttack;
textOut.resized(100 + Periodic::Sine0_1(0.5s) * 5).mirrored().drawAt(pos);
textHit.resized(50).mirrored().drawAt(pos+Vec2(50,-50));
}
else if (jump)
{
textOut = textJump;
textOut.resized(100 + Periodic::Sine0_1(0.5s) * 5).flipped().rotated(-30_deg).drawAt(pos);
}
else if (move && countMove == 1)
{
textOut = textMove;
textOut.resized(100 + Periodic::Sine0_1(0.5s) * 5).mirrored().drawAt(pos);
}
else
{
textOut.resized(100 + Periodic::Sine0_1(0.5s) * 10).mirrored().rotated(5_deg * Periodic::Sine0_1(0.5s)).drawAt(pos);
}
if (bom)
{
if (Scene::Time() - countBom < 0.5)
{
textBom.resized(50).drawAt(posBom + Vec2(500 * (Scene::Time() - countBom), - 100 * sin(Math::Pi * (Scene::Time() - countBom) / 0.5)));
}
else if (Scene::Time() - countBom < 0.7)
{
textHit.resized(50).mirrored().drawAt(posBom + Vec2(250, 50));
}
else
{
bom = false;
}
}
if (arm)
{
if (Scene::Time() - countArm < 1.0)
{
textArm.resized(60).drawAt(pos + Vec2(70 - 50 * sin(Math::TwoPi * (Scene::Time() - countArm) / 0.1), -15));
textArm.resized(60).drawAt(pos + Vec2(80 - 60 * sin(Math::TwoPi * (Scene::Time() - countArm) / 0.05), -10));
textArm.resized(60).drawAt(pos + Vec2(100 - 80 * sin(Math::TwoPi * (Scene::Time() - countArm) / 0.02), -5));
textArm.resized(60).drawAt(pos + Vec2(60 - 40 * sin(Math::TwoPi * (Scene::Time() - countArm) / 0.03), -20));
if (Scene::Time() - countArm < 0.2)
{
textHit.resized(30).mirrored().drawAt(pos + Vec2(150, -50));
}
else if (Scene::Time() - countArm < 0.4)
{
textHit.resized(30).mirrored().drawAt(pos + Vec2(180, 20));
}
else if (Scene::Time() - countArm < 0.6)
{
textHit.resized(30).mirrored().drawAt(pos + Vec2(100, -10));
}
else if (Scene::Time() - countArm < 0.8)
{
textHit.resized(30).mirrored().drawAt(pos + Vec2(120, 30));
}
else if (Scene::Time() - countArm < 1.0)
{
textHit.resized(30).mirrored().drawAt(pos + Vec2(170, -20));
}
}
else
{
arm = false;
}
}
}
}
#サンプルコード2
次にこちらのサンプル。こちらは奥行き感を与えてみた検証になります。奥行き感についてはパースペクティブ射影変換行列をご参照ください。こちらも殴り書きなので、そこらへんは省いてそれっぽく書いています。
https://twitter.com/hat_a2c/status/1332840479440867330
パースペクティブ射影変換は遠近法に関するお話です。詳しく勉強したい大学生の方はCAD/CG関連の基礎論もしくは概論系の講義(できれば演習付き)を受講してみることをお勧めします。中高生の方は、美術の遠近法、数学の三角関数と行列計算をクエストしてみてください。
参考:https://qiita.com/edo_m18/items/4c7e64f94141997cdf50
https://blog.natade.net/2017/06/04/directx-opengl-perspective/
http://marupeke296.com/DXG_No70_perspective.html
# include <Siv3D.hpp> // OpenSiv3D v0.4.3
void Main()
{
Scene::SetBackground(ColorF(0.8, 0.9, 1.0));
Window::Resize(800,300);
//使用絵文字
const Texture tree(Emoji(U"🌲"));
const Texture car(Emoji(U"🚖"));
int pos = 0;
//他車表示用
bool car1On = false;
double car1Start = 0;
bool car2On = false;
double car2Start = 0;
bool car3On = false;
double car3Start = 0;
double c1y = 0;
double c2y = 0;
double c3y = 0;
//ここから更新内容
while (System::Update())
{
//入力処理
if (KeyRight.pressed())
{
pos -= 10;
}
if (KeyLeft.pressed())
{
pos += 10;
}
//フィールド描画
Quad(Vec2(-400, 300), Vec2(20+pos, 300), Vec2(400, 200), Vec2(0, 200))
.draw(ColorF(Palette::Green, 1.0), ColorF(Palette::Green, 1.0), ColorF(Palette::Green, 0.0), ColorF(Palette::Green, 0.0));
Quad(Vec2(800+400, 300), Vec2(800-20+pos, 300), Vec2(800 - 400, 200), Vec2(800, 200))
.draw(ColorF(Palette::Green, 1.0), ColorF(Palette::Green, 1.0), ColorF(Palette::Green, 0.0), ColorF(Palette::Green, 0.0));
Quad(Vec2(20+pos, 300), Vec2(800-20+pos, 300), Vec2(800-400, 200), Vec2(400, 200))
.draw(ColorF(0.6, 1.0), ColorF(0.6, 1.0), ColorF(0.6, 0.0), ColorF(0.6, 0.0));
for (int i = 0; i < 5; i++) //車線用
{
double xo = 5 * (1 - Periodic::Sawtooth0_1(0.3s)) + 5 * i;
double x = Max(0.0, xo - 3);
double yo = 1 / (xo + 1);
double y = 1 / (x + 1);
Quad(Vec2(400 - (20 - pos) * y, 200 + 100 * y), Vec2(400 + (20 + pos)* y, 200 + 100 * y), Vec2(400 + (20+pos) * yo, 200 + 100 * yo), Vec2(400 - (20-pos) * yo, 200 + 100 * yo))
.draw(ColorF(1.0, y), ColorF(1.0, y), ColorF(1.0, yo), ColorF(1.0, yo));
}
for (int i = 10; i >= 0; i--) //樹用
{
double x = 4 * (1 - Periodic::Sawtooth0_1(0.3s)) + 4 * i;
double y = 1 / (x + 1);
tree.resized(1500 * y).drawAt(400 - 2000 * y + pos * 3.5 * y, 200 - 400 * y, ColorF(1.0, y * 5));
tree.resized(1500 * y).drawAt(400 + 2000 * y + pos * 3.5 * y, 200 - 400 * y, ColorF(1.0, y * 5));
}
//他車描画
if (Random() > 0.3 && !car1On)
{
car1On = true;
car1Start = Scene::Time();
}
if (Random() > 0.5 && !car2On)
{
car2On = true;
car2Start = Scene::Time();
}
if (Random() > 0.8 && !car3On)
{
car3On = true;
car3Start = Scene::Time();
}
if (car1On)
{
double cx = 100 * (1 - (Scene::Time() - car1Start) / 2.0 );
c1y = 1 / (cx + 1);
car.resized(1000 * c1y).drawAt(400 - 1000 * c1y + pos * 5.0 * c1y, 200 + 100 * c1y, ColorF(1.0, c1y * 10));
if (cx <= -1)
{
car1On = false;
}
}
if (car2On)
{
double cx = 100 * (1 - (Scene::Time() - car2Start) / 3.0);
c2y = 1 / (cx + 1);
car.resized(1000 * c2y).drawAt(400 + pos * 5.0 * c2y, 200 + 100 * c2y, ColorF(1.0, c2y * 10));
if (cx <= -1)
{
car2On = false;
}
}
if (car3On)
{
double cx = 100 * (1 - (Scene::Time() - car3Start) / 2.5);
double c3y = 1 / (cx + 1);
car.resized(1000 * c3y).drawAt(400 + 1000 * c3y + pos * 5.0 * c3y, 200 + 100 * c3y, ColorF(1.0, c3y * 10));
if (cx <= -1)
{
car3On = false;
}
}
// 範囲外マスク
Quad(Vec2(0, 300), Vec2(800, 300), Vec2(800, 600), Vec2(0, 600))
.draw(ColorF(0, 1.0), ColorF(0, 1.0), ColorF(0, 1.0), ColorF(0, 1.0));
}
}
#まとめ
『Emoji/絵文字』という共通認識を利用し、モーションを与えることでキャラクタは作れるのか?ということを試してみました。絵文字が拡充していけば更に多様な組み合わせのアイデアが生まれていくと思いますし、見た目についてもテクスチャの色を操作することやパースペクティブ射影変換等を行うことで様々なアイデアを実現できると思います。何かのお役に立てば幸いです。ご参考ください。