diff --git a/README.md b/README.md index 98ae0b4..6e8a5be 100644 --- a/README.md +++ b/README.md @@ -117,3 +117,18 @@ Quando ativo, após ações de cadastro/edição, exclusão, importação e expe - totais expedidos por SKU. > Importante: por segurança do navegador, não é possível editar automaticamente o mesmo arquivo Excel já aberto no seu computador. O que o sistema faz é gerar uma nova versão atualizada da planilha. + +## Demo de jogo mobile de resgate + +Adicionado um protótipo jogável em: + +- `resgate-game.html` + +Tema da demo: salvar personagens em várias fases (princesa, rei, camponesa + criança etc.), com controle por botões touch e setas do teclado. + +Para rodar: + +```bash +python3 -m http.server 4173 +# abrir http://localhost:4173/resgate-game.html +``` diff --git a/resgate-game.css b/resgate-game.css new file mode 100644 index 0000000..fe93262 --- /dev/null +++ b/resgate-game.css @@ -0,0 +1,101 @@ +:root { + --bg: #101728; + --card: #172239; + --text: #f4f7ff; + --accent: #ffca3a; + --danger: #ff595e; + --ok: #34d399; +} + +* { box-sizing: border-box; } + +body { + margin: 0; + font-family: Inter, system-ui, sans-serif; + background: radial-gradient(circle at top, #1f2f52, var(--bg)); + color: var(--text); +} + +.game-shell { + min-height: 100vh; + max-width: 540px; + margin: 0 auto; + padding: 1rem; + display: grid; + gap: 0.9rem; +} + +.hud { + background: var(--card); + border-radius: 14px; + padding: 0.8rem; + display: grid; + grid-template-columns: repeat(3, 1fr); + text-align: center; +} + +.label { + margin: 0; + font-size: 0.75rem; + opacity: 0.8; +} + +h1, h2 { margin: 0.2rem 0 0; font-size: 1.2rem; } + +.arena-wrap { + background: rgba(255, 255, 255, 0.06); + border-radius: 14px; + padding: 0.7rem; +} + +.arena { + position: relative; + width: 100%; + aspect-ratio: 1/1; + background: #0b1220; + border-radius: 10px; + border: 2px solid rgba(255,255,255,0.15); + overflow: hidden; +} + +.hero, .npc, .enemy { + position: absolute; + width: 24px; + height: 24px; + border-radius: 6px; +} + +.hero { background: var(--accent); box-shadow: 0 0 14px rgba(255,202,58,.6); } +.npc { background: var(--ok); } +.enemy { background: var(--danger); } + +.controls { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 0.5rem; +} + +button { + border: 0; + border-radius: 10px; + padding: 0.75rem; + font-size: 1rem; + font-weight: 700; + background: #2a3f67; + color: white; +} + +.actions { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 0.5rem; +} + +#nextPhaseBtn { background: #3c7d3f; } +.secondary { background: #5f6678; } + +.message { + min-height: 1.8rem; + text-align: center; + margin: 0; +} diff --git a/resgate-game.html b/resgate-game.html new file mode 100644 index 0000000..f498bff --- /dev/null +++ b/resgate-game.html @@ -0,0 +1,49 @@ + + + + + + Resgate Real - Demo Mobile + + + +
+
+
+

Fase

+

1

+
+
+

Vidas

+

3

+
+
+

Objetivo

+

Princesa

+
+
+ +
+
+
+
+
+ +
+ + + + +
+ +
+ + +
+ +

Chegue no refém evitando os inimigos.

