diff --git a/.eslintrc b/.eslintrc index ae31524..d05f39a 100644 --- a/.eslintrc +++ b/.eslintrc @@ -1,3 +1,6 @@ { - "extends": "hrundel/browser" + "extends": "hrundel/browser", + "parserOptions": { + "sourceType": "module" + } } diff --git a/drawer.js b/drawer.js new file mode 100644 index 0000000..808b660 --- /dev/null +++ b/drawer.js @@ -0,0 +1,56 @@ +/* global mina:true*/ +import snap from 'snapsvg'; + +class Drawer { + + drawHero() { + this.layout = snap('.hero__picture'); + this.head = this.layout.circle(60, 50, 50).attr({ fill: '#cca0a0' }); + this.eyes = this.layout.group( + this.layout.circle(80, 30, 10), + this.layout.circle(40, 30, 10) + ); + this.eyes.attr({ fill: '#CF4D6F' }); + this.mouth = this.layout.path('M20,70C40,90,80,90,100,70').attr({ fill: '#C97282' }); + this.nose = this.layout.path('').attr({ fill: '#76818E' }); + this.nose = this.layout.group( + this.layout.circle(60, 50, 12).attr({ fill: '#ecc5c4' }), + this.layout.circle(54, 50, 3).attr({ fill: '#b85f69' }), + this.layout.circle(66, 50, 3).attr({ fill: '#b85f69' }), + ); + this.paws = this.layout.group( + this.layout.circle(10, 55, 10), + this.layout.circle(110, 55, 10), + this.layout.circle(40, 100, 10), + this.layout.circle(80, 100, 10) + ); + this.paws.attr({ fill: '#ecc5c4' }); + } + + animateDeath() { + this.stopSpeak(); + this.eyes.attr({ fill: '#000' }); + this.mouth.animate({ d: 'M20,80C40,60,80,60,100,80' }, 1000); + } + + animateWake() { + this.eyes.attr({ fill: 'none' }).animate({ fill: '#CF4D6F' }, 1500, mina.easeinout); + this.paws.animate({ transform: 't0, 2' }, 500, mina.easein, () => { + this.paws.animate({ transform: 't0, -2' }, 500, mina.easein); + }); + } + + stopSpeak() { + if (this.ears) { + this.ears.attr({ opacity: 0 }); + } + } + + startSpeak() { + this.ears = this.layout.group(this.layout.polygon([14, 30, 40, 4, 0, 0]), + this.layout.polygon([106, 30, 80, 4, 110, 0])); + this.ears.attr({ fill: '#b85f69' }, 10000, mina.easein); + } +} + +export default new Drawer(); diff --git a/hrunogochi.js b/hrunogochi.js new file mode 100644 index 0000000..989ddd6 --- /dev/null +++ b/hrunogochi.js @@ -0,0 +1,132 @@ +'use srtict'; + +// eslint-disable-next-line no-empty-function +const bounded = (value) => Math.max(0, Math.min(100, value)); +const tickInterval = 1750; + +const phrases = [ + 'хрю-хрю-хрю', + 'давай играть', + 'ох-ох' +]; + +export default class Hrunogochi { + constructor(state) { + this.init(state); + } + + init(state) { + this.state = state || this.getState(); + this.eating = false; + this.sleeping = false; + this.speaking = false; + } + + getState() { + return { + 'bellyful': 100, + 'energy': 100, + 'mood': 100 + }; + } + + isDead() { + const { bellyful, energy, mood } = this; + + return [bellyful, energy, mood].filter(value => value === 0).length > 1; + } + + get energy() { + return this.state.energy; + } + + set energy(value) { + this.state.energy = bounded(value); + } + + get bellyful() { + return this.state.bellyful; + } + + set bellyful(value) { + this.state.bellyful = bounded(value); + } + + get mood() { + return this.state.mood; + } + + set mood(value) { + this.state.mood = bounded(value); + } + + setState({ bellyful, energy, mood }) { + this.bellyful = bellyful; + this.energy = energy; + this.mood = mood; + + this.onUpdate(); + } + + updateState(newState) { + const mergedState = Object.assign({}, this.state, newState); + this.setState(mergedState); + } + + saveState(saveAction) { + saveAction(this.state); + } + + start() { + // eslint-disable-next-line max-statements + const tick = () => { + if (this.isDead()) { + this.onDeath(); + + return this.stop(); + } + + if (Math.random() > 0.8) { + const text = phrases[Math.floor(Math.random() * phrases.length)]; + this.onSpeak(text); + } + + let { bellyful, energy, mood } = this; + + if (this.speaking) { + mood += 4; + } else if (this.sleeping) { + energy += 4; + } else if (this.eating) { + bellyful += 4; + } + + bellyful--; + energy--; + mood--; + + this.updateState({ + bellyful, + energy, + mood + }); + + this.tickId = setTimeout(tick, tickInterval); + }; + + tick(); + this.onStart(); + } + + stop() { + clearTimeout(this.tickId); + } + + reset() { + this.onReset(); + this.stop(); + this.setState(this.getState()); + this.start(); + } + +} diff --git a/index.css b/index.css index e69de29..08b2a6f 100644 --- a/index.css +++ b/index.css @@ -0,0 +1,58 @@ +html, +body +{ + margin: 0; + padding: 0; + height: 100%; +} + +body +{ + display: flex; +} + +header +{ + text-align: center; + margin-bottom: 1em; +} + +.game +{ + width: 35%; + margin: auto; + border: 2px black solid; + padding: 2em; +} + +.state, +.controls +{ + display: flex; + justify-content: space-between; +} + +.state__item, +.controls__item +{ + text-align: center; +} + +.controls__item +{ + padding: 5px 10px; + font-size: 15px; +} + +.hero +{ + margin: 10px; + display: flex; + flex-direction: column; + align-items: center; +} + +.hero__speech +{ + margin: 10px; +} diff --git a/index.html b/index.html index ae344b1..e482c68 100644 --- a/index.html +++ b/index.html @@ -4,7 +4,36 @@ Хрюногочи + + +
+
+ HRUNOGOCHI +
+
+
+ Cытость: 0% +
+
+ Энергия: 0% +
+
+ Настроение: 0% +
+
+ +
+ + + +
+ +
+ +
Готов поговорить с тобой!
+
+
diff --git a/index.js b/index.js new file mode 100644 index 0000000..595007e --- /dev/null +++ b/index.js @@ -0,0 +1,162 @@ +'use srtict'; + +import Hrunogochi from './hrunogochi'; +import drawer from './drawer'; + +const illuminance = 20; +const notificationInterval = 4000; + +const bellyfulValue = document.querySelector('.bellyful .state__item__value'); +const energyValue = document.querySelector('.energy .state__item__value'); +const moodValue = document.querySelector('.mood .state__item__value'); +const feedButton = document.querySelector('.controls__item.feed'); +const volume = document.querySelector('.controls__item.volume'); +const heroSpeech = document.querySelector('.hero__speech'); + +volume.value = localStorage.getItem('volume') || volume.value; + +const hruState = JSON.parse(localStorage.getItem('state')); +const hru = new Hrunogochi(hruState); + +feedButton.addEventListener('click', () => { + hru.eating = true; +}); + +window.addEventListener('beforeunload', () => { + hru.saveState(state => localStorage.setItem('state', JSON.stringify(state))); + localStorage.setItem('volume', volume.value); +}); + +const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition; +if (SpeechRecognition) { + const recognizer = new SpeechRecognition(); + recognizer.lang = 'ru-RU'; + recognizer.continious = true; + + recognizer.addEventListener('start', () => { + hru.speaking = true; + heroSpeech.textContent = 'Слушаю тебя!'; + drawer.startSpeak(); + }); + + recognizer.addEventListener('result', (e) => { + const index = e.resultIndex; + if (e.results[index].isFinal) { + const result = e.results[index][0].transcript.trim(); + heroSpeech.textContent = result; + recognizer.stop(); + } + }); + + recognizer.addEventListener('end', () => { + hru.speaking = false; + drawer.stopSpeak(); + }); + + document.querySelector('.hero__picture') + .addEventListener('click', () => recognizer.start()); +} + +window.addEventListener('blur', () => { + hru.sleeping = true; +}); + +window.addEventListener('focus', () => { + hru.sleeping = false; + drawer.animateWake(); +}); + +document.querySelector('.controls__item.reset') + .addEventListener('click', () => hru.reset()); + +if (window.speechSynthesis) { + hru.onSpeak = (text) => { + const speechSynthesis = window.speechSynthesis; + const message = new SpeechSynthesisUtterance(text); + message.lang = 'ru-RU'; + message.volume = volume.value / 100; + + speechSynthesis.speak(message); + }; +} + +if ('AmbientLightSensor' in window) { + // eslint-disable-next-line no-undef + var sensor = new AmbientLightSensor(); + + sensor.addEventListener('reading', () => { + if (sensor.illuminance < illuminance) { + hru.sleeping = true; + } else { + hru.sleeping = false; + drawer.animateWake(); + } + }); + + sensor.start(); +} + +if (navigator.getBattery) { + feedButton.remove(); + navigator + .getBattery() + .then(battery => { + hru.eating = battery.charging; + battery.addEventListener('chargingchange', () => { + hru.eating = battery.charging; + }); + }); +} + +// eslint-disable-next-line no-empty-function +let sendNotify = () => {}; +if (('Notification' in window)) { + Notification.requestPermission(permission => { + if (permission !== 'granted') { + return; + } + + let wasRecentlyNotified = false; + sendNotify = ({ mood, bellyful }) => { + let message = ''; + + if (mood < 10) { + message = 'Эй, я скучаю!'; + } else if (bellyful < 10) { + message = 'Я хочу кушать!'; + } + + if (message && !wasRecentlyNotified && hru.sleeping) { + // eslint-disable-next-line no-new + new Notification('Хрюногочи', { body: message }); + + wasRecentlyNotified = true; + setTimeout(() => { + wasRecentlyNotified = false; + }, notificationInterval); + } + }; + }); +} + +hru.onStart = () => { + drawer.drawHero(); +}; + +hru.onUpdate = () => { + bellyfulValue.textContent = hru.bellyful; + energyValue.textContent = hru.energy; + moodValue.textContent = hru.mood; + sendNotify(hru.state); +}; + +hru.onReset = () => { + heroSpeech.textContent = 'Готов поговорить с тобой!'; + drawer.stopSpeak(); +}; + +hru.onDeath = () => { + drawer.animateDeath(); +}; + +hru.start(); diff --git a/package.json b/package.json index 6210e61..cc82f32 100644 --- a/package.json +++ b/package.json @@ -1,17 +1,22 @@ { "private": true, "scripts": { + "start": "parcel index.html", "test": "npm run lint && mocha ./node_modules/html-tests/task-stub/test", "lint": "eslint . && stylelint *.css **/*.css && html-validator --file index.html --verbose && htmllint *.html" }, - "dependencies": { - "eslint": "4.18.0", + "devDependencies": { + "eslint": "4.9.0", "eslint-config-hrundel": "1.3.0", "html-tests": "1.0.2", "html-validator-cli": "3.0.2", "htmllint-cli-alpha": "0.0.6", "mocha": "3.1.0", + "parcel-bundler": "1.8.1", "stylelint": "7.3.1", "stylelint-config-hrundel": "1.0.3" + }, + "dependencies": { + "snapsvg": "0.5.1" } }