[ブラウザゲームの作り方] Number-Place(数独)#9「クリア履歴の表示」

2023年5月28日

ゲーム プログラミング

eyecatch ゲームをクリアした時に、クリアした実績が積み上がっていかないと、ゲームの達成感が得られません。 バッジやシールや、何かしらのカードなどで、種類をコンプリートさせたりすることで、ゲームを続けるモチベーションも爆上がりします。 今回作っているナンプレゲームは、パズルクリアゲームなので、見え方はとにかく、自分がクリアした実績をリストアップできるようにしてみたいと思います。

ソースコード

[全部更新] index.html

※ファイルを下記コードで上書き。 <!DOCTYPE html> <html> <head> <meta http-equiv="Content-Type" content="text/html; charset=UTF-8" /> <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1" /> <title>NumberPlace</title> <link type='text/css' rel='stylesheet' href='style.css' /> <script type='module' src='main.js'></script> </head> <body> <h1>NumberPlace</h1> <div id='NumberPlace'> <div class='buttons'> <button id='btn' data-status='start'></button> </div> </div> <div id='NumberPlaceHistory'></div> </body> </html>

[一部追加] style.css

※最後の行に次の1行を追加 @import 'css/history.css';

[新規追加] css/history.css

#NumberPlaceHistory > *{ cursor:pointer; } #NumberPlaceHistory > *:hover{ color:red; } #NumberPlaceHistory > *[data-status='active']{ font-weight:bold; } #NumberPlaceHistory > * .date{ font-size:0.8em; } #NumberPlaceHistory > * .count{ font-size:0.8em; } #NumberPlaceHistory > * .count::before{ content:'clear'; } #NumberPlaceHistory > * .count::after{ content:'回'; }

[新規追加] js/history.js

