[ブラウザゲームの作り方] Number-Place(数独)#5「データ保存」

2023年5月24日

ゲーム プログラミング

eyecatch 操作系が安定してきたところで、入力した数値を保存して、ナンプレのゲームを途中で中断したり、ブラウザをリロードしても、ゲームの続きをプレイできるようにしたいと思います。 次のソースコードを上書き、又は新規作成して、環境を更新してください。

ソースコード

[更新] 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' 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.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/data.js

import { Main } from '../main.js' import { Common } from './common.js' export class Data{ save(){ 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(){ const json = window.localStorage.getItem(Main.save_name) if(!json){return null} return JSON.parse(json) } }

[新規] js/common.js

import { Main } from '../main.js' import { Element } from './element.js' export class Common{ static get is_started(){ if(Element.elm_button.getAttribute('data-status') === 'check'){ return true } else{ return false } } static start(){ Element.elm_button.setAttribute('data-status' , 'check') Main.question.new(Main.question_num) } static continue(){ const datas = Main.data.load() if(!datas){return} Main.question_num = datas.question_num this.start() this.put_number(datas.input) } static put_number(datas){ const tr_lists = Element.tr_lists if(!tr_lists || !tr_lists.length){return} 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++){ const num = datas[i][j] if(!num){continue} td_lists[j].textContent = num } } } // type : lock or '' static get_matrix_numbers(type=''){ const tr_lists = Element.tr_lists if(!tr_lists || !tr_lists.length){return} const numbers = [] for(let i=0; i<tr_lists.length; i++){ numbers[i] = [] const td_lists = tr_lists[i].getElementsByTagName('td') for(let j=0; j<td_lists.length; j++){ numbers[i][j] = this.get_cell_status(td_lists[j] , type) } } return numbers } static get_cell_status(cell , type){ if(cell.getAttribute('data-status') === type){ return Number(cell.textContent || 0) } else{ return 0 } } }

[更新] js/input.js

import { Main } from '../main.js' import { Element } from './element.js' import { Common } from './common.js' export class Input{ constructor(){ this.set_event() } get next_num(){ if(!this.data){return null} 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)) } 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){ const cell = e.target.closest('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, }, } } mousemove(e){ if(!this.data){return} 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 } mouseup(e){ if(!this.data){return} if(!this.data.move_flg){ this.data.num = this.next_num } this.data.cell.textContent = this.data.num || '' delete this.data Main.data.save() } // 移動距離を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': console.log('check') break case 'start': default: Common.start() Main.data.save() break } } }

[更新] view.js

import { Main } from '../main.js' import { Element } from './element.js' export class View{ constructor(){ if(!Element.table){return} this.set_stage() } set_stage(){ const table = document.createElement('table') Element.table.appendChild(table) this.set_row(table) } set_row(parent){ for(let i=0; i<9; i++){ const row = document.createElement('tr') parent.appendChild(row) this.set_cell(row) } } set_cell(parent){ for(let i=0; i<9; i++){ const cell = document.createElement('td') parent.appendChild(cell) } } }

[更新] question.js

import { Main } from '../main.js' import { Element } from './element.js' export class Question{ constructor(options){ this.options = options || {} this.load() } load(){ const xhr = new XMLHttpRequest() xhr.open('get' , Main.data_path , true) xhr.setRequestHeader('Content-Type', 'application/json'); xhr.onreadystatechange = (e => { if(xhr.readyState !== XMLHttpRequest.DONE){return} const status = xhr.status; if (status === 0 || (status >= 200 && status < 400) || e.target.response) { this.datas = JSON.parse(e.target.response) } this.finish() }).bind(this) xhr.send() } new(num){ Main.question_num = num || Main.question_num const data = this.datas[Main.question_num] if(!data || !data.data){return} this.put_numbers(data.data) } put_numbers(datas){ const tr_lists = Element.tr_lists if(!tr_lists || !tr_lists.length){return} for(let i=0; i<datas.length; i++){ const td_lists = tr_lists[i].getElementsByTagName('td') for(let j=0; j<datas[i].length; j++){ if(datas[i][j]){ td_lists[j].textContent = datas[i][j] td_lists[j].setAttribute('data-status' , 'lock') } else{ td_lists[j].textContent = '' td_lists[j].setAttribute('data-status' , '') } } } } finish(){ if(this.options.callback){ this.options.callback() } } }

解説とポイント

上記ソースコードで更新したページで文字を入力して、ブラウザのページをリロードしてみましょう。 入力した数値や、問題数値がすぐに現れていれば成功です。 もし、うまく表示出来ない人は、ブラウザキャッシュでモジュールが更新されていない可能性があるので、ブラウザのスーパーリロードをしてみてください。 GoogleChromeであれば、シフトを押しながらリロードボタンをクリックするか、デバッグコンソールを表示した状態で、controlを押しながら、リロードボタンを押して「キャッシュの消去とハード再読み込み」を選択してください。

今回のメインモジュール

今回の注目するモジュールは、data.jsです。 中を見ると、非常に簡単なコードで、saveとloadの処理を書いているだけです。 ただ、saveする時にどんなデータを保存するかというデータ作成は、common.jsに、get_matrix_numbers() という関数を作って対応しています。 localStorageの読み込みと書き込み処理は非常に簡単なので覚えておくとcookieよりも手軽に使えて便利ですよ。

get_matrix_numbers関数について

この関数では、tableタグのセルをfor文で1つずつ見ていって、データ登録した時のような、9x9のマトリクス2重配列データを作っています。 typeという値で、問題の数値と入力した数値の2種類を分類して、それぞれ保存するようにしています。 ちなみに、問題は、データに保存されているので、本来は問題のIDを保存しておけばいいだけなんですが、データの仕様が今後変わる可能性もあるので、必要情報は保存しておくことにしました。

保存タイミング

データを保存するイベントは、数値を入力したタイミングです。 Main.jsで最初にmain.dataという変数にdataクラスのインスタンスを保存して、いろいろな処理を引き継いでいく仕様にしています。 実際の入力処理である、input.jsの中の、72行目あたりに、mouseupのタイミングで、Main.data.save() という関数を実行しています。 これで、入力をするたびに、localStorageにデータを書くようにしています。 すでに書かれてあるデータがあれば、上書きをするという簡易な処理です。

データの読み込み

次に、ページを読み込んだ時(リロードした時)に、localStorageに保存してあるデータがあれば、それを表示するという処理を、Main.jsの、questionデータを読み込んだcallback関数内に書いています。 バケツリレー方式になっていますが、Commonクラスのcontinueという関数にデータをloadして、ゲームをスタートさせるようにしています。 数値をマス目に書き込む処理は、問題数値も、入力数値も、どれも同じ関数で対応できるように、Common.put_number(データ) という関数を作って対応しています。 当たり前ですが、プログラミングは、このように似たような処理は、同じ関数にして使うのが鉄則で、こうしてまとめていくことで、書き込むソースコードが少なくなります。 この辺の感覚は、教科書を見ても覚えられないので、たくさんコードを書いたり、人のコードを見て、自分なりのまとめ方を身に着けていくというのが良いと思います。