<offset>: Parse, evaluate, and example test case. (Fixes issue #3)
[python-bulletml.git] / bulletml / parser.py
1 """BulletML parser.
2
3 This is based on the format described at
4 http://www.asahi-net.or.jp/~cs8k-cyu/bulletml/bulletml_ref_e.html.
5
6 Unless you are adding support for new tags, the only class you should
7 care about in here is BulletML.
8 """
9
10 from __future__ import division
11
12 import math
13
14 from xml.etree.ElementTree import ElementTree
15
16 try:
17 from cStringIO import StringIO
18 except ImportError:
19 from StringIO import StringIO
20
21 from bulletml.errors import Error
22 from bulletml.expr import NumberDef, INumberDef
23
24 __all_ = ["ParseError", "BulletML"]
25
26 class ParseError(Error):
27 """Raised when an error occurs parsing the XML structure."""
28 pass
29
30 def realtag(element):
31 """Strip namespace poop off the front of a tag."""
32 try:
33 return element.tag.rsplit('}', 1)[1]
34 except ValueError:
35 return element.tag
36
37 class ParamList(object):
38 """List of parameter definitions."""
39
40 def __init__(self, params=()):
41 self.params = list(params)
42
43 @classmethod
44 def FromElement(cls, doc, element):
45 """Construct using an ElementTree-style element."""
46 return cls([NumberDef(subelem.text) for subelem in element
47 if realtag(subelem) == "param"])
48
49 def __call__(self, params, rank):
50 return [param(params, rank) for param in self.params]
51
52 def __repr__(self):
53 return "%s(%r)" % (type(self).__name__, self.params)
54
55 class Direction(object):
56 """Raw direction value."""
57
58 VALID_TYPES = ["relative", "absolute", "aim", "sequence"]
59
60 def __init__(self, type, value):
61 if type not in self.VALID_TYPES:
62 raise ValueError("invalid type %r" % type)
63 self.type = type
64 self.value = value
65
66 @classmethod
67 def FromElement(cls, doc, element, default="absolute"):
68 """Construct using an ElementTree-style element."""
69 return cls(element.get("type", default), NumberDef(element.text))
70
71 def __call__(self, params, rank):
72 return (math.radians(self.value(params, rank)), self.type)
73
74 def __repr__(self):
75 return "%s(%r, type=%r)" % (
76 type(self).__name__, self.value, self.type)
77
78 class ChangeDirection(object):
79 """Direction change over time."""
80
81 def __init__(self, term, direction):
82 self.term = term
83 self.direction = direction
84
85 @classmethod
86 def FromElement(cls, doc, element):
87 """Construct using an ElementTree-style element."""
88 for subelem in element.getchildren():
89 tag = realtag(subelem)
90 if tag == "direction":
91 direction = Direction.FromElement(doc, subelem)
92 elif tag == "term":
93 term = INumberDef(subelem.text)
94 try:
95 return cls(term, direction)
96 except UnboundLocalError as exc:
97 raise ParseError(str(exc))
98
99 def __call__(self, params, rank):
100 return self.term(params, rank), self.direction(params, rank)
101
102 def __repr__(self):
103 return "%s(term=%r, direction=%r)" % (
104 type(self).__name__, self.term, self.direction)
105
106 class Speed(object):
107 """Raw speed value."""
108
109 VALID_TYPES = ["relative", "absolute", "sequence"]
110
111 def __init__(self, type, value):
112 if type not in self.VALID_TYPES:
113 raise ValueError("invalid type %r" % type)
114 self.type = type
115 self.value = value
116
117 @classmethod
118 def FromElement(cls, doc, element):
119 """Construct using an ElementTree-style element."""
120 return cls(element.get("type", "absolute"), NumberDef(element.text))
121
122 def __call__(self, params, rank):
123 return (self.value(params, rank), self.type)
124
125 def __repr__(self):
126 return "%s(%r, type=%r)" % (type(self).__name__, self.value, self.type)
127
128 class ChangeSpeed(object):
129 """Speed change over time."""
130
131 def __init__(self, term, speed):
132 self.term = term
133 self.speed = speed
134
135 @classmethod
136 def FromElement(cls, doc, element):
137 """Construct using an ElementTree-style element."""
138 for subelem in element.getchildren():
139 tag = realtag(subelem)
140 if tag == "speed":
141 speed = Speed.FromElement(doc, subelem)
142 elif tag == "term":
143 term = INumberDef(subelem.text)
144 try:
145 return cls(term, speed)
146 except UnboundLocalError as exc:
147 raise ParseError(str(exc))
148
149 def __call__(self, params, rank):
150 return self.term(params, rank), self.speed(params, rank)
151
152 def __repr__(self):
153 return "%s(term=%r, speed=%r)" % (
154 type(self).__name__, self.term, self.speed)
155
156 class Wait(object):
157 """Wait for some frames."""
158
159 def __init__(self, frames):
160 self.frames = frames
161
162 @classmethod
163 def FromElement(cls, doc, element):
164 """Construct using an ElementTree-style element."""
165 return cls(INumberDef(element.text))
166
167 def __call__(self, params, rank):
168 return self.frames(params, rank)
169
170 def __repr__(self):
171 return "%s(%r)" % (type(self).__name__, self.frames)
172
173 class Vanish(object):
174 """Make the owner disappear."""
175
176 def __init__(self):
177 pass
178
179 @classmethod
180 def FromElement(cls, doc, element):
181 """Construct using an ElementTree-style element."""
182 return cls()
183
184 def __repr__(self):
185 return "%s()" % (type(self).__name__)
186
187 class Repeat(object):
188 """Repeat an action definition."""
189
190 def __init__(self, times, action):
191 self.times = times
192 self.action = action
193
194 @classmethod
195 def FromElement(cls, doc, element):
196 """Construct using an ElementTree-style element."""
197 for subelem in element.getchildren():
198 tag = realtag(subelem)
199 if tag == "times":
200 times = INumberDef(subelem.text)
201 elif tag == "action":
202 action = ActionDef.FromElement(doc, subelem)
203 elif tag == "actionRef":
204 action = ActionRef.FromElement(doc, subelem)
205 try:
206 return cls(times, action)
207 except UnboundLocalError as exc:
208 raise ParseError(str(exc))
209
210 def __call__(self, params, rank):
211 return self.times(params, rank), self.action(params, rank)
212
213 def __repr__(self):
214 return "%s(%r, %r)" % (type(self).__name__, self.times, self.action)
215
216 class Accel(object):
217 """Accelerate over some time."""
218
219 horizontal = None
220 vertical = None
221
222 def __init__(self, term, horizontal=None, vertical=None):
223 self.term = term
224 self.horizontal = horizontal
225 self.vertical = vertical
226
227 @classmethod
228 def FromElement(cls, doc, element):
229 """Construct using an ElementTree-style element."""
230 horizontal = None
231 vertical = None
232
233 for subelem in element.getchildren():
234 tag = realtag(subelem)
235 if tag == "term":
236 term = INumberDef(subelem.text)
237 elif tag == "horizontal":
238 horizontal = Speed.FromElement(doc, subelem)
239 elif tag == "vertical":
240 vertical = Speed.FromElement(doc, subelem)
241
242 try:
243 return cls(term, horizontal, vertical)
244 except AttributeError:
245 raise ParseError
246
247 def __call__(self, params, rank):
248 frames = self.term(params, rank)
249 horizontal = self.horizontal and self.horizontal(params, rank)
250 vertical = self.vertical and self.vertical(params, rank)
251 return frames, horizontal, vertical
252
253 def __repr__(self):
254 return "%s(%r, horizontal=%r, vertical=%r)" % (
255 type(self).__name__, self.term, self.horizontal, self.vertical)
256
257 class BulletDef(object):
258 """Bullet definition."""
259
260 direction = None
261 speed = None
262
263 def __init__(self, actions=[], direction=None, speed=None):
264 self.direction = direction
265 self.speed = speed
266 self.actions = list(actions)
267
268 @classmethod
269 def FromElement(cls, doc, element):
270 """Construct using an ElementTree-style element."""
271 actions = []
272 speed = None
273 direction = None
274 for subelem in element.getchildren():
275 tag = realtag(subelem)
276 if tag == "direction":
277 direction = Direction.FromElement(doc, subelem)
278 elif tag == "speed":
279 speed = Speed.FromElement(doc, subelem)
280 elif tag == "action":
281 actions.append(ActionDef.FromElement(doc, subelem))
282 elif tag == "actionRef":
283 actions.append(ActionRef.FromElement(doc, subelem))
284 dfn = cls(actions, direction, speed)
285 doc.bullets[element.get("label")] = dfn
286 return dfn
287
288 def __call__(self, params, rank):
289 actions = [action(params, rank) for action in self.actions]
290 return (
291 self.direction and self.direction(params, rank),
292 self.speed and self.speed(params, rank),
293 actions)
294
295 def __repr__(self):
296 return "%s(direction=%r, speed=%r, actions=%r)" % (
297 type(self).__name__, self.direction, self.speed, self.actions)
298
299 class BulletRef(object):
300 """Create a bullet by name with parameters."""
301
302 def __init__(self, bullet, params=None):
303 self.bullet = bullet
304 self.params = params or ParamList()
305
306 @classmethod
307 def FromElement(cls, doc, element):
308 """Construct using an ElementTree-style element."""
309 bullet = cls(element.get("label"), ParamList.FromElement(doc, element))
310 doc._bullet_refs.append(bullet)
311 return bullet
312
313 def __call__(self, params, rank):
314 return self.bullet(self.params(params, rank), rank)
315
316 def __repr__(self):
317 return "%s(params=%r, bullet=%r)" % (
318 type(self).__name__, self.params, self.bullet)
319
320 class ActionDef(object):
321 """Action definition.
322
323 To support parsing new actions, add tags to
324 ActionDef.CONSTRUCTORS. It maps tag names to classes with a
325 FromElement classmethod, which take the BulletML instance and
326 ElementTree element as arguments.
327 """
328
329 # This is self-referential, so it's filled in later.
330 CONSTRUCTORS = dict()
331
332 def __init__(self, actions):
333 self.actions = list(actions)
334
335 @classmethod
336 def FromElement(cls, doc, element):
337 """Construct using an ElementTree-style element."""
338 actions = []
339 for subelem in element.getchildren():
340 tag = realtag(subelem)
341 try:
342 ctr = cls.CONSTRUCTORS[tag]
343 except KeyError:
344 continue
345 else:
346 actions.append(ctr.FromElement(doc, subelem))
347 dfn = cls(actions)
348 doc.actions[element.get("label")] = dfn
349 return dfn
350
351 def __call__(self, params, rank):
352 return self.actions, params
353
354 def __repr__(self):
355 return "%s(%r)" % (type(self).__name__, self.actions)
356
357 class ActionRef(object):
358 """Run an action by name with parameters."""
359
360 def __init__(self, action, params=None):
361 self.action = action
362 self.params = params or ParamList()
363
364 @classmethod
365 def FromElement(cls, doc, element):
366 """Construct using an ElementTree-style element."""
367 action = cls(element.get("label"), ParamList.FromElement(doc, element))
368 doc._action_refs.append(action)
369 return action
370
371 def __call__(self, params, rank):
372 return self.action(self.params(params, rank), rank)
373
374 def __repr__(self):
375 return "%s(params=%r, action=%r)" % (
376 type(self).__name__, self.params, self.action)
377
378 class Offset(object):
379 """Provide an offset to a bullet's initial position."""
380
381 VALID_TYPES = ["relative", "absolute"]
382
383 def __init__(self, type, x, y):
384 if type not in self.VALID_TYPES:
385 raise ValueError("invalid type %r" % type)
386 self.type = type
387 self.x = x
388 self.y = y
389
390 @classmethod
391 def FromElement(cls, doc, element):
392 """Construct using an ElementTree-style element."""
393 type = element.get("type", "relative")
394 x = None
395 y = None
396 for subelem in element:
397 tag = realtag(subelem)
398 if tag == "x":
399 x = NumberDef(subelem.text)
400 elif tag == "y":
401 y = NumberDef(subelem.text)
402 return cls(type, x, y)
403
404 def __call__(self, params, rank):
405 return (self.x(params, rank) if self.x else 0,
406 self.y(params, rank) if self.y else 0)
407
408 class FireDef(object):
409 """Fire definition (creates a bullet)."""
410
411 def __init__(self, bullet, direction=None, speed=None, offset=None):
412 self.bullet = bullet
413 self.direction = direction
414 self.speed = speed
415 self.offset = offset
416
417 @classmethod
418 def FromElement(cls, doc, element):
419 """Construct using an ElementTree-style element."""
420 direction = None
421 speed = None
422 offset = None
423
424 for subelem in element.getchildren():
425 tag = realtag(subelem)
426 if tag == "direction":
427 direction = Direction.FromElement(doc, subelem, "aim")
428 elif tag == "speed":
429 speed = Speed.FromElement(doc, subelem)
430 elif tag == "bullet":
431 bullet = BulletDef.FromElement(doc, subelem)
432 elif tag == "bulletRef":
433 bullet = BulletRef.FromElement(doc, subelem)
434 elif tag == "offset":
435 offset = Offset.FromElement(doc, subelem)
436 try:
437 fire = cls(bullet, direction, speed, offset)
438 except UnboundLocalError as exc:
439 raise ParseError(str(exc))
440 else:
441 doc.fires[element.get("label")] = fire
442 return fire
443
444 def __call__(self, params, rank):
445 direction, speed, actions = self.bullet(params, rank)
446 if self.direction:
447 direction = self.direction(params, rank)
448 if self.speed:
449 speed = self.speed(params, rank)
450 return direction, speed, actions, self.offset
451
452 def __repr__(self):
453 return "%s(direction=%r, speed=%r, bullet=%r)" % (
454 type(self).__name__, self.direction, self.speed, self.bullet)
455
456 class FireRef(object):
457 """Fire a bullet by name with parameters."""
458
459 def __init__(self, fire, params=None):
460 self.fire = fire
461 self.params = params or ParamList()
462
463 @classmethod
464 def FromElement(cls, doc, element):
465 """Construct using an ElementTree-style element."""
466 fired = cls(element.get("label"), ParamList.FromElement(doc, element))
467 doc._fire_refs.append(fired)
468 return fired
469
470 def __call__(self, params, rank):
471 return self.fire(self.params(params, rank), rank)
472
473 def __repr__(self):
474 return "%s(params=%r, fire=%r)" % (
475 type(self).__name__, self.params, self.fire)
476
477 class BulletML(object):
478 """BulletML document.
479
480 A BulletML document is a collection of bullets, actions, and
481 firings, as well as a base game type.
482
483 You can add tags to the BulletML.CONSTRUCTORS dictionary to extend
484 its parsing. It maps tag names to classes with a FromElement
485 classmethod, which take the BulletML instance and ElementTree
486 element as arguments.
487
488 """
489
490 CONSTRUCTORS = dict(
491 bullet=BulletDef,
492 action=ActionDef,
493 fire=FireDef,
494 )
495
496 def __init__(self, type="none", bullets=None, fires=None, actions=None):
497 self.type = type
498 self.bullets = {} if bullets is None else bullets
499 self.actions = {} if actions is None else actions
500 self.fires = {} if fires is None else fires
501
502 @classmethod
503 def FromDocument(cls, source):
504 """Return a BulletML instance based on a string or file-like."""
505 if isinstance(source, (str, unicode)):
506 source = StringIO(source)
507
508 tree = ElementTree()
509 root = tree.parse(source)
510
511 self = cls(type=root.get("type", "none"))
512
513 self._bullet_refs = []
514 self._action_refs = []
515 self._fire_refs = []
516
517 for element in root.getchildren():
518 tag = realtag(element)
519 if tag in self.CONSTRUCTORS:
520 self.CONSTRUCTORS[tag].FromElement(self, element)
521
522 try:
523 for ref in self._bullet_refs:
524 ref.bullet = self.bullets[ref.bullet]
525 for ref in self._fire_refs:
526 ref.fire = self.fires[ref.fire]
527 for ref in self._action_refs:
528 ref.action = self.actions[ref.action]
529 except KeyError as exc:
530 raise ParseError("unknown reference %s" % exc)
531
532 del(self._bullet_refs)
533 del(self._action_refs)
534 del(self._fire_refs)
535
536 self.bullets.pop(None, None)
537 self.actions.pop(None, None)
538 self.fires.pop(None, None)
539
540 return self
541
542 @property
543 def top(self):
544 """Get a list of all top-level actions."""
545 return [dfn for name, dfn in self.actions.iteritems()
546 if name and name.startswith("top")]
547
548 def __repr__(self):
549 return "%s(type=%r, bullets=%r, actions=%r, fires=%r)" % (
550 type(self).__name__, self.type, self.bullets, self.actions,
551 self.fires)
552
553 ActionDef.CONSTRUCTORS = dict(
554 repeat=Repeat,
555 fire=FireDef,
556 fireRef=FireRef,
557 changeSpeed=ChangeSpeed,
558 changeDirection=ChangeDirection,
559 accel=Accel,
560 wait=Wait,
561 vanish=Vanish,
562 action=ActionDef,
563 actionRef=ActionRef)