├── .prettierrc ├── LICENSE ├── README.md ├── castle.png ├── cats-of-jasnah.css ├── cats-of-jasnah.js ├── cats.png ├── dog.png ├── index.html ├── levels.js ├── pig.png ├── raindough.png ├── tree.png └── trophy.png /.prettierrc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/countable/cats-of-jasnah/90c56df1be1107b81d8a0c1028f30aae5dfc04de/.prettierrc -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Clark Van Oyen 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # cats-of-jasnah 2 | Cats of Jasnah, a web based game for learning categories and logic 3 | 4 | I made this game with my 3 year old to develop logical language skills. It progresses from counting up to increasingly difficult word problems using boolean logic. This seems like an effective path for her to learn at her current level of development and I couldn't find something like this elsewhere. 5 | 6 | Enjoy! 7 | 8 | [Play the game](https://countable.github.io/cats-of-jasnah) 9 | 10 |  11 | 12 | ### References 13 | 14 | Thanks for all the feedback, folks from [Hacker News!](https://news.ycombinator.com/item?id=21880446#21886290) 15 | 16 | [The Game of Logic, Lewis Carroll](https://www.gutenberg.org/files/4763/4763-h/4763-h.htm) 17 | 18 | A work by [RainDough](https://countable.github.io/raindough/) 19 | -------------------------------------------------------------------------------- /castle.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/countable/cats-of-jasnah/90c56df1be1107b81d8a0c1028f30aae5dfc04de/castle.png -------------------------------------------------------------------------------- /cats-of-jasnah.css: -------------------------------------------------------------------------------- 1 | body { 2 | background: #ffddee; 3 | font-family: Helvetica, Arial, sans-serif; 4 | font-size: 15px; 5 | margin: 0; 6 | text-align: center; 7 | overflow: hidden; 8 | } 9 | 10 | html, 11 | body { 12 | height: 100%; 13 | } 14 | .github { 15 | position: absolute; 16 | display: block; 17 | bottom: 0; 18 | right: 0; 19 | z-index: 111; 20 | font-size: 2vmin; 21 | } 22 | p { 23 | font-size: 22px; 24 | margin: 0; 25 | padding: 1em 0 0 0; 26 | } 27 | .trophy { 28 | position:fixed; 29 | display:none; 30 | top: 50%; 31 | height: 30vmax; 32 | left: 50%; 33 | margin-left:-15vmax; 34 | margin-top:-20vmax; 35 | z-index: 10000; 36 | box-shadow: 10px 10px 50px 50px rgba(50,0,0,0.2); 37 | } 38 | .instructions { 39 | position: relative; 40 | z-index: 10000; 41 | padding: 25px 25px; 42 | } 43 | .coj-btn { 44 | align-items: center; 45 | background-color: #ffeef8; 46 | border: 1px solid transparent; 47 | box-shadow: 1px 1px 5px rgba(0, 0, 0, 0.3); 48 | color: #000; 49 | cursor: pointer; 50 | display: inline-flex; 51 | font-size: 1.5rem; 52 | height: 40px; 53 | justify-content: center; 54 | margin: 4px 5px; 55 | outline: 0; 56 | padding: 0; 57 | text-align: center; 58 | -moz-user-select: none; 59 | -ms-user-select: none; 60 | -webkit-user-select: none; 61 | user-select: none; 62 | vertical-align: middle; 63 | width: calc(16.66667% - 10px); 64 | } 65 | 66 | .coj-btn:focus { 67 | background-color: #b3cdff; 68 | } 69 | 70 | .spinning { 71 | animation: 2s spin infinite; 72 | } 73 | 74 | .hidden { 75 | display: none; 76 | } 77 | .ghost { 78 | opacity: 0.5; 79 | } 80 | 81 | svg .face { 82 | fill: white; 83 | } 84 | 85 | .smile, 86 | .frown { 87 | display: none; 88 | } 89 | 90 | .ducks .smile, 91 | .ducks .frown { 92 | display: inline; 93 | stroke: none; 94 | fill: orange; 95 | } 96 | 97 | .mouth { 98 | stroke: black; 99 | } 100 | 101 | .red .face { 102 | fill: red; 103 | } 104 | 105 | .blue .face { 106 | fill: #8888ff; 107 | } 108 | 109 | .yellow .face { 110 | fill: yellow; 111 | } 112 | 113 | .purple .face { 114 | fill: #aa66ff; 115 | } 116 | 117 | @keyframes spin { 118 | 0% { 119 | transform: translate(0, 0) rotate(0deg); 120 | } 121 | 50% { 122 | transform: translate(1em, -1em) rotate(360deg); 123 | } 124 | 100% { 125 | transform: translate(0, 0) rotate(0deg); 126 | } 127 | } 128 | 129 | .bouncing { 130 | animation: bounce 2s infinite; 131 | animation-timing-function: cubic-bezier(0.28, 0.84, 0.42, 1); 132 | } 133 | 134 | @keyframes bounce { 135 | 0% { 136 | transform: scale(1, 1) translateY(0); 137 | } 138 | 10% { 139 | transform: scale(1.1, 0.9) translateY(0); 140 | } 141 | 30% { 142 | transform: scale(0.9, 1.1) translateY(-50px); 143 | } 144 | 50% { 145 | transform: scale(1.05, 0.95) translateY(0); 146 | } 147 | 57% { 148 | transform: scale(1, 1) translateY(-5px); 149 | } 150 | 64% { 151 | transform: scale(1, 1) translateY(0); 152 | } 153 | 100% { 154 | transform: scale(1, 1) translateY(0); 155 | } 156 | } 157 | 158 | h1 { 159 | font-size: 2em; 160 | font-variant: small-caps; 161 | text-align: center; 162 | text-shadow: 1px 1px 1px rgba(0, 0, 0, 0.5); 163 | color: white; 164 | margin: 0; 165 | cursor: pointer; 166 | } 167 | 168 | .back, 169 | .forward { 170 | font-size: 25px; 171 | position: fixed; 172 | top: 0; 173 | width: 25px; 174 | cursor: pointer; 175 | } 176 | 177 | .back { 178 | left: 0; 179 | } 180 | 181 | .forward { 182 | right: 0; 183 | } 184 | 185 | .level-display { 186 | box-shadow: 1px 1px 5px rgba(0, 0, 0, 0.3); 187 | background: #ffeef8; 188 | padding: 5px; 189 | margin: 0; 190 | text-align: center; 191 | display: none; 192 | } 193 | 194 | .number-bar { 195 | position: fixed; 196 | bottom: 1rem; 197 | left: 0; 198 | right: 0; 199 | display: none; 200 | } 201 | 202 | .number-bar .desc { 203 | display: none; 204 | } 205 | 206 | li { 207 | padding: 10px 5px; 208 | list-style: none; 209 | cursor: pointer; 210 | margin: 7px; 211 | box-shadow: 1px 1px 5px rgba(0, 0, 0, 0.3); 212 | border-radius: 5px; 213 | background: #ffeef8; 214 | } 215 | 216 | ul { 217 | margin: 0; 218 | padding: 0; 219 | float:left; 220 | } 221 | 222 | svg { 223 | height: 14vmax; 224 | width: 14vmax; 225 | } 226 | #logo { 227 | max-width: 100% 228 | } 229 | .splash { 230 | top: 50%; 231 | left: 50%; 232 | z-index: 100; 233 | position: fixed; 234 | transform: translate(-50%, -50%) scale(10); 235 | } 236 | @keyframes example { 237 | from {transform: translate(-50%, -50%) scale(5);} 238 | to {transform: translate(-50%, -50%) scale(10);} 239 | } 240 | .topics { 241 | display: flex; 242 | } 243 | .topics ul { 244 | flex: 1; 245 | } 246 | .yay { 247 | animation-name: example; 248 | animation-duration: 2s; 249 | } 250 | 251 | @media (min-width: 768px) { 252 | .coj-btn { 253 | height: 60px; 254 | width: 60px; 255 | } 256 | 257 | .number-bar .desc { 258 | display: block; 259 | font-size: 14px; 260 | opacity: 0.7; 261 | padding-top: 0.25rem; 262 | } 263 | } 264 | 265 | /*some vmax/vh elements get crazy at hi-res, cap their size.*/ 266 | @media screen and (min-width: 1024px) { 267 | svg { 268 | height: 160px; 269 | width: 140px; 270 | } 271 | } 272 | 273 | #forkongithub a { 274 | background: #000; 275 | color: #fff; 276 | text-decoration: none; 277 | font-family: arial, sans-serif; 278 | text-align: center; 279 | font-weight: bold; 280 | padding: 5px 40px; 281 | font-size: 1rem; 282 | line-height: 2rem; 283 | position: relative; 284 | transition: 0.5s; 285 | } 286 | #forkongithub a:hover { 287 | background: #c11; 288 | color: #fff; 289 | } 290 | #forkongithub a::before, 291 | #forkongithub a::after { 292 | content: ''; 293 | width: 100%; 294 | display: block; 295 | position: absolute; 296 | top: 1px; 297 | left: 0; 298 | height: 1px; 299 | background: #fff; 300 | } 301 | #forkongithub a::after { 302 | bottom: 1px; 303 | top: auto; 304 | } 305 | @media screen and (min-width: 800px) { 306 | #forkongithub { 307 | position: absolute; 308 | display: block; 309 | top: 0; 310 | right: 0; 311 | width: 200px; 312 | overflow: hidden; 313 | height: 200px; 314 | z-index: 9999; 315 | } 316 | #forkongithub a { 317 | width: 200px; 318 | position: absolute; 319 | top: 60px; 320 | right: -60px; 321 | transform: rotate(45deg); 322 | -webkit-transform: rotate(45deg); 323 | -ms-transform: rotate(45deg); 324 | -moz-transform: rotate(45deg); 325 | -o-transform: rotate(45deg); 326 | box-shadow: 4px 4px 10px rgba(0, 0, 0, 0.8); 327 | } 328 | } 329 | 330 | ellipse.face { 331 | stroke: #ff88dd; 332 | } 333 | .circle ellipse.face { 334 | stroke: lightgreen; 335 | stroke-width: 10px; 336 | } 337 | -------------------------------------------------------------------------------- /cats-of-jasnah.js: -------------------------------------------------------------------------------- 1 | const COLOR_ATTS = ['red', 'blue', 'yellow', 'purple'] 2 | const MOTION_ATTS = ['bouncing', 'spinning'] 3 | const ANIMAL_ATTS = ['ducks'] 4 | const ALL_ATTS = COLOR_ATTS.concat(MOTION_ATTS).concat(ANIMAL_ATTS) 5 | let ATTS 6 | let cur_atts = {} 7 | let clue 8 | let stage 9 | 10 | const pluralize_cat = function(count) { 11 | return Math.abs(count) > 1 ? 'cats' : 'cat'; 12 | }; 13 | 14 | const pick_rand = function(seq) { 15 | return seq[Math.floor(Math.random() * seq.length)] 16 | } 17 | const set_level = function(topic_num, stage_num) { 18 | // initialization 19 | $('#logo').hide() 20 | $('.instructions').hide() 21 | $('#forkongithub').hide() 22 | $('.number-bar').show() 23 | $('.level-display').show() 24 | 25 | console.log(topic_num, stage_num) 26 | if (stage_num >= TOPICS[topic_num].stages.length) { 27 | 28 | topic_num ++ 29 | if (topic_num >= TOPICS.length) { 30 | return alert('this is the highest level already.') 31 | } 32 | stage_num = 0 33 | } 34 | stage = {} 35 | stage.__proto__ = TOPICS[topic_num].stages[stage_num] 36 | 37 | $('.level-number').text( 38 | topic_num + '-' + stage_num + ' | ' + stage.name 39 | ) 40 | 41 | make_cats() 42 | } 43 | 44 | const permute_atts = function() { 45 | cur_atts = {} 46 | for (let i = 0; i < ATTS.length && i < stage.max_asked_atts; i++) { 47 | if (Math.random() < stage.att_chance) { 48 | cur_atts[ATTS[i]] = !stage.get_value('negation') 49 | } 50 | } 51 | } 52 | 53 | 54 | const speak = function(text, opts) { 55 | opts = opts || {} 56 | $('p.clue').html(text + "🔈") 57 | responsiveVoice.speak(text, 'US English Female', opts) 58 | } 59 | 60 | const draw_stars = function() { 61 | $('.stars').html(stage.get_stars() + '★') 62 | } 63 | 64 | var make_cats = function() { 65 | stage.init() 66 | draw_stars() 67 | stage.set_avail_atts() 68 | permute_atts() 69 | is_reversed = Math.random() < 0.5 70 | let text = 'how many ' 71 | const keys = Object.keys(cur_atts) 72 | const prefix_pos = stage.get_num_adjectives(keys) 73 | const prefix_keys = keys.slice(0, prefix_pos) 74 | if (prefix_keys.length) { 75 | let prefix_words = [] 76 | for (let att in prefix_keys) { 77 | prefix_words.push( 78 | (cur_atts[prefix_keys[att]] ? '' : 'non-') + prefix_keys[att] 79 | ) 80 | } 81 | text += prefix_words.join(', ') + ' ' 82 | // ducks is just 'duck' when used as an adjective 83 | text = text.replace('ducks', 'duck') 84 | } 85 | postfix_keys = keys.slice(prefix_pos) 86 | if (postfix_keys.length) { 87 | text += 'cats ' + stage.get_equality_operator() + ' ' 88 | 89 | let items = [] 90 | for (let att in postfix_keys) { 91 | items.push( 92 | (cur_atts[postfix_keys[att]] ? '' : 'not ') + postfix_keys[att] 93 | ) 94 | } 95 | text += items.join(' ' + stage.operator + ' are ') 96 | } else { 97 | text += 'cats ' + stage.get_equality_operator() + ' here' 98 | } 99 | if (stage.successor) { 100 | // alternate wording 101 | if (Math.random() > .5) { 102 | text += ' if we had ' + Math.abs(stage.successor) + ' ' 103 | + (stage.successor > 0 ? 'more ' : 'less ') 104 | + (ATTS.length ? ATTS[0] + ' ' + pluralize_cat(stage.successor): '') 105 | } else { 106 | text += ' if ' + Math.abs(stage.successor) + ' ' 107 | + (stage.successor > 0 108 | ? 'more ' + (ATTS.length ? ATTS[0] + ' ' + pluralize_cat(stage.successor) + ' ' : '') + 'came' 109 | : (ATTS.length ? ATTS[0] + ' ' + pluralize_cat(stage.successor) + ' ' : '') + 'went away') 110 | } 111 | } 112 | 113 | text += '?' 114 | 115 | // substitution. 116 | if (stage.get_value('substitution')) {1 117 | if (Math.random() < 0.5) { 118 | text = text.replace(/not.blue/, 'white') 119 | text = text.replace(/not.red/, 'white') 120 | text = text.replace(/not.yellow/, 'white') 121 | } 122 | } 123 | 124 | clue = text 125 | 126 | // remove existing cats and add new ones for the current level. 127 | $('svg:gt(0)').remove() 128 | num_cats = stage.num_cats() 129 | for (var i = 0; i < num_cats + stage.get_added_num(); i++) { 130 | $('svg') 131 | .eq(0) 132 | .clone() 133 | .appendTo('body') 134 | .each(function(svg) { 135 | const $t = $(this) 136 | $(this).removeClass('hidden') 137 | if (i >= num_cats) { 138 | $(this).addClass('hidden') 139 | } 140 | for (var att = 0; att < ATTS.length; att++) { 141 | if (i >= num_cats) { 142 | // for now set 1 (will chance base on gameplay) 143 | chance = 1; 144 | } else if (cur_atts[ATTS[att]] === true) { 145 | chance = stage.chance() 146 | } else if (cur_atts[ATTS[att]] === false) { 147 | chance = 1 - stage.chance() 148 | } else { 149 | chance = 0.5 150 | } 151 | if (Math.random() < chance) $t.addClass(ATTS[att]) 152 | } 153 | }) 154 | } 155 | console.log('num_cats rendered', num_cats + stage.get_added_num(), $('svg:gt(0)').length) 156 | const num_answer = get_answer().length 157 | if (num_answer > 9) { 158 | console.log('Too many cats, generate a new puzzle.') 159 | return make_cats() 160 | } else if (num_answer == 0 &! stage.min > 0) { 161 | console.log('not enough cats.') 162 | return make_cats() 163 | } 164 | 165 | speak(clue) 166 | } 167 | 168 | const next_level = function(){ 169 | let next_stage_num = stage.number + 1 170 | set_level(stage.topic.topic_number, next_stage_num) 171 | } 172 | const sound = function(s) { 173 | var snd = new Audio(s + '.mp3') 174 | snd.play() 175 | } 176 | 177 | $('body').keyup(function(e) { 178 | if (e.key === ' ') return next_level() 179 | if (!/\d/.test(e.key)) return 180 | submit(parseInt(e.key)) 181 | }) 182 | 183 | const get_answer = function() { 184 | let set = $('svg:gt(0)').filter(function(svg) { 185 | let match = stage.operator === 'and'; 186 | for (let att in cur_atts) { 187 | // consider negation 188 | let in_set = (cur_atts[att] ? $(this).hasClass(att) : !$(this).hasClass(att)); 189 | // conjunction / disjunction 190 | match = 191 | stage.operator === 'and' ? (match && in_set) : (match || in_set) 192 | 193 | } 194 | return match 195 | }) 196 | if (set.length + stage.get_added_num() < 0) { 197 | // return invalid answer to generate again 198 | return { length: 10 } 199 | } 200 | set = set.slice(0, set.length + stage.get_added_num()) 201 | console.log(set.length, 'answer size counted', set) 202 | return set 203 | } 204 | 205 | const submit = function(value) { 206 | let answer_set = get_answer() 207 | answer = answer_set.length 208 | console.log("COMPARING", value, 'to answer', answer) 209 | answer_set.addClass('circle') 210 | answer_set.filter('.hidden').addClass('ghost').removeClass('hidden') 211 | 212 | if (value === answer) { 213 | stage.add_star() 214 | //yay() 215 | let congrats = "That's right, " + answer + '.' 216 | if (stage.get_stars() == 5) { 217 | congrats += " You're on a Winning Streak!" 218 | $('.trophy').show() 219 | } 220 | speak(congrats, { 221 | onend: function() { 222 | /*if (stage.get_stars() == 5) { 223 | next_level() 224 | } else {*/ 225 | make_cats() 226 | 227 | $('.trophy').hide() 228 | //} 229 | } 230 | }) 231 | } else { 232 | speak("Good try, but that's wrong.", { 233 | onend: function() { 234 | stage.lose_stars() 235 | draw_stars() 236 | speak(clue) 237 | } 238 | }) 239 | } 240 | } 241 | 242 | const n_str = function(n, s) { 243 | if (n < 2) return s 244 | return s + n_str(n-1, s) 245 | } 246 | 247 | const yay = function() { 248 | $(".splash").html(n_str(stage.get_stars(), '★')).show().addClass('yay') 249 | setTimeout(function(){ 250 | $('.splash').hide().removeClass('yay') 251 | }, 2000); 252 | } 253 | -------------------------------------------------------------------------------- /cats.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/countable/cats-of-jasnah/90c56df1be1107b81d8a0c1028f30aae5dfc04de/cats.png -------------------------------------------------------------------------------- /dog.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/countable/cats-of-jasnah/90c56df1be1107b81d8a0c1028f30aae5dfc04de/dog.png -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 |
3 | 4 |