[Javascript] パックマンをHTMLで作ってみるブログ#11 パワーエサモード

2023年7月9日

Javascript

eyecatch いよいよ、このゲーム開発も大詰めですね。 今回は、パワーエサを食べた時に敵キャラが青くなる処理を追加します。

今回の目的

- 敵キャラのweekモード(敵キャラの進行方向を逆にする。) - 一定時間でもとに戻る処理

対象ファイル一覧

  1. index.html
  2. assets/ghost.html
  3. css/ghost.css
  4. js/feed.js
  5. js/frame.js
  6. js/ghost.js
  7. js/main.js

ソースコード

ステージに、data-powerというパワーエサを食べている時のフラグ路立てる属性を追加しました。

[部分更新] index.html

<!DOCTYPE html> <html lang='ja'> <head> <link rel='stylesheet' href='css/style.css' /> <script type='module' src='js/main.js'></script> <style>body{background-color:black}</style> </head> <body> <div class='frame-area' data-power="0"> <div class='pacman'></div> </div> </body> </html>

[全部更新] assets/ghost.html

敵キャラの内部HTMLの構造を大幅に変更しました。 実は、キャラクタ製作時に、青色モードを作っていたんですが、その時に構造を変えていたことを忘れていました・・・OMG <div class='head'> <div class='eyes'> <div class='eye-left'></div> <div class='eye-right'></div> </div> </div> <div class='mouse'> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 10"> <path fill='none' stroke='white' stroke-width='4' d="M 15,7 Q 15,7 17.5,3 T 25,5 35,5 45,5 55,5 65,5 75,5 85,7"> <animate attributeName='d' dur='800ms' repeatCount='indefinite' calcMode="linear" values='M 15,7 Q 15,7 17.5,3 T 25,5 35,5 45,5 55,5 65,5 75,5 85,7; M 15,3 Q 15,3 17.5,7 T 25,5 35,5 45,5 55,5 65,5 75,5 85,3; M 15,7 Q 15,7 17.5,3 T 25,5 35,5 45,5 55,5 65,5 75,5 85,7;' /> </path> </svg> </div> <div class='under'> <svg class='mouse' xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 30"> <path fill='red' d="M 0,0 Q 0,15 5,20 T 20,15 40,15 60,15 80,15 100,15 L 100,0" fill='red'> <animate attributeName='d' dur='500ms' repeatCount='indefinite' calcMode="linear" values='M 0,0 Q 0,15 5,20 T 15,15 35,15 55,15 75,15 100,15 L 100,0; M 0,0 Q 0,15 5,20 T 25,15 45,15 65,15 85,15 100,15 L 100,0; M 0,0 Q 0,15 5,20 T 15,15 35,15 55,15 75,15 100,15 L 100,0;' /> </path> </svg> </div>

[全部更新] css/ghost.css

敵キャラHTML変更に伴って、cssも大幅に変更したので、該当するcssも総入れ替えしました。 .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;} .frame-area[data-power='1'] .ghost{--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%; } .frame-area[data-power='1'] .ghost > .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; } .frame-area[data-power='1'] .ghost > .head .eyes *::before{ display:none; } .frame-area[data-power='1'] .ghost > .head .eyes .eye-left, .frame-area[data-power='1'] .ghost > .head .eyes .eye-right{ width : calc(var(--size-eye) / 2); height : calc(var(--size-eye) / 2); background-color:white; border-radius:50%; } .frame-area[data-power='1'] .ghost > .head .eyes .eye-left{ left:25%; } .frame-area[data-power='1'] .ghost > .head .eyes .eye-right{ right:25%; } .ghost > .mouse{ height : 30%; background-color : var(--color-body); position:relative; } .frame-area .ghost > .mouse > svg{ display:none; } .frame-area[data-power='1'] .ghost > .mouse > svg{ position:absolute; top:-60%; display:block; margin-top:10px; } .ghost > .under{ height : var(--size-under); } .ghost > .under path{ fill:var(--color-body); }

[全部更新] js/feed.js

エサ処理のモジュールでは、敵キャラモジュールと連携して、setTimeoutで10秒計測して、パワーONとパワーOFFを処理しています。 import { Frame } from './frame.js' import { Pacman } from './pacman.js' import { Ghost } from './ghost.js' export class Feed{ static move_map(){ const num = Frame.get_pos2num(Pacman.coodinates) const item = Frame.frame_datas[num] switch(item){ case 'P1': this.eat_normal_dot(num) break case 'P2': Feed.power_on() setTimeout(Feed.power_off , 10000) this.eat_big_dot(num) break } } static eat_normal_dot(num){ Frame.frame_datas[num] = 'S5' const elm = Frame.get_elm(num) if(!elm){return} elm.setAttribute('class','S5') } static eat_big_dot(num){ Frame.frame_datas[num] = 'S5' const elm = Frame.get_elm(num) if(!elm){return} elm.setAttribute('class','S5') } static power_on(){ Frame.root.setAttribute('data-power' , '1') Ghost.power_on() } static power_off(){ Frame.root.setAttribute('data-power' , '0') Ghost.power_off() } }

[全部更新] js/frame.js