+
+ + + + diff --git a/resgate-game.js b/resgate-game.js new file mode 100644 index 0000000..e54fd39 --- /dev/null +++ b/resgate-game.js @@ -0,0 +1,166 @@ +const arena = document.getElementById('arena'); +const heroEl = document.getElementById('hero'); +const phaseTitle = document.getElementById('phaseTitle'); +const livesEl = document.getElementById('lives'); +const goalEl = document.getElementById('goal'); +const messageEl = document.getElementById('message'); + +const PHASES = [ + { target: 'Princesa', enemies: 2 }, + { target: 'Rei', enemies: 3 }, + { target: 'Camponesa + Criança', enemies: 4 }, + { target: 'Rainha', enemies: 5 }, + { target: 'Vilarejo', enemies: 6 } +]; + +const state = { + phase: 0, + lives: 3, + hero: { x: 16, y: 16 }, + npc: null, + enemies: [], + enemyEls: [] +}; + +function clamp(value, min, max) { + return Math.min(max, Math.max(min, value)); +} + +function place(el, x, y) { + el.style.left = `${x}px`; + el.style.top = `${y}px`; +} + +function makeEnemy() { + const el = document.createElement('div'); + el.className = 'enemy'; + arena.appendChild(el); + return { + el, + x: Math.random() * 280 + 20, + y: Math.random() * 280 + 20, + vx: Math.random() > 0.5 ? 2 : -2, + vy: Math.random() > 0.5 ? 2 : -2 + }; +} + +function resetPhase() { + state.enemyEls.forEach((el) => el.remove()); + state.enemyEls = []; + state.enemies = []; + + const phase = PHASES[state.phase]; + phaseTitle.textContent = String(state.phase + 1); + goalEl.textContent = phase.target; + livesEl.textContent = String(state.lives); + state.hero = { x: 16, y: 16 }; + + document.querySelector('.npc')?.remove(); + const npcEl = document.createElement('div'); + npcEl.className = 'npc'; + arena.appendChild(npcEl); + state.npc = { + el: npcEl, + x: Math.random() * 260 + 40, + y: Math.random() * 260 + 40 + }; + + for (let i = 0; i < phase.enemies; i += 1) { + const enemy = makeEnemy(); + state.enemies.push(enemy); + state.enemyEls.push(enemy.el); + } + + messageEl.textContent = `Resgate: ${phase.target}`; + render(); +} + +function distance(a, b) { + return Math.hypot(a.x - b.x, a.y - b.y); +} + +function moveHero(dx, dy) { + const max = arena.clientWidth - 24; + state.hero.x = clamp(state.hero.x + dx, 0, max); + state.hero.y = clamp(state.hero.y + dy, 0, max); + render(); + checkCollisions(); +} + +function checkCollisions() { + if (distance(state.hero, state.npc) < 20) { + messageEl.textContent = `Você salvou ${PHASES[state.phase].target}!`; + return; + } + + const hit = state.enemies.some((enemy) => distance(state.hero, enemy) < 18); + if (hit) { + state.lives -= 1; + livesEl.textContent = String(state.lives); + if (state.lives <= 0) { + messageEl.textContent = 'Game over! Reinicie para tentar novamente.'; + state.lives = 0; + return; + } + state.hero = { x: 16, y: 16 }; + messageEl.textContent = 'Você foi atingido! Volte ao início da fase.'; + render(); + } +} + +function animateEnemies() { + const max = arena.clientWidth - 24; + state.enemies.forEach((enemy) => { + enemy.x += enemy.vx; + enemy.y += enemy.vy; + if (enemy.x <= 0 || enemy.x >= max) enemy.vx *= -1; + if (enemy.y <= 0 || enemy.y >= max) enemy.vy *= -1; + }); + render(); + checkCollisions(); + requestAnimationFrame(animateEnemies); +} + +function render() { + place(heroEl, state.hero.x, state.hero.y); + place(state.npc.el, state.npc.x, state.npc.y); + state.enemies.forEach((enemy) => place(enemy.el, enemy.x, enemy.y)); +} + +function nextPhase() { + if (state.phase === PHASES.length - 1) { + messageEl.textContent = 'Parabéns! Você concluiu todas as fases desta demo.'; + return; + } + state.phase += 1; + resetPhase(); +} + +document.querySelectorAll('[data-dir]').forEach((btn) => { + btn.addEventListener('click', () => { + if (state.lives <= 0) return; + const dir = btn.dataset.dir; + if (dir === 'left') moveHero(-18, 0); + if (dir === 'right') moveHero(18, 0); + if (dir === 'up') moveHero(0, -18); + if (dir === 'down') moveHero(0, 18); + }); +}); + +document.getElementById('nextPhaseBtn').addEventListener('click', nextPhase); +document.getElementById('restartBtn').addEventListener('click', () => { + state.phase = 0; + state.lives = 3; + resetPhase(); +}); + +window.addEventListener('keydown', (event) => { + if (state.lives <= 0) return; + if (event.key === 'ArrowLeft') moveHero(-16, 0); + if (event.key === 'ArrowRight') moveHero(16, 0); + if (event.key === 'ArrowUp') moveHero(0, -16); + if (event.key === 'ArrowDown') moveHero(0, 16); +}); + +resetPhase(); +requestAnimationFrame(animateEnemies);