<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<title>Tiny Town Explorer</title>
<style>
:root {
--bg-color: #2c3e50;
--ui-bg: rgba(0, 0, 0, 0.6);
--accent: #f1c40f;
--text: #ecf0f1;
}
* {
box-sizing: border-box;
user-select: none;
-webkit-user-select: none;
touch-action: none;
}
body {
margin: 0;
padding: 0;
overflow: hidden;
background-color: var(--bg-color);
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
color: var(--text);
width: 100vw;
height: 100vh;
}
#game-container {
position: relative;
width: 100%;
height: 100%;
overflow: hidden;
background: #81c784;
cursor: grab;
}
#game-container:active { cursor: grabbing; }
canvas {
display: block;
width: 100%;
height: 100%;
}
/* Joystick Styling */
#joystick-zone {
position: absolute;
left: 0;
top: 0;
width: 100%;
height: 100%;
pointer-events: none;
z-index: 10;
}
.joystick {
position: absolute;
width: 140px;
height: 140px;
background: var(--ui-bg);
backdrop-filter: blur(8px);
border: 2px solid rgba(255,255,255,0.3);
border-radius: 50%;
transform: translate(-50%, -50%) scale(0);
transition: transform 0.1s cubic-bezier(0.175, 0.885, 0.32, 1.275);
pointer-events: none;
box-shadow: 0 10px 30px rgba(0,0,0,0.3);
display: flex;
align-items: center;
justify-content: center;
z-index: 50;
}
.joystick.active { transform: translate(-50%, -50%) scale(1); }
.joystick-knob {
width: 50px;
height: 50px;
background: rgba(255, 255, 255, 0.95);
border-radius: 50%;
position: absolute;
box-shadow: 0 4px 10px rgba(0,0,0,0.3);
transition: transform 0.05s linear;
}
/* UI Overlay */
#ui-layer {
position: absolute;
top: 20px;
left: 20px;
pointer-events: none;
z-index: 40;
text-shadow: 0 2px 4px rgba(0,0,0,0.5);
}
h1 {
margin: 0;
font-size: 1.2rem;
font-weight: 800;
letter-spacing: 1px;
text-transform: uppercase;
}
.coords {
font-family: monospace;
font-size: 0.9rem;
background: var(--ui-bg);
padding: 4px 8px;
border-radius: 4px;
margin-top: 5px;
display: inline-block;
color: var(--accent);
}
/* Minimap */
#minimap {
position: absolute;
bottom: 20px;
right: 20px;
width: 150px;
height: 150px;
background: rgba(0, 20, 0, 0.8);
border: 2px solid rgba(255,255,255,0.3);
border-radius: 50%;
pointer-events: none;
z-index: 30;
overflow: hidden;
}
#minimap canvas {
width: 100%;
height: 100%;
border-radius: 50%;
}
#tooltip {
position: absolute;
background: rgba(255, 255, 255, 0.95);
color: #222;
padding: 6px 12px;
border-radius: 8px;
font-size: 12px;
font-weight: bold;
pointer-events: none;
opacity: 0;
transition: opacity 0.2s, transform 0.1s;
transform: translate(-50%, -160%) scale(0.9);
z-index: 35;
box-shadow: 0 4px 12px rgba(0,0,0,0.2);
}
#loader {
position: absolute; inset: 0;
background: #2d3436;
display: flex; align-items: center; justify-content: center;
z-index: 100; transition: opacity 0.5s;
}
.spinner {
width: 40px; height: 40px;
border: 4px solid rgba(255,255,255,0.2);
border-top-color: var(--accent);
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
</style>
</head>
<body>
<div id="loader"><div class="spinner"></div></div>
<div id="game-container">
<canvas id="world"></canvas>
<div id="joystick-zone">
<div id="joystick" class="joystick">
<div id="joystick-knob" class="joystick-knob"></div>
</div>
</div>
<div id="ui-layer">
<h1>Tiny Town</h1>
<div class="coords" id="coord-display">X: 0 | Y: 0</div>
</div>
<div id="minimap">
<canvas id="minimap-canvas"></canvas>
</div>
<div id="tooltip"></div>
</div>
<script>
const canvas = document.getElementById('world');
const ctx = canvas.getContext('2d');
const mmCanvas = document.getElementById('minimap-canvas');
const mmCtx = mmCanvas.getContext('2d');
const uiCoords = document.getElementById('coord-display');
// Game Config
const WORLD_W = 2000;
const WORLD_H = 2000;
const TILE_SIZE = 40;
const PLAYER_SPEED = 5;
const MAX_JOYSTICK_DIST = 40;
// State
let width, height;
let camera = { x: 0, y: 0 };
let input = { x: 0, y: 0, active: false };
let joystickPos = { x: 0, y: 0 };
const player = { x: 1000, y: 1000, z: 0, color: '#FFD166' };
const worldObjects = [];
const collisionRects = [];
// Setup
function initWorld() {
// Create Roads (Grid Pattern)
const roadColor = '#d7ccc8';
for(let x=0; x<WORLD_W; x+=400) {
worldObjects.push({type:'road', x:x, y:0, w:40, h:WORLD_H, color:roadColor});
}
for(let y=0; y<WORLD_H; y+=400) {
worldObjects.push({type:'road', x:0, y:y, w:WORLD_W, h:40, color:roadColor});
}
// Procedural Town Generation
const positions = [
[600,600], [1000,600], [1400,600],
[600,1000], [1400,1000],
[600,1400], [1000,1400], [1400,1400]
];
positions.forEach((pos, i) => {
const isBig = i === 4; // Center building
createBuilding(pos[0], pos[1], isBig ? 120 : 80, isBig ? 100 : 70,
isBig ? '#c0392b' : '#d35400',
isBig ? '#2c3e50' : '#e67e22',
isBig ? 'Town Hall' : 'House #' + (i+1)
);
// Surrounding trees
createTree(pos[0] - 50, pos[1] - 50, 20);
createTree(pos[0] + 100, pos[1] + 20, 20);
});
// Random park area
for(let i=0; i<20; i++) {
createTree(200 + Math.random()*300, 200 + Math.random()*300, 25 + Math.random()*10);
}
// Lake
worldObjects.push({type:'lake', x:1600, y:300, r:150});
}
function createBuilding(x, y, w, h, wall, roof, name) {
worldObjects.push({type:'building', x, y, w, h, wall, roof, name, z:y+h});
collisionRects.push({x,y,w,h});
}
function createTree(x, y, r) {
worldObjects.push({type:'tree', x, y, r, z:y+r});
collisionRects.push({x: x-r/2, y:y-r/2, w:r, h:r});
}
// Input Handling
const joystickEl = document.getElementById('joystick');
const knobEl = document.getElementById('joystick-knob');
function setupInputs() {
const container = document.getElementById('game-container');
container.addEventListener('touchstart', (e) => {
if(e.target.closest('#ui-layer')) return;
const t = e.touches[0];
joystickPos = {x: t.clientX, y: t.clientY};
joystickEl.style.left = t.clientX + 'px';
joystickEl.style.top = t.clientY + 'px';
joystickEl.classList.add('active');
input.active = true;
updateJoystick(t.clientX, t.clientY);
}, {passive:false});
container.addEventListener('touchmove', (e) => {
if(!input.active) return;
e.preventDefault();
updateJoystick(e.touches[0].clientX, e.touches[0].clientY);
}, {passive:false});
container.addEventListener('touchend', () => {
input.active = false; input.x = 0; input.y = 0;
joystickEl.classList.remove('active');
knobEl.style.transform = `translate(0px,0px)`;
});
// Keyboard
const keys = {};
window.addEventListener('keydown', e => {
keys[e.key] = true;
updateKeyboard();
});
window.addEventListener('keyup', e => {
keys[e.key] = false;
updateKeyboard();
});
function updateKeyboard() {
input.x = 0; input.y = 0;
if(keys['ArrowUp'] || keys['w']) input.y = -1;
if(keys['ArrowDown'] || keys['s']) input.y = 1;
if(keys['ArrowLeft'] || keys['a']) input.x = -1;
if(keys['ArrowRight'] || keys['d']) input.x = 1;
input.active = input.x !== 0 || input.y !== 0;
}
}
function updateJoystick(cx, cy) {
let dx = cx - joystickPos.x;
let dy = cy - joystickPos.y;
const dist = Math.sqrt(dx*dx + dy*dy);
if(dist > MAX_JOYSTICK_DIST) {
const ratio = MAX_JOYSTICK_DIST/dist;
dx *= ratio; dy *= ratio;
}
knobEl.style.transform = `translate(${dx}px,${dy}px)`;
input.x = dx/MAX_JOYSTICK_DIST;
input.y = dy/MAX_JOYSTICK_DIST;
}
// Game Loop
function update() {
// Movement
if(input.active) {
player.x += input.x * PLAYER_SPEED;
player.y += input.y * PLAYER_SPEED;
}
// World Bounds
player.x = Math.max(20, Math.min(WORLD_W-20, player.x));
player.y = Math.max(20, Math.min(WORLD_H-20, player.y));
player.z = player.y; // For sorting
// Camera Logic (Center on player, clamp to world bounds)
let targetCamX = player.x - width/2;
let targetCamY = player.y - height/2;
// Clamp Camera so we don't see void
targetCamX = Math.max(0, Math.min(targetCamX, WORLD_W - width));
targetCamY = Math.max(0, Math.min(targetCamY, WORLD_H - height));
// Smooth Lerp
camera.x += (targetCamX - camera.x) * 0.1;
camera.y += (targetCamY - camera.y) * 0.1;
// UI Updates
uiCoords.textContent = `X: ${Math.floor(player.x)} | Y: ${Math.floor(player.y)}`;
}
function draw() {
ctx.fillStyle = '#81c784';
ctx.fillRect(0,0,width,height);
ctx.save();
ctx.translate(-Math.floor(camera.x), -Math.floor(camera.y));
// Draw Grid (Visual proof of movement)
ctx.strokeStyle = 'rgba(0,0,0,0.05)';
ctx.lineWidth = 1;
for(let x=0; x<=WORLD_W; x+=TILE_SIZE) {
ctx.beginPath(); ctx.moveTo(x,0); ctx.lineTo(x,WORLD_H); ctx.stroke();
}
for(let y=0; y<=WORLD_H; y+=TILE_SIZE) {
ctx.beginPath(); ctx.moveTo(0,y); ctx.lineTo(WORLD_W,y); ctx.stroke();
}
// World Border
ctx.strokeStyle = '#2e7d32';
ctx.lineWidth = 5;
ctx.strokeRect(0,0,WORLD_W,WORLD_H);
// Prepare Render List
const renderList = [...worldObjects,
{...player, type:'player', z:player.y},
{type:'tooltip', x:player.x, y:player.y-20, z:player.y} // Dummy for z-sort
];
renderList.sort((a,b) => a.z - b.z);
renderList.forEach(obj => {
if(obj.type === 'road') drawRoad(obj);
else if(obj.type === 'building') drawBuilding(obj);
else if(obj.type === 'tree') drawTree(obj);
else if(obj.type === 'lake') drawLake(obj);
else if(obj.type === 'player') drawPlayer(obj);
});
ctx.restore();
drawMinimap();
}
function drawRoad(r) {
ctx.fillStyle = r.color;
ctx.fillRect(r.x, r.y, r.w, r.h);
// Dashed line center
ctx.strokeStyle = 'rgba(0,0,0,0.1)';
ctx.setLineDash([20, 20]);
ctx.lineWidth = 2;
ctx.beginPath();
if(r.w > r.h) { // Horizontal
ctx.moveTo(r.x, r.y+r.h/2);
ctx.lineTo(r.x+r.w, r.y+r.h/2);
} else {
ctx.moveTo(r.x+r.w/2, r.y);
ctx.lineTo(r.x+r.w/2, r.y+r.h);
}
ctx.stroke();
ctx.setLineDash([]);
}
function drawBuilding(b) {
// Shadow
ctx.fillStyle = 'rgba(0,0,0,0.2)';
ctx.fillRect(b.x+10, b.y+b.h, b.w, 8);
// Walls
ctx.fillStyle = b.wall;
ctx.fillRect(b.x, b.y, b.w, b.h);
// Trim
ctx.fillStyle = 'rgba(0,0,0,0.1)';
ctx.fillRect(b.x, b.y+b.h-10, b.w, 10);
// Roof
ctx.fillStyle = b.roof;
ctx.beginPath();
ctx.moveTo(b.x-10, b.y);
ctx.lineTo(b.x+b.w+10, b.y);
ctx.lineTo(b.x+b.w/2, b.y-40);
ctx.fill();
// Door
ctx.fillStyle = '#4e342e';
ctx.fillRect(b.x+b.w/2-12, b.y+b.h-30, 24, 30);
// Label if close
const dx = player.x - (b.x+b.w/2);
const dy = player.y - (b.y+b.h/2);
if(Math.sqrt(dx*dx+dy*dy) < 80) {
ctx.fillStyle = 'rgba(0,0,0,0.7)';
ctx.fillRect(b.x+b.w/2-40, b.y-60, 80, 20);
ctx.fillStyle = 'white';
ctx.font = '12px sans-serif';
ctx.textAlign = 'center';
ctx.fillText(b.name, b.x+b.w/2, b.y-46);
// Update DOM tooltip position
updateTooltip(b.x+b.w/2, b.y, b.name);
}
}
function drawTree(t) {
ctx.fillStyle = 'rgba(0,0,0,0.15)';
ctx.beginPath();
ctx.ellipse(t.x, t.y+t.r/2, t.r, t.r/3, 0, 0, Math.PI*2);
ctx.fill();
ctx.fillStyle = '#5d4037'; // Trunk
ctx.fillRect(t.x-6, t.y, 12, t.r);
ctx.fillStyle = '#2e7d32'; // Leaves
ctx.beginPath();
ctx.arc(t.x, t.y-t.r/3, t.r, 0, Math.PI*2);
ctx.arc(t.x-t.r/2, t.y, t.r*0.7, 0, Math.PI*2);
ctx.arc(t.x+t.r/2, t.y, t.r*0.7, 0, Math.PI*2);
ctx.fill();
}
function drawLake(l) {
ctx.fillStyle = '#0288d1';
ctx.beginPath();
ctx.arc(l.x, l.y, l.r, 0, Math.PI*2);
ctx.fill();
// Glint
ctx.fillStyle = 'rgba(255,255,255,0.3)';
ctx.beginPath();
ctx.arc(l.x-20, l.y-20, 10, 0, Math.PI*2);
ctx.fill();
}
function drawPlayer(p) {
// Shadow
ctx.fillStyle = 'rgba(0,0,0,0.3)';
ctx.beginPath();
ctx.ellipse(p.x, p.y+8, 10, 5, 0, 0, Math.PI*2);
ctx.fill();
// Body
ctx.fillStyle = p.color;
ctx.beginPath();
ctx.arc(p.x, p.y, 12, 0, Math.PI*2);
ctx.fill();
// Direction indicator (hat)
ctx.fillStyle = '#e53935';
ctx.beginPath();
ctx.arc(p.x, p.y-2, 12, Math.PI, 0);
ctx.fill();
// Walking wobble
if(input.active) {
ctx.fillStyle = 'rgba(0,0,0,0.5)';
ctx.beginPath();
ctx.arc(p.x + Math.cos(Date.now()/100)*5, p.y+6, 3, 0, Math.PI*2);
ctx.fill();
}
}
function updateTooltip(x, y, text) {
const el = document.getElementById('tooltip');
// Convert world coord to screen coord
const sx = x - camera.x;
const sy = y - camera.y;
el.style.left = sx + 'px';
el.style.top = sy + 'px';
el.textContent = text;
el.style.opacity = 1;
el.style.transform = 'translate(-50%, -160%) scale(1)';
}
// Minimap
function drawMinimap() {
// Clear
mmCtx.fillStyle = '#1b5e20';
mmCtx.fillRect(0,0,150,150);
const scale = 150 / WORLD_W;
// Draw Roads
mmCtx.fillStyle = '#a1887f';
worldObjects.filter(o => o.type === 'road').forEach(r => {
mmCtx.fillRect(r.x*scale, r.y*scale, Math.max(r.w*scale, 2), Math.max(r.h*scale,2));
});
// Draw Buildings
mmCtx.fillStyle = '#bf360c';
worldObjects.filter(o => o.type === 'building').forEach(b => {
mmCtx.fillRect(b.x*scale, b.y*scale, b.w*scale, b.h*scale);
});
// Draw Player
mmCtx.fillStyle = '#ffd166';
mmCtx.beginPath();
mmCtx.arc(player.x*scale, player.y*scale, 3, 0, Math.PI*2);
mmCtx.fill();
// Viewport rect
mmCtx.strokeStyle = 'white';
mmCtx.lineWidth = 1;
mmCtx.strokeRect(camera.x*scale, camera.y*scale, width*scale, height*scale);
}
// Init
function loop() {
update();
draw();
requestAnimationFrame(loop);
}
window.onload = () => {
width = window.innerWidth;
height = window.innerHeight;
canvas.width = width;
canvas.height = height;
mmCanvas.width = 150;
mmCanvas.height = 150;
initWorld();
setupInputs();
document.getElementById('loader').style.opacity = 0;
setTimeout(()=>document.getElementById('loader').remove(), 500);
loop();
};
window.addEventListener('resize', () => {
width = window.innerWidth;
height = window.innerHeight;
canvas.width = width;
canvas.height = height;
});
</script>
</body>
</html>