index.htmlで追加したdata-powerフラグを確認して、is_weekフラグを返すようにしました。 ※この処理は一時的で後ほど別の仕様に変更する予定です。 export class Frame{ constructor(){ return new Promise(resolve => { this.resolve = resolve Frame.stage_datas = this.stage_datas = [] this.load_asset() }) } static get root(){ return document.querySelector(`.frame-area`) } get block_size(){ return Frame.block_size } static get block_size(){ const s5 = document.querySelector('.S5') return s5.offsetWidth } get cols_count(){ return ~~(Frame.root.offsetWidth / Frame.block_size) } static get_elm(num){ return Frame.root.querySelector(`[data-num='${num}']`) } static get is_weak(){ if(Frame.root.getAttribute('data-power') === '1'){ return true } else{ return false } } load_asset(){ const xhr = new XMLHttpRequest() xhr.open('get' , `assets/frame.json` , 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) { Frame.frame_datas =this.frame_datas = JSON.parse(e.target.response) this.view() this.set_collision() this.finish() } }).bind(this) xhr.send() } view(){ for(let i=0; i<this.frame_datas.length; i++){ const p = document.createElement('p') p.className = this.frame_datas[i] Frame.root.appendChild(p) p.setAttribute('data-num' , i) } } finish(){ if(this.resolve){ this.resolve(this) } } static put(elm , coodinates){ if(!elm){return} const pos = this.calc_coodinates2position(coodinates) this.pos(elm , pos) elm.setAttribute('data-x' , coodinates.x) elm.setAttribute('data-y' , coodinates.y) } static calc_coodinates2position(coodinates){ const size = Frame.block_size return { x : (coodinates.x) * size, y : (coodinates.y) * size, } } static pos(elm , pos){ elm.style.setProperty('left' , `${pos.x}px` , '') elm.style.setProperty('top' , `${pos.y}px` , '') } // 壁座標に1を設置 set_collision(type){ const cols_count = this.cols_count const maps = [] let row_count = 0 for(const frame_data of this.frame_datas){ maps[row_count] = maps[row_count] || [] // 移動できる if(frame_data.match(/^P/i) || frame_data.toUpperCase() === 'S5' || frame_data.match(/^W/i) || frame_data.match(/^T/i)){ maps[row_count].push(0) } // 壁 else{ maps[row_count].push(1) } if(maps[row_count].length === cols_count){ row_count++ } } Frame.map = this.map = maps } static is_collision(map){ if(!map || !Frame.map || !Frame.map[map.y]){return} return Frame.map[map.y][map.x] } static is_through(map , direction){ // if(!map || !Frame.map || !Frame.map[map.y] || !Frame.map[map.y][map.x]){return} const through_item = Frame.frame_datas[Frame.get_pos2num(map)] if(through_item === 'TU' && direction !== 'up' || through_item === 'TD' && direction !== 'down' || through_item === 'TL' && direction !== 'left' || through_item === 'TR' && direction !== 'right'){ return false } else{ return true } } static get_pos2num(pos){ return pos.y * Frame.map[0].length + pos.x } static get_num2pos(num){ return { x : num % Frame.map[0].length, y : ~~(num / Frame.map[0].length), } } static is_warp(map){ const num = Frame.get_pos2num(map) // console.log(num,Frame.frame_datas[num]) return Frame.frame_datas[num] === 'W1' ? true : false } static get_another_warp_pos(map){ const warp_index_arr = Frame.filterIndex(Frame.frame_datas , 'W1') const current_index = Frame.get_pos2num(map) const another_num = warp_index_arr.find(e => e !== current_index) return Frame.get_num2pos(another_num) } static filterIndex(datas,target){ const res_arr = [] for(let i=0; i<datas.length; i++){ if(datas[i] === target){ res_arr.push(i) } } return res_arr } static next_pos(direction , pos){ const temp_pos = { x : pos.x, y : pos.y, } switch(direction){ case 'left': temp_pos.x -= 1 break case 'right': temp_pos.x += 1 break case 'up': temp_pos.y -= 1 break case 'down': temp_pos.y += 1 break default: return } return temp_pos } }

[全部更新] js/ghost.js

今回メインで変更したモジュールです。 import { Main } from './main.js' import { Frame } from './frame.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} //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, } 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 , next_pos)) } moved(elm_ghost , pos){ elm_ghost.style.setProperty('left' , `${pos.x * Frame.block_size}px` , '') elm_ghost.style.setProperty('top' , `${pos.y * Frame.block_size}px` , '') const data = Ghost.get_data(elm_ghost) data.coodinate = pos 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') } } static power_off(){ } }

js/main.js

mainモジュールは、全体スピードだけしか登録されていなかった、グローバル変数に、パワーエサモード時に敵キャラのスピードを変更できるように、値を追加しました。 ついでに、自キャラと敵キャラのスピードも、デバッグなどの時に変更できるように、全部で3つの値に分けました。 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, } 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())) }

画面キャプチャ

解説

今回、敵キャラがパワーエサを食べると青くなりますが、まだその敵を食べるモードは付けていません。 難しかった点としては、パワーモードになると、敵キャラって方向を逆転させるんですね。(動画を見ていて気が付きました。) animate機能で動いている途中の敵キャラと、タイミング良くanimate機能外の敵キャラの2パターンが存在していたので、2種類の反転モードを記述する必要があったのに気がつくのに時間を要してしまいました。 でも、結果的にいい感じにパワーエサモードが出来上がりましたよ。 1回だけ、パワーエサを食べた時に、敵が1体消えて3体になるという現象を見つけてしまいましたが、ランダムで動く敵のため、再確認がその後できないため、もしかしたらバグが含まれているかもしれません。 JSエラーが出ていたわけではないので、そのままにしておいて、以後見つけたタイミングで再調査したいと思います。

知財

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

このブログを検索

ごあいさつ

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