[Javascript] パックマンをHTMLで作ってみるブログ#12 敵とぶつかる処理

2023年7月10日

Javascript

eyecatch パワーエサを食べた後の処理をつけようと思ったんですが、その前にパワーエサを食べていない時に敵キャラに捕まってしまう、いわゆるDead処理を作っておきたいと思います。 この処理を先に作っておいて、自キャラと敵キャラが重なった時に、パワーエサを食べて敵キャラがWeekモード(青色)になっているかどうかの判定をして、自キャラのダメージか、敵キャラのダメージかを処理する事になります。

今回の目的

- 敵キャラに捕まるモード - 自キャラの消滅アニメーション

対象ファイル一覧

  1. css/ghost.css
  2. css/pacman.css
  3. js/ghost.js
  4. s/main.js
  5. js/pacman.js

ソースコード

[全部更新] css/ghost.css

ステージのroot部分で、data-powerという属性をセットしていた仕様を、敵キャラ1体ずつの処理に切り替えました。 これにより、敵キャラを個別にWeakモードから、通常モードに自由に切り替えられるようになりました。 .ghost{ position:absolute; z-index:9; display:inline-block; --size-width : calc(var(--size-chara) * 1.0); --size-eye : calc(var(--size-width) * 0.3); --size-under : calc(var(--size-width) * 0.2); --color-body : red; width:var(--size-chara); height:var(--size-chara); transform:translate(calc(var(--block) / -2) , calc(var(--block) / -2)); } .ghost[data-color='1']{--color-body : red;} .ghost[data-color='2']{--color-body : orange;} .ghost[data-color='3']{--color-body : lightblue;} .ghost[data-color='4']{--color-body : pink;} .ghost[data-status='weak']{--color-body : blue;} .ghost > *{ margin : 0 auto; width : var(--size-width); } .ghost > .head{ height : calc(var(--size-width) / 2); background-color : var(--color-body); border-radius : var(--size-chara) var(--size-chara) 0 0; position:relative; } .ghost > .head .eyes{ position:absolute; width:100%; bottom:0; z-index:10; } .ghost > .head .eyes *{ position:absolute; bottom:0%; width : var(--size-eye); height : var(--size-eye); background-color:white; border-radius:50%; } .ghost > .head .eyes .eye-left{ left:13%; } .ghost > .head .eyes .eye-right{ right:13%; } .ghost > .head .eyes *::before{ content:''; display:block; background-color:black; width : 50%; height : 50%; margin : 25%; border-radius:50%; transition-property : margin; transition-duration : 0.3s; } .ghost > .head[data-direction='right'] .eyes{ left:12%; } .ghost > .head[data-direction='left'] .eyes{ left:-12%; } .ghost > .head[data-direction='up'] .eyes{ bottom:20%; } .ghost > .head[data-direction='down'] .eyes{ bottom:-20%; } .ghost[data-status='weak'] > .head .eyes{ left:0; bottom:20%; } .ghost > .head[data-direction='right'] .eyes > *::before{ margin-left : 50%; margin-right : 0; } .ghost > .head[data-direction='left'] .eyes > *::before{ margin-left : 0; margin-right : 50%; } .ghost > .head[data-direction='up'] .eyes > *::before{ margin-top : 0; margin-bottom : 50%; } .ghost > .head[data-direction='down'] .eyes > *::before{ margin-top : 50%; margin-bottom : 0; } .ghost[data-status='weak'] > .head .eyes *::before{ display:none; } .ghost[data-status='weak'] > .head .eyes .eye-left, .ghost[data-status='weak'] > .head .eyes .eye-right{ width : calc(var(--size-eye) / 2); height : calc(var(--size-eye) / 2); background-color:white; border-radius:50%; } .ghost[data-status='weak'] > .head .eyes .eye-left{ left:25%; } .ghost[data-status='weak'] > .head .eyes .eye-right{ right:25%; } .ghost > .mouse{ height : 30%; background-color : var(--color-body); position:relative; } .frame-area .ghost > .mouse > svg{ display:none; } .ghost[data-status='weak'] > .mouse > svg{ position:absolute; top:-60%; display:block; margin-top:10px; } .ghost > .under{ height : var(--size-under); } .ghost > .under path{ fill:var(--color-body); }

[部分追加] css/pacman.css

自キャラが敵キャラに捕まってしまった時の消滅アニメーションを追加しました。 clip-pathを使ってcssだけでアニメーションを表現しています。 これかなり難しかった〜〜〜! .pacman{ position:absolute; z-index:10; display:inline-block; width:var(--size-chara); height:var(--size-chara); } .pacman[data-status='crashed']::before, .pacman[data-status='crashed']::after{ animation-play-state: paused; } .pacman::before, .pacman::after{ content:''; display:block; width:100%; height:50%; background-color:yellow; animation-duration:var(--anim-speed); animation-timing-function: ease-in-out; animation-iteration-count: infinite; } .pacman::before{ transform-origin:center bottom; border-radius:var(--size-chara) var(--size-chara) 0 0; transform:rotate(45deg); } .pacman::after{ transform-origin:center top; border-radius:0 0 var(--size-chara) var(--size-chara); transform:rotate(-45deg); } .pacman[data-anim="true"]::before{ animation-name : pacman_before; } .pacman[data-anim="true"]::after{ animation-name : pacman_after; } @keyframes pacman_before{ 0%{ transform:rotate(45deg); } 50%{ transform:rotate(0deg); } 100%{ transform:rotate(45deg); } } @keyframes pacman_after{ 0%{ transform:rotate(-45deg); } 50%{ transform:rotate(0deg); } 100%{ transform:rotate(-45deg); } } .pacman, .pacman[data-direction="left"]{ transform:translate(calc(var(--block) / -2) , calc(var(--block) / -2)); } .pacman[data-direction="right"]{ transform:translate(calc(var(--block) / -2) , calc(var(--block) / -2)) scaleX(-1); } .pacman[data-direction="up"]{ transform:translate(calc(var(--block) / -2) , calc(var(--block) / -2)) rotate(90deg); } .pacman[data-direction="down"]{ transform:translate(calc(var(--block) / -2) , calc(var(--block) / -2)) rotate(-90deg); } .pacman[data-status="dead"]::before, .pacman[data-status="dead"]::after{ transform:rotate(0deg); animation-delay : 0.0s; animation-direction : normal; animation-duration : 2.0s; animation-fill-mode: forwards; animation-iteration-count: 1; animation-timing-function: linear; } .pacman[data-status="dead"]::before{ animation-name : pacman-dead-before; } .pacman[data-status="dead"]::after{ animation-name : pacman-dead-after; } @keyframes pacman-dead-before{ 0%{ clip-path: polygon( 50% 100%, 0% 100%, 0% 0%, 100% 0%, 100% 100% ); } 25%{ clip-path: polygon( 50% 100%, 0% 0%, 0% 0%, 100% 0%, 100% 100% ); } 75%{ clip-path: polygon( 50% 100%, 100% 0%, 100% 0%, 100% 0%, 100% 100% ); } 100%{ clip-path: polygon( 50% 100%, 100% 100%, 100% 100%, 100% 100%, 100% 100% ); } } @keyframes pacman-dead-after{ 0%{ clip-path: polygon( 50% 0%, 0% 0%, 0% 100%, 100% 100%, 100% 0% ); } 25%{ clip-path: polygon( 50% 0%, 0% 100%, 0% 100%, 100% 100%, 100% 0% ); } 75%{ clip-path: polygon( 50% 0%, 100% 100%, 100% 100%, 100% 100%, 100% 0% ); } 100%{ clip-path: polygon( 50% 0%, 100% 0%, 100% 0%, 100% 0%, 100% 0% ); } }

[全部更新] js/ghost.js

基本的に当たり判定(コリジョン)は敵キャラの座標を基準に行うようにしたので、このモジュール内で判定を行っています。 自キャラと敵キャラ1体が衝突した時に、全体の動きを止めるというのが、なかなかしんどかったのですが、jsのanimate機能と、cssのモーションストップの2種類を非同期で行うように書いてクリアできました。 import { Main } from './main.js' import { Frame } from './frame.js' import { Pacman } from './pacman.js' export class Ghost{ constructor(){ this.put_element() } static datas = [ { id : 1, direction : null, coodinate : { x : 12, y : 11 }, }, { id : 2, direction : null, coodinate : { x : 15, y : 11 }, }, { id : 3, direction : null, coodinate : { x : 12, y : 14 }, }, { id : 4, direction : null, coodinate : { x : 15, y : 14 }, }, ] static get elm_ghosts(){ return document.querySelectorAll('.ghost') } static get_data(elm){ const color_num = elm.getAttribute('data-color') return Ghost.datas.find(e => e.id === Number(color_num)) } static get_id(elm){ const data = Ghost.get_data(elm) return data.id } static get_coodinate(elm){ const data = Ghost.get_data(elm) return data.coodinate } put_element(){ for(const data of Ghost.datas){ const elm = document.createElement('div') elm.className = 'ghost' elm.setAttribute('data-color' , data.id) Frame.root.appendChild(elm) } this.load_asset() } load_asset(){ const xhr = new XMLHttpRequest() xhr.open('get' , `assets/ghost.html` , true) xhr.setRequestHeader('Content-Type', 'text/html'); xhr.onreadystatechange = ((e) => { if(xhr.readyState !== XMLHttpRequest.DONE){return} if(xhr.status === 404){return} if (xhr.status === 200) { this.asset = e.target.response this.set_ghost() setTimeout(this.set_move.bind(this) , 1000) } }).bind(this) xhr.send() } set_ghost(){ const elm_ghosts = Ghost.elm_ghosts for(const elm_ghost of elm_ghosts){ const coodinate = Ghost.get_coodinate(elm_ghost) Frame.put(elm_ghost, coodinate) elm_ghost.innerHTML = this.asset // break;//デバッグ用(敵1体のみ表示の場合) } } set_move(){ const elm_ghosts = Ghost.elm_ghosts for(const elm_ghost of elm_ghosts){ this.move(elm_ghost) } } move(elm_ghost){ if(!elm_ghost){return} const data = Ghost.get_data(elm_ghost) const coodinate = Ghost.get_coodinate(elm_ghost) const directions = Ghost.get_enable_directions(coodinate , data.direction) || Ghost.get_enable_directions(coodinate) const direction = Ghost.get_direction(directions) Ghost.set_direction(elm_ghost , direction) const next_pos = Frame.next_pos(direction , coodinate) this.moving(elm_ghost , next_pos) } moving(elm_ghost , next_pos){ if(!elm_ghost || !next_pos){return} const data = Ghost.get_data(elm_ghost) if(!data){return} if(Main.is_dead){ return } //warp if(Frame.is_warp(next_pos)){ data.coodinate = Frame.get_another_warp_pos(next_pos) next_pos = Frame.next_pos(data.direction , data.coodinate) } if(!next_pos){return} const before_pos = { x : data.coodinate.x * Frame.block_size, y : data.coodinate.y * Frame.block_size, } const after_pos = { x : next_pos.x * Frame.block_size, y : next_pos.y * Frame.block_size, } if(Pacman.is_collision(next_pos)){ Ghost.crashed() Pacman.crashed(elm_ghost) } data.next_pos = next_pos elm_ghost.animate( [ { left : `${before_pos.x}px`, top : `${before_pos.y}px`, }, { left : `${after_pos.x}px`, top : `${after_pos.y}px`, } ], { duration: Frame.is_weak ? Main.ghost_weak_speed : Main.ghost_normal_speed } ) Promise.all(elm_ghost.getAnimations().map(e => e.finished)) .then(this.moved.bind(this , elm_ghost)) } moved(elm_ghost){ const data = Ghost.get_data(elm_ghost) elm_ghost.style.setProperty('left' , `${data.next_pos.x * Frame.block_size}px` , '') elm_ghost.style.setProperty('top' , `${data.next_pos.y * Frame.block_size}px` , '') data.coodinate = data.next_pos if(Main.is_dead){return} if(Pacman.is_collision(data.coodinate)){ Ghost.crashed() Pacman.crashed(elm_ghost) } else if(elm_ghost.hasAttribute('data-reverse')){ elm_ghost.removeAttribute('data-reverse') this.reverse_move(elm_ghost , data) } else{ this.next_move(elm_ghost , data) } } reverse_move(elm_ghost , data){ const direction = Ghost.reverse_direction(data.direction) Ghost.set_direction(elm_ghost , direction) data.direction = direction const next_pos = Frame.next_pos(data.direction , data.coodinate) this.moving(elm_ghost , next_pos) } next_move(elm_ghost , data){ const directions = Ghost.get_enable_directions(data.coodinate , data.direction) const direction = Ghost.get_direction(directions) Ghost.set_direction(elm_ghost , direction) const next_pos = Frame.next_pos(data.direction , data.coodinate) if(Frame.is_collision(next_pos)){ this.move(elm_ghost) } else{ this.moving(elm_ghost , next_pos) } } static get_direction(directions){ if(!directions || !directions.length){return null} const direction_num = Math.floor(Math.random() * directions.length) return directions[direction_num] || null } // 移動可能な方向の一覧を取得する static get_enable_directions(pos , direction){ const directions = [] // Through(通り抜け) const frame_data = Frame.frame_datas[Frame.get_pos2num(pos)] if(frame_data.match(/^T/i)){ return [direction] } // 右 : right if(pos.x + 1 < Frame.map[pos.y].length && !Frame.is_collision({x: pos.x + 1, y: pos.y}) && Frame.is_through({x: pos.x + 1, y: pos.y} , 'right') && direction !== 'left'){ directions.push('right') } // 左 : left if(pos.x - 1 >= 0 && !Frame.is_collision({x: pos.x - 1, y: pos.y}) && Frame.is_through({x: pos.x - 1, y: pos.y} , 'left') && direction !== 'right'){ directions.push('left') } // 上 : up if(pos.y - 1 >= 0 && !Frame.is_collision({x: pos.x, y: pos.y - 1}) && Frame.is_through({x: pos.x, y: pos.y - 1} , 'up') && direction !== 'down' ){ directions.push('up') } // 下 : down if(pos.y + 1 < Frame.map.length && !Frame.is_collision({x: pos.x, y: pos.y + 1}) && Frame.is_through({x: pos.x, y: pos.y + 1} , 'down') && direction !== 'up'){ directions.push('down') } if(directions.length){ return directions } else{ return [Ghost.reverse_direction(direction)] } } static reverse_direction(direction){ switch(direction){ case 'right' : return 'left' case 'left' : return 'right' case 'up' : return 'down' case 'down' : return 'up' } } static set_direction(elm_ghost , direction){ const data = Ghost.get_data(elm_ghost) data.direction = direction const head = elm_ghost.querySelector('.head') if(!head){return} head.setAttribute('data-direction' , direction) } static power_on(){ for(const elm of Ghost.elm_ghosts){ elm.setAttribute('data-reverse' , '1') elm.setAttribute('data-status' , 'weak') } } static power_off(){ for(const elm of Ghost.elm_ghosts){ if(elm.getAttribute('data-status') === 'weak'){continue} elm.removeAttribute('data-status') } } static crashed(){ Main.is_crash = true for(const elm of Ghost.elm_ghosts){ Main.is_dead = true const svg = elm.querySelector('.under svg') svg.pauseAnimations() const anim = elm.getAnimations() if(!anim || !anim.length){continue} anim[0].pause() } } static hidden_all(){ for(const elm of Ghost.elm_ghosts){ elm.style.setProperty('display' , 'none' , ''); } } }

[一部更新] js/main.js

グローバル変数に、クラッシュと自キャラの消滅のそれぞれのフラグを追加しました。 import { Ghost } from './ghost.js' import { Frame } from './frame.js' import { Control } from './control.js' import { Pacman } from './pacman.js' export const Main = { anim_speed : 200, ghost_normal_speed : 200, ghost_weak_speed : 400, is_crash : false, is_dead : false, } function init(){ new Frame().then(()=>{ new Ghost() new Pacman() new Control() }) } switch(document.readyState){ case 'complete': case 'interactive': init() break default: window.addEventListener('DOMContentLoaded' , (()=>init())) }

[全部更新] js/pacman.js

敵キャラとクラッシュした時に、自キャラの動きを全て止める処理と、その後の消滅アニメーションの処理を追加しました。 import { Main } from './main.js' import { Frame } from './frame.js' import { Control } from './control.js' import { Feed } from './feed.js' import { Ghost } from './ghost.js' export class Pacman{ // 初期表示座標処理 constructor(){ Pacman.coodinates = this.start_coodinates Frame.put(this.elm, Pacman.coodinates) this.elm.style.setProperty('--anim-speed' , `${Main.anim_speed}ms` , '') } get start_coodinates(){ return { x : 14, y : 23, } } get elm(){ return Pacman.elm } static get elm(){ return document.querySelector('.pacman') } static move(direction){ if(Pacman.direction){ return } Pacman.direction = direction this.elm.setAttribute('data-anim' , "true") this.moving() } static moving(){ if(Main.is_dead){return} Pacman.next_pos = Frame.next_pos(Pacman.direction , Pacman.coodinates) //warp if(Frame.is_warp(Pacman.next_pos)){ Pacman.coodinates = Frame.get_another_warp_pos(Pacman.next_pos) Pacman.next_pos = Pacman.next_pos(Pacman.direction , Pacman.coodinates) } if(Frame.is_collision(Pacman.next_pos)){ this.elm.setAttribute('data-anim' , "") delete Pacman.direction return } this.elm.setAttribute('data-direction' , Pacman.direction) this.elm.animate( [ { left : `${Pacman.coodinates.x * Frame.block_size}px`, top : `${Pacman.coodinates.y * Frame.block_size}px`, }, { left : `${Pacman.next_pos.x * Frame.block_size}px`, top : `${Pacman.next_pos.y * Frame.block_size}px`, } ], { duration: Main.anim_speed } ) Promise.all(this.elm.getAnimations().map(e => e.finished)).then(()=>{ Pacman.moved() }) } static moved(){ Pacman.coodinates = Pacman.next_pos Frame.put(this.elm, Pacman.coodinates) Feed.move_map() if(Control.direction && Control.direction !== Pacman.direction){ const temp_pos = Frame.next_pos(Control.direction , Pacman.coodinates) if(!Frame.is_collision(temp_pos)){ Pacman.direction = Control.direction } } Pacman.moving() } static is_collision(pos){ if(!pos || !Pacman.coodinates || !Pacman.next_pos){return} if(pos.x === Pacman.coodinates.x && pos.y === Pacman.coodinates.y){ return true } else if(pos.x === Pacman.next_pos.x && pos.y === Pacman.next_pos.y){ return true } else{ return false } } static crashed(){ setTimeout(Pacman.dead , 1000) const anim = Pacman.elm.getAnimations() if(anim && anim.length){ anim[0].pause() } } static dead(){ Ghost.hidden_all() Pacman.elm.setAttribute('data-direction' , 'up') Pacman.elm.setAttribute('data-status' , 'dead') } }

画面キャプチャ

解説

今回行ったプログラミングの最大のポイントは、自キャラの消滅アニメーションです。 半円を2つ重ねてパクパクさせるキャラクターで構成されているんですが、敵キャラとぶつかった時に、パクパクモーションの逆の動きで、消えるアニメーションを実装する必要がありました。 さて、どうしたものか・・・ 悩んだ挙げ句、次のパターンを思いつきました。
1. キャラクタをsvgに切り替えて、svgアニメーションで実装する。 2. このアニメーションの時だけ、gifアニメで表示する。 3. cssのclip-path機能で実装する。
部分的なsvgは、敵キャラでも使っているので、これまでcssアニメだけで行っていた自キャラの処理を、切り替えることも可能だったのですが、gifアニメパターンは、仕様的にあり得ないという事で却下しました。 やっぱり処理として最も望ましいのは、3番のcssだけでの処理を実装するパターンです。 色々実験した結果、clip-path:polygon(...)として半円を消していくアニメーションを付けることで、想定通りの見た目を表現できました。 単体で見ると次のようになっています。

clip-pathデモ

知財

パックマンは、バンダイナムコ社の登録商標です。 PAC-MAN™ & ©1980 BANDAI NAMCO Entertainment Inc.

このブログを検索

ごあいさつ

このWebサイトは、独自思考で我が道を行くユゲタの少し尖った思考のTechブログです。 毎日興味がどんどん切り替わるので、テーマはマルチになっています。 もしかしたらアイデアに困っている人の助けになるかもしれません。