import { Main } from '../main.js' import { Common } from './common.js' import { Element } from './element.js' export class History{ constructor(){ if(!this.is_elm){return} this.datas = Main.data.load_clear() if(this.datas && this.datas.length){ this.add_lists() } } get elm(){ return document.getElementById('NumberPlaceHistory') } get items(){ return this.elm.querySelectorAll(':scope > *') } get is_elm(){ return this.elm ? true : false } get max_question_num(){ const sort_datas = this.datas.sort((a,b)=>{ if(a.question_num < b.question_num) return +1 if(a.question_num > b.question_num) return -1 return 0 }) if(!sort_datas || !sort_datas.length){return null} return sort_datas[0].question_num } get new_question_num(){ const max_question_num = this.max_question_num if(max_question_num === null){return 0} const new_question_num = max_question_num + 1 if(Main.question.datas[new_question_num]){ return new_question_num } else{ return null } } add_lists(){ if(!this.is_elm){return} for(let i=0; i<this.datas.length; i++){ this.add_list(i) } } add_list(num){ const data = this.datas[num] if(!this.is_elm || !data){return} // 同じnumデータの検索 const elm_same_num = document.querySelector(`#NumberPlaceHistory [data-question-num='${data.question_num}']`) if(elm_same_num){ this.edit_list(num , data , elm_same_num) } else{ this.append_list(num , data) } } edit_list(num , data , elm){ const elm_count = elm.querySelector('.count') if(elm_count){ const count = Number(elm_count.textContent) || 0 elm_count.textContent = count + 1 } const elm_date = elm.querySelector('.date') if(elm_date){ elm_date.textContent = data.date || '--' } elm.setAttribute('data-num' , num) } append_list(num , data){ const div = document.createElement('div') div.classList.add('item') div.setAttribute('data-num' , num) div.setAttribute('data-question-num' , data.question_num) div.innerHTML = this.set_history_value(data) this.elm.appendChild(div) div.addEventListener('click' , this.click.bind(this)) } set_history_value(data){ return `game: ${data.question_num+1} , <span class='count'>${data.count||0}</span> <span class='date'>(${data.date||'--'})</span>` } click(e){ const item = e.target.closest(`#NumberPlaceHistory .item`) if(!item){return} this.set_status_all(null) this.set_status(item , 'active') const num = Number(item.getAttribute('data-num')) // const data = this.question.get_question_data(num) const data = this.datas[num] || {} console.log(data) Main.question.put_numbers(data.question) Common.put_number(data.input) Main.question_num = data.question_num Element.table.setAttribute('data-status' , 'history-view') } set_status_all(value){ for(const item of this.items){ this.set_status(item , value) } } set_status(item , value){ if(value === null){ if(item.hasAttribute('data-status')){ item.removeAttribute('data-status') } } else{ item.setAttribute('data-status' , value) } } }

[全部更新] main.js

import { View } from './js/view.js' import { Input } from './js/input.js' import { Question } from './js/question.js' import { Data } from './js/data.js' import { Common } from './js/common.js' import { History } from './js/history.js' export const Main = { stage_id : 'NumberPlace', data_path : 'data/questions.json', save_name : 'mynt_number_place_save', clear_name : 'mynt_number_place_clear', interval_px : 10, question_num : 0, } function init(){ Main.data = new Data() Main.history = new History() Main.view = new View() Main.input = new Input() Main.question = new Question({ callback : (e => { Common.continue() }).bind(this) }) } switch(document.readyState){ case 'complete': case 'interactive': init() break default: window.addEventListener('DOMContetLoaded' , init) break }

[一部更新] js.check.js

※次の関数を上書きします。 correct(){ Main.data.save_clear() Main.view.correct() Main.history.add_list(Main.question_num) }

[全部更新] js/data.js

import { Main } from '../main.js' import { Common } from './common.js' export class Data{ save_cache(){ if(!Common.is_started){return} const data = { question_num : Main.question_num, input : Common.get_matrix_numbers(''), question : Common.get_matrix_numbers('lock'), } const json = JSON.stringify(data) window.localStorage.setItem(Main.save_name , json) } load_cache(){ const json = window.localStorage.getItem(Main.save_name) if(!json){return null} return JSON.parse(json) } del_cache(){ window.localStorage.removeItem(Main.save_name) } save_clear(){ if(!Common.is_started){return} const datas = this.load_clear() datas.push(this.make_save_data()) const json = JSON.stringify(datas) window.localStorage.setItem(Main.clear_name , json) } make_save_data(){ const clear_num_data = this.get_clear_data(Main.question_num) return { question_num : Main.question_num, input : Common.get_matrix_numbers(''), question : Common.get_matrix_numbers('lock'), count : clear_num_data && clear_num_data.count ? clear_num_data.count++ : 1, date : this.get_current_datetime(), } } load_clear(){ const json = window.localStorage.getItem(Main.clear_name) return json ? JSON.parse(json) : [] } get_clear_data(num){ const datas = this.load_clear() if(!datas || !datas.length){return} return datas.find(e => e.question_num === num) } del_clear(){ window.localStorage.removeItem(Main.clear_name) } get_current_datetime(){ const dt = new Date() const y = dt.getFullYear() const m = ('00' + String(dt.getMonth() + 1)).slice(-2) const d = ('00' + String(dt.getDate())).slice(-2) const h = ('00' + String(dt.getHours())).slice(-2) const i = ('00' + String(dt.getMinutes())).slice(-2) const s = ('00' + String(dt.getSeconds())).slice(-2) return `${y}-${m}-${d} ${h}:${i}:${s}` } }

[関数追加] js/elements.js

※クラス内に次の関数(getter)を追加 static get elm_new_button(){ return document.querySelector('button#NumberPlace_NewGame') }

[関数追加] js/question.js

※クラス内に次の関数(getter)を追加 get_question_data(num){ if(!this.datas || !this.datas.length){return null} return this.datas[num] }

[全部更新] js/input.js

※ファイル内を全て次のコードに入れ替え import { Main } from '../main.js' import { Element } from './element.js' import { Common } from './common.js' import { Check } from './check.js' export class Input{ constructor(){ this.set_event() } get is_history_view(){ return Element.table.getAttribute('data-status') === 'history-view' ? true : false } get_next_num(){ if(!this.data){return null} // decrement if(this.data.shift_flg){ const next_num = this.data.num - 1 return next_num < 0 ? 9 : next_num } // increment else{ const next_num = this.data.num + 1 return next_num > 9 ? 0 : next_num } } set_event(){ const btn = Element.elm_button if(btn){ btn.addEventListener('click' , this.click_btn.bind(this)) } const new_btn = Element.elm_new_button if(new_btn){ new_btn.addEventListener('click' , this.click_new_btn.bind(this)) } if(typeof window.ontouchstart !== 'undefined'){ Element.table.addEventListener('touchstart' , this.touchstart.bind(this)) Element.table.addEventListener('touchmove' , this.touchmove.bind(this)) Element.table.addEventListener('touchend' , this.mouseup.bind(this)) } else{ Element.table.addEventListener('mousedown' , this.mousedown.bind(this)) Element.table.addEventListener('mousemove' , this.mousemove.bind(this)) Element.table.addEventListener('mouseup' , this.mouseup.bind(this)) } } touchstart(e){ this.mousedown(e.touches[0]) } touchmove(e){ e.preventDefault() this.mousemove(e.touches[0]) } mousedown(e){ if(this.is_history_view){return} const cell = e.target.closest('#NumberPlace td') if(!cell){return} if(cell.getAttribute('data-status') === 'lock'){return} this.data = { cell : cell, num : Number(cell.textContent || 0), pos : { x : e.pageX, y : e.pageY, }, shift_flg : e.shiftKey, } } mousemove(e){ // number-input if(this.data){ const size = Math.abs(e.pageX - this.data.pos.x) if(size < Main.interval_px){return} const num = this.pos2num(size) this.data.cell.textContent = num || '' this.data.num = num this.data.move_flg = true } else{ // same-number this.check_same_number(e) } } mouseup(e){ if(!this.data){return} if(!this.data.move_flg){ this.data.num = this.get_next_num() } this.data.cell.textContent = this.data.num || '' // same-number this.set_same_number(this.data.num) delete this.data Main.data.save_cache() } // 移動距離を0~9の数値に変換する pos2num(pos){ const num = ~~(pos / Main.interval_px) return num > 9 ? num % 10 : num } click_btn(){ const status = Element.elm_button.getAttribute('data-status') switch(status){ case 'check': Main.check = new Check() break case 'next': Main.question_num++ Common.start() Main.data.save_cache() break; case 'start': default: Common.start() Main.data.save_cache() break } } check_same_number(e){ const current_cell = e.target.closest('#NumberPlace td') if(current_cell && this.current_cell !== current_cell){ this.current_cell = current_cell const current_num = Number(current_cell.textContent || 0) this.set_same_number(current_num) } else if(!current_cell && this.current_cell){ this.set_same_number() delete this.current_cell } } set_same_number(current_num){ const cell_all = document.querySelectorAll('#NumberPlace td') for(const cell of cell_all){ const num = Number(cell.textContent || 0) if(!current_num || current_num !== num){ if(cell.hasAttribute('data-same-number')){ cell.removeAttribute('data-same-number') } } else if(current_num === num){ cell.setAttribute('data-same-number' , 1) } } } click_new_btn(){ const new_question_num = Main.history.new_question_num if(new_question_num === null || new_question_num === undefined){return} Main.question_num = new_question_num Main.question.put_numbers(Main.question.datas[new_question_num].data) if(Element.table.hasAttribute('data-status')){ Element.table.removeAttribute('data-status') } } }

[一部更新] js/view.js

※error()関数とcorrect()関数を下記に入れ替えて、set_data_error()関数を追加。 error(error_datas){ this.set_data_error(error_datas) alert('違うよ') } correct(){ this.set_data_error() Main.input.set_same_number() Element.elm_button.setAttribute('data-status' , 'next') alert('正解') } set_data_error(error_datas){ const tr_lists = Element.tr_lists for(let i=0; i<tr_lists.length; i++){ const td_lists = tr_lists[i].getElementsByTagName('td') for(let j=0; j<td_lists.length; j++){ if(error_datas && error_datas[i] && error_datas[i][j]){ td_lists[j].setAttribute('data-error' , 1) } else if(td_lists[j].hasAttribute('data-error')){ td_lists[j].removeAttribute('data-error') } } } }

完成確認

Checkボタンを押して、問題をクリアした時に、tableの下にクリア情報を表示するようにしました。
・クリアした面数 ・同じ面をクリアした回数 ・最後にクリアした日時
そして、次の面に移動しても、クリアした文字をクリックしたら、その問題を解いた状態をtable内に表示するようにしてみました。

解説とポイント

今回の一番のポイントは、history.jsという新しいクラスを追加した事です。 このクラスでは、大きく次の2つの役割があります。
1、ページ読み込み時にlocalStorageに保存されているクリア情報を取得してそれをテーブル下に表示する。 2、面クリア時に、クリアデータを履歴としてlocalStorageに保存する。
さほど難しい処理はしていないのですが、追加した関数の数が多いので、読み解くのは難しいかもしれません。 でも、この程度の関数を読み溶けるようになれば、他人のプログラムを解析するのは楽になるでしょう。 他のクラスモジュールに関しても、合わせて更新が入っているので、今回は10ファイル以上のファイル数を更新する事になってしまいました。

ポイント

一番注目したいのは、クリアした履歴データを保存する、localStorageの処理です。 以前に保存したデータは、localStorageの"mynt_number_place_save"というkey値に保存していましたが、 今回は、"mynt_number_place_clear"という名前で変えてあります。 同じkey内に一つのデータの塊として保存してもいいのですが、開発を進めた時に、データが壊れたら、キャッシュデータもクリア履歴データも壊れてしまいかねないので、安全を考慮して分けて保存することにしました。

このブログを検索

ごあいさつ